♻️ Extract the Developer to new service, add PublisherServiceGrpc

This commit is contained in:
2025-08-07 17:16:38 +08:00
parent f1ea7c1c5a
commit 00cdd1bc5d
35 changed files with 602 additions and 101 deletions

View File

@@ -10,7 +10,7 @@ namespace DysonNetwork.Sphere.Activity;
public class ActivityService(
AppDatabase db,
PublisherService pub,
Publisher.PublisherService pub,
PostService ps,
DiscoveryService ds,
AccountService.AccountServiceClient accounts

View File

@@ -1,7 +1,6 @@
using System.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Realm;
@@ -61,9 +60,6 @@ public class AppDatabase(
public DbSet<StickerPack> StickerPacks { get; set; }
public DbSet<StickerPackOwnership> StickerPackOwnerships { get; set; }
public DbSet<CustomApp> CustomApps { get; set; }
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; }
public DbSet<WebReader.WebArticle> WebArticles { get; set; }
public DbSet<WebReader.WebFeed> WebFeeds { get; set; }
@@ -103,16 +99,6 @@ public class AppDatabase(
.HasIndex(p => p.SearchVector)
.HasMethod("GIN");
modelBuilder.Entity<CustomAppSecret>()
.HasIndex(s => s.Secret)
.IsUnique();
modelBuilder.Entity<CustomApp>()
.HasMany(c => c.Secrets)
.WithOne(s => s.App)
.HasForeignKey(s => s.AppId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post.Post>()
.HasOne(p => p.RepliedPost)
.WithMany()

View File

@@ -1,68 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using NodaTime;
namespace DysonNetwork.Sphere.Developer;
public enum CustomAppStatus
{
Developing,
Staging,
Production,
Suspended
}
public class CustomApp : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Slug { get; set; } = null!;
[MaxLength(1024)] public string Name { get; set; } = null!;
[MaxLength(4096)] public string? Description { get; set; }
public CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
public Guid PublisherId { get; set; }
public Publisher.Publisher Developer { get; set; } = null!;
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
}
public class CustomAppLinks
{
[MaxLength(8192)] public string? HomePage { get; set; }
[MaxLength(8192)] public string? PrivacyPolicy { get; set; }
[MaxLength(8192)] public string? TermsOfService { get; set; }
}
public class CustomAppOauthConfig
{
[MaxLength(1024)] public string? ClientUri { get; set; }
[MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
[MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; }
[MaxLength(256)] public string[]? AllowedScopes { get; set; } = ["openid", "profile", "email"];
[MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
public bool RequirePkce { get; set; } = true;
public bool AllowOfflineAccess { get; set; } = false;
}
public class CustomAppSecret : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Secret { get; set; } = null!;
[MaxLength(4096)] public string? Description { get; set; } = null!;
public Instant? ExpiredAt { get; set; }
public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth
public Guid AppId { get; set; }
public CustomApp App { get; set; } = null!;
}

View File

@@ -1,129 +0,0 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Developer;
[ApiController]
[Route("/api/developers/{pubName}/apps")]
public class CustomAppController(CustomAppService customApps, PublisherService ps) : ControllerBase
{
public record CustomAppRequest(
[MaxLength(1024)] string? Slug,
[MaxLength(1024)] string? Name,
[MaxLength(4096)] string? Description,
string? PictureId,
string? BackgroundId,
CustomAppStatus? Status,
CustomAppLinks? Links,
CustomAppOauthConfig? OauthConfig
);
[HttpGet]
public async Task<IActionResult> ListApps([FromRoute] string pubName)
{
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
var apps = await customApps.GetAppsByPublisherAsync(publisher.Id);
return Ok(apps);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetApp([FromRoute] string pubName, Guid id)
{
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
var app = await customApps.GetAppAsync(id, publisherId: publisher.Id);
if (app == null)
return NotFound();
return Ok(app);
}
[HttpPost]
[Authorize]
public async Task<IActionResult> CreateApp([FromRoute] string pubName, [FromBody] CustomAppRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
return BadRequest("Name and slug are required");
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to create a custom app");
if (!await ps.HasFeature(publisher.Id, PublisherFeatureFlag.Develop))
return StatusCode(403, "Publisher must be a developer to create a custom app");
try
{
var app = await customApps.CreateAppAsync(publisher, request);
return Ok(app);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPatch("{id:guid}")]
[Authorize]
public async Task<IActionResult> UpdateApp(
[FromRoute] string pubName,
[FromRoute] Guid id,
[FromBody] CustomAppRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to update a custom app");
var app = await customApps.GetAppAsync(id, publisherId: publisher.Id);
if (app == null)
return NotFound();
try
{
app = await customApps.UpdateAppAsync(app, request);
return Ok(app);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("{id:guid}")]
[Authorize]
public async Task<IActionResult> DeleteApp(
[FromRoute] string pubName,
[FromRoute] Guid id
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to delete a custom app");
var app = await customApps.GetAppAsync(id, publisherId: publisher.Id);
if (app == null)
return NotFound();
var result = await customApps.DeleteAppAsync(id);
if (!result)
return NotFound();
return NoContent();
}
}

View File

@@ -1,172 +0,0 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Developer;
public class CustomAppService(
AppDatabase db,
FileReferenceService.FileReferenceServiceClient fileRefs,
FileService.FileServiceClient files
)
{
public async Task<CustomApp?> CreateAppAsync(
Publisher.Publisher pub,
CustomAppController.CustomAppRequest request
)
{
var app = new CustomApp
{
Slug = request.Slug!,
Name = request.Name!,
Description = request.Description,
Status = request.Status ?? CustomAppStatus.Developing,
Links = request.Links,
OauthConfig = request.OauthConfig,
PublisherId = pub.Id
};
if (request.PictureId is not null)
{
var picture = await files.GetFileAsync(
new GetFileRequest
{
Id = request.PictureId
}
);
if (picture is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Picture = CloudFileReferenceObject.FromProtoValue(picture);
// Create a new reference
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
FileId = picture.Id,
Usage = "custom-apps.picture",
ResourceId = app.ResourceIdentifier
}
);
}
if (request.BackgroundId is not null)
{
var background = await files.GetFileAsync(
new GetFileRequest { Id = request.BackgroundId }
);
if (background is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Background = CloudFileReferenceObject.FromProtoValue(background);
// Create a new reference
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
FileId = background.Id,
Usage = "custom-apps.background",
ResourceId = app.ResourceIdentifier
}
);
}
db.CustomApps.Add(app);
await db.SaveChangesAsync();
return app;
}
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? publisherId = null)
{
var query = db.CustomApps.Where(a => a.Id == id).AsQueryable();
if (publisherId.HasValue)
query = query.Where(a => a.PublisherId == publisherId.Value);
return await query.FirstOrDefaultAsync();
}
public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId)
{
return await db.CustomApps.Where(a => a.PublisherId == publisherId).ToListAsync();
}
public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
{
if (request.Slug is not null)
app.Slug = request.Slug;
if (request.Name is not null)
app.Name = request.Name;
if (request.Description is not null)
app.Description = request.Description;
if (request.Status is not null)
app.Status = request.Status.Value;
if (request.Links is not null)
app.Links = request.Links;
if (request.OauthConfig is not null)
app.OauthConfig = request.OauthConfig;
if (request.PictureId is not null)
{
var picture = await files.GetFileAsync(
new GetFileRequest
{
Id = request.PictureId
}
);
if (picture is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Picture = CloudFileReferenceObject.FromProtoValue(picture);
// Create a new reference
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
FileId = picture.Id,
Usage = "custom-apps.picture",
ResourceId = app.ResourceIdentifier
}
);
}
if (request.BackgroundId is not null)
{
var background = await files.GetFileAsync(
new GetFileRequest { Id = request.BackgroundId }
);
if (background is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Background = CloudFileReferenceObject.FromProtoValue(background);
// Create a new reference
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
FileId = background.Id,
Usage = "custom-apps.background",
ResourceId = app.ResourceIdentifier
}
);
}
db.Update(app);
await db.SaveChangesAsync();
return app;
}
public async Task<bool> DeleteAppAsync(Guid id)
{
var app = await db.CustomApps.FindAsync(id);
if (app == null)
{
return false;
}
db.CustomApps.Remove(app);
await db.SaveChangesAsync();
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
{
ResourceId = app.ResourceIdentifier
}
);
return true;
}
}

View File

@@ -1,155 +0,0 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Developer;
[ApiController]
[Route("/api/developers")]
public class DeveloperController(
AppDatabase db,
PublisherService ps,
ActionLogService.ActionLogServiceClient als
)
: ControllerBase
{
[HttpGet("{name}")]
public async Task<ActionResult<Publisher.Publisher>> GetDeveloper(string name)
{
var publisher = await db.Publishers
.Where(e => e.Name == name)
.FirstOrDefaultAsync();
if (publisher is null) return NotFound();
return Ok(publisher);
}
[HttpGet("{name}/stats")]
public async Task<ActionResult<DeveloperStats>> GetDeveloperStats(string name)
{
var publisher = await db.Publishers
.Where(p => p.Name == name)
.FirstOrDefaultAsync();
if (publisher is null) return NotFound();
// Check if publisher has developer feature
var now = SystemClock.Instance.GetCurrentInstant();
var hasDeveloperFeature = await db.PublisherFeatures
.Where(f => f.PublisherId == publisher.Id)
.Where(f => f.Flag == PublisherFeatureFlag.Develop)
.Where(f => f.ExpiredAt == null || f.ExpiredAt > now)
.AnyAsync();
if (!hasDeveloperFeature) return NotFound("Not a developer account");
// Get custom apps count
var customAppsCount = await db.CustomApps
.Where(a => a.PublisherId == publisher.Id)
.CountAsync();
var stats = new DeveloperStats
{
TotalCustomApps = customAppsCount
};
return Ok(stats);
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Publisher.Publisher>>> ListJoinedDevelopers()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var members = await db.PublisherMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null)
.Include(e => e.Publisher)
.ToListAsync();
// Filter to only include publishers with the developer feature flag
var now = SystemClock.Instance.GetCurrentInstant();
var publisherIds = members.Select(m => m.Publisher.Id).ToList();
var developerPublisherIds = await db.PublisherFeatures
.Where(f => publisherIds.Contains(f.PublisherId))
.Where(f => f.Flag == PublisherFeatureFlag.Develop)
.Where(f => f.ExpiredAt == null || f.ExpiredAt > now)
.Select(f => f.PublisherId)
.ToListAsync();
return members
.Where(m => developerPublisherIds.Contains(m.Publisher.Id))
.Select(m => m.Publisher)
.ToList();
}
[HttpPost("{name}/enroll")]
[Authorize]
[RequiredPermission("global", "developers.create")]
public async Task<ActionResult<Publisher.Publisher>> EnrollDeveloperProgram(string name)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var publisher = await db.Publishers
.Where(p => p.Name == name)
.FirstOrDefaultAsync();
if (publisher is null) return NotFound();
// Check if the user is an owner of the publisher
var isOwner = await db.PublisherMembers
.AnyAsync(m =>
m.PublisherId == publisher.Id &&
m.AccountId == accountId &&
m.Role == PublisherMemberRole.Owner &&
m.JoinedAt != null);
if (!isOwner) return StatusCode(403, "You must be the owner of the publisher to join the developer program");
// Check if already has a developer feature
var now = SystemClock.Instance.GetCurrentInstant();
var hasDeveloperFeature = await db.PublisherFeatures
.AnyAsync(f =>
f.PublisherId == publisher.Id &&
f.Flag == PublisherFeatureFlag.Develop &&
(f.ExpiredAt == null || f.ExpiredAt > now));
if (hasDeveloperFeature) return BadRequest("Publisher is already in the developer program");
// Add developer feature flag
var feature = new PublisherFeature
{
PublisherId = publisher.Id,
Flag = PublisherFeatureFlag.Develop,
ExpiredAt = null
};
db.PublisherFeatures.Add(feature);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = "developers.enroll",
Meta =
{
{ "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Id.ToString()) },
{ "publisher_name", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Name) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return Ok(publisher);
}
public class DeveloperStats
{
public int TotalCustomApps { get; set; }
}
}

View File

@@ -36,6 +36,7 @@
</PackageReference>
<PackageReference Include="NodaTime" Version="3.2.2"/>
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/>
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/>

View File

@@ -4,7 +4,6 @@ using System.Collections.Generic;
using DysonNetwork.Shared.Data;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -397,20 +396,12 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<CustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<CustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<CloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");

View File

@@ -2,7 +2,6 @@
using System.Collections.Generic;
using DysonNetwork.Shared.Data;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
@@ -248,8 +247,6 @@ namespace DysonNetwork.Sphere.Migrations
picture = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
background = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
verification = table.Column<VerificationMark>(type: "jsonb", nullable: true),
oauth_config = table.Column<CustomAppOauthConfig>(type: "jsonb", nullable: true),
links = table.Column<CustomAppLinks>(type: "jsonb", nullable: true),
publisher_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),

View File

@@ -4,7 +4,6 @@ using System.Collections.Generic;
using DysonNetwork.Shared.Data;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -397,20 +396,12 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<CustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<CustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<CloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");

View File

@@ -5,7 +5,6 @@ using System.Text.Json;
using DysonNetwork.Shared.Data;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore;
@@ -399,20 +398,12 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<CustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<CustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<CloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");

View File

@@ -5,7 +5,6 @@ using System.Text.Json;
using DysonNetwork.Shared.Data;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore;
@@ -399,20 +398,12 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<CustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<CustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<CloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");

View File

@@ -5,7 +5,6 @@ using System.Text.Json;
using DysonNetwork.Shared.Data;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore;
@@ -396,20 +395,12 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<CustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<CustomAppOauthConfig>("OauthConfig")
.HasColumnType("jsonb")
.HasColumnName("oauth_config");
b.Property<CloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");

View File

@@ -11,7 +11,7 @@ namespace DysonNetwork.Sphere.PageData;
public class PostPageData(
AppDatabase db,
AccountService.AccountServiceClient accounts,
PublisherService pub,
Publisher.PublisherService pub,
PostService ps,
IConfiguration configuration
)

View File

@@ -11,7 +11,7 @@ namespace DysonNetwork.Sphere.Poll;
[ApiController]
[Route("/api/polls")]
public class PollController(AppDatabase db, PollService polls, PublisherService pub) : ControllerBase
public class PollController(AppDatabase db, PollService polls, Publisher.PublisherService pub) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<ActionResult<PollWithStats>> GetPoll(Guid id)
@@ -85,7 +85,7 @@ public class PollController(AppDatabase db, PollService polls, PublisherService
.FirstOrDefaultAsync(p => p.Id == id);
if (poll is null) return NotFound("Poll not found");
if (!await pub.IsMemberWithRole(poll.PublisherId, accountId, PublisherMemberRole.Viewer))
if (!await pub.IsMemberWithRole(poll.PublisherId, accountId, Publisher.PublisherMemberRole.Viewer))
return StatusCode(403, "You need to be a viewer to view this poll's feedback.");
var answerQuery = db.PollAnswers
@@ -186,7 +186,7 @@ public class PollController(AppDatabase db, PollService polls, PublisherService
var publisher = await pub.GetPublisherByName(pubName);
if (publisher is null) return BadRequest("Publisher was not found.");
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
if (!await pub.IsMemberWithRole(publisher.Id, accountId, Publisher.PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to create polls as this publisher.");
var poll = new Poll
@@ -231,7 +231,7 @@ public class PollController(AppDatabase db, PollService polls, PublisherService
if (poll == null) return NotFound("Poll not found");
// Check if user is an editor of the publisher that owns the poll
if (!await pub.IsMemberWithRole(poll.PublisherId, accountId, PublisherMemberRole.Editor))
if (!await pub.IsMemberWithRole(poll.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
return StatusCode(403, "You need to be at least an editor to update this poll.");
// Update properties if they are provided in the request
@@ -294,7 +294,7 @@ public class PollController(AppDatabase db, PollService polls, PublisherService
if (poll == null) return NotFound("Poll not found");
// Check if user is an editor of the publisher that owns the poll
if (!await pub.IsMemberWithRole(poll.PublisherId, accountId, PublisherMemberRole.Editor))
if (!await pub.IsMemberWithRole(poll.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
return StatusCode(403, "You need to be at least an editor to delete this poll.");
// Delete all answers for this poll

View File

@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using PublisherService = DysonNetwork.Sphere.Publisher.PublisherService;
namespace DysonNetwork.Sphere.Post;
@@ -301,13 +302,13 @@ public class PostController(
{
// Use the first personal publisher
publisher = await db.Publishers.FirstOrDefaultAsync(e =>
e.AccountId == accountId && e.Type == PublisherType.Individual);
e.AccountId == accountId && e.Type == Publisher.PublisherType.Individual);
}
else
{
publisher = await pub.GetPublisherByName(pubName);
if (publisher is null) return BadRequest("Publisher was not found.");
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
if (!await pub.IsMemberWithRole(publisher.Id, accountId, Publisher.PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to post as this publisher.");
}
@@ -473,14 +474,14 @@ public class PostController(
if (post is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await pub.IsMemberWithRole(post.Publisher.Id, accountId, PublisherMemberRole.Editor))
if (!await pub.IsMemberWithRole(post.Publisher.Id, accountId, Publisher.PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to edit this publisher's post.");
if (pubName is not null)
{
var publisher = await pub.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
if (!await pub.IsMemberWithRole(publisher.Id, accountId, Publisher.PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to transfer this post to this publisher.");
post.PublisherId = publisher.Id;
post.Publisher = publisher;
@@ -552,7 +553,8 @@ public class PostController(
.FirstOrDefaultAsync();
if (post is null) return NotFound();
if (!await pub.IsMemberWithRole(post.Publisher.Id, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
if (!await pub.IsMemberWithRole(post.Publisher.Id, Guid.Parse(currentUser.Id),
Publisher.PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to delete the publisher's post.");
await ps.DeletePostAsync(post);

View File

@@ -161,7 +161,7 @@ public partial class PostService(
{
var sender = post.Publisher;
using var scope = factory.CreateScope();
var pub = scope.ServiceProvider.GetRequiredService<PublisherService>();
var pub = scope.ServiceProvider.GetRequiredService<Publisher.PublisherService>();
var nty = scope.ServiceProvider.GetRequiredService<PusherService.PusherServiceClient>();
var accounts = scope.ServiceProvider.GetRequiredService<AccountService.AccountServiceClient>();
try
@@ -455,7 +455,7 @@ public partial class PostService(
_ = Task.Run(async () =>
{
using var scope = factory.CreateScope();
var pub = scope.ServiceProvider.GetRequiredService<PublisherService>();
var pub = scope.ServiceProvider.GetRequiredService<Publisher.PublisherService>();
var nty = scope.ServiceProvider.GetRequiredService<PusherService.PusherServiceClient>();
var accounts = scope.ServiceProvider.GetRequiredService<AccountService.AccountServiceClient>();
try

View File

@@ -5,6 +5,7 @@ using DysonNetwork.Shared.Data;
using DysonNetwork.Sphere.Post;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Serialization.Protobuf;
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
using Account = DysonNetwork.Pass.Account.Account;
@@ -49,6 +50,49 @@ public class Publisher : ModelBase, IIdentifiedResource
[NotMapped] public Account? Account { get; set; }
public string ResourceIdentifier => $"publisher:{Id}";
public Shared.Proto.Publisher ToProto(AppDatabase db)
{
var p = new Shared.Proto.Publisher()
{
Id = Id.ToString(),
Type = Type == PublisherType.Individual
? Shared.Proto.PublisherType.Individual
: Shared.Proto.PublisherType.Organizational,
Name = Name,
Nick = Nick,
Bio = Bio,
AccountId = AccountId?.ToString() ?? string.Empty,
RealmId = RealmId?.ToString() ?? string.Empty,
CreatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(CreatedAt.ToDateTimeOffset()),
UpdatedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(UpdatedAt.ToDateTimeOffset())
};
if (Picture is not null)
{
p.Picture = new Shared.Proto.CloudFile
{
Id = Picture.Id,
Name = Picture.Name,
MimeType = Picture.MimeType,
Hash = Picture.Hash,
Size = Picture.Size,
};
}
if (Background is not null)
{
p.Background = new Shared.Proto.CloudFile
{
Id = Background.Id,
Name = Background.Name,
MimeType = Background.MimeType,
Hash = Background.Hash,
Size = Background.Size,
};
}
return p;
}
}
public enum PublisherMemberRole
@@ -68,6 +112,25 @@ public class PublisherMember : ModelBase
public PublisherMemberRole Role { get; set; } = PublisherMemberRole.Viewer;
public Instant? JoinedAt { get; set; }
public Shared.Proto.PublisherMember ToProto()
{
return new Shared.Proto.PublisherMember()
{
PublisherId = PublisherId.ToString(),
AccountId = AccountId.ToString(),
Role = Role switch
{
PublisherMemberRole.Owner => Shared.Proto.PublisherMemberRole.Owner,
PublisherMemberRole.Manager => Shared.Proto.PublisherMemberRole.Manager,
PublisherMemberRole.Editor => Shared.Proto.PublisherMemberRole.Editor,
PublisherMemberRole.Viewer => Shared.Proto.PublisherMemberRole.Viewer,
_ => throw new ArgumentOutOfRangeException(nameof(Role), Role, null)
},
JoinedAt = JoinedAt?.ToTimestamp()
};
}
}
public enum PublisherSubscriptionStatus

View File

@@ -0,0 +1,68 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Publisher;
public class PublisherServiceGrpc(PublisherService service, AppDatabase db)
: Shared.Proto.PublisherService.PublisherServiceBase
{
public override async Task<GetPublisherResponse> GetPublisher(GetPublisherRequest request,
ServerCallContext context)
{
var p = await service.GetPublisherByName(request.Name);
if (p is null) throw new RpcException(new Status(StatusCode.NotFound, "publisher not found"));
return new GetPublisherResponse { Publisher = p.ToProto(db) };
}
public override async Task<ListPublishersResponse> ListPublishers(ListPublishersRequest request,
ServerCallContext context)
{
IQueryable<Publisher> query = db.Publishers.AsQueryable();
if (!string.IsNullOrWhiteSpace(request.AccountId) && Guid.TryParse(request.AccountId, out var aid))
{
var ids = await db.PublisherMembers.Where(m => m.AccountId == aid).Select(m => m.PublisherId).ToListAsync();
query = query.Where(p => ids.Contains(p.Id));
}
if (!string.IsNullOrWhiteSpace(request.RealmId) && Guid.TryParse(request.RealmId, out var rid))
{
query = query.Where(p => p.RealmId == rid);
}
var list = await query.Take(request.PageSize > 0 ? request.PageSize : 100).ToListAsync();
var resp = new ListPublishersResponse();
resp.Publishers.AddRange(list.Select(p => p.ToProto(db)));
resp.TotalSize = list.Count;
return resp;
}
public override async Task<ListPublisherMembersResponse> ListPublisherMembers(ListPublisherMembersRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.PublisherId, out var pid))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid publisher_id"));
var members = await service.GetPublisherMembers(pid);
var resp = new ListPublisherMembersResponse();
resp.Members.AddRange(members.Select(m => m.ToProto()));
return resp;
}
public override async Task<Google.Protobuf.WellKnownTypes.StringValue> SetPublisherFeatureFlag(
SetPublisherFeatureFlagRequest request, ServerCallContext context)
{
if (!Guid.TryParse(request.PublisherId, out var pid))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid publisher_id"));
await service.SetFeatureFlag(pid, request.Flag);
return new Google.Protobuf.WellKnownTypes.StringValue { Value = request.Flag };
}
public override async Task<HasPublisherFeatureResponse> HasPublisherFeature(HasPublisherFeatureRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.PublisherId, out var pid))
throw new RpcException(new Status(StatusCode.InvalidArgument, "invalid publisher_id"));
var enabled = await service.HasFeature(pid, request.Flag);
return new HasPublisherFeatureResponse { Enabled = enabled };
}
}

View File

@@ -1,6 +1,7 @@
using System.Net;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.HttpOverrides;
using Prometheus;
@@ -29,6 +30,7 @@ public static class ApplicationConfiguration
// Map gRPC services
app.MapGrpcService<WebSocketHandlerGrpc>();
app.MapGrpcService<PublisherServiceGrpc>();
return app;
}

View File

@@ -17,7 +17,6 @@ using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Sphere.WebReader;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Translation;
@@ -169,7 +168,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<WebReaderService>();
services.AddScoped<WebFeedService>();
services.AddScoped<DiscoveryService>();
services.AddScoped<CustomAppService>();
services.AddScoped<PollService>();
var translationProvider = configuration["Translation:Provider"]?.ToLower();

View File

@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -16,7 +15,7 @@ public class StickerController(AppDatabase db, StickerService st, FileService.Fi
private async Task<IActionResult> _CheckStickerPackPermissions(
Guid packId,
Account currentUser,
PublisherMemberRole requiredRole
Publisher.PublisherMemberRole requiredRole
)
{
var pack = await db.StickerPacks
@@ -149,7 +148,7 @@ public class StickerController(AppDatabase db, StickerService st, FileService.Fi
.FirstOrDefaultAsync(m => m.AccountId == accountId && m.PublisherId == pack.PublisherId);
if (member is null)
return StatusCode(403, "You are not a member of this publisher");
if (member.Role < PublisherMemberRole.Editor)
if (member.Role < Publisher.PublisherMemberRole.Editor)
return StatusCode(403, "You need to be at least an editor to update sticker packs");
if (request.Name is not null)
@@ -181,7 +180,7 @@ public class StickerController(AppDatabase db, StickerService st, FileService.Fi
.FirstOrDefaultAsync(m => m.AccountId == accountId && m.PublisherId == pack.PublisherId);
if (member is null)
return StatusCode(403, "You are not a member of this publisher");
if (member.Role < PublisherMemberRole.Editor)
if (member.Role < Publisher.PublisherMemberRole.Editor)
return StatusCode(403, "You need to be an editor to delete sticker packs");
await st.DeleteStickerPackAsync(pack);
@@ -242,7 +241,7 @@ public class StickerController(AppDatabase db, StickerService st, FileService.Fi
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, PublisherMemberRole.Editor);
var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, Publisher.PublisherMemberRole.Editor);
if (permissionCheck is not OkResult)
return permissionCheck;
@@ -278,7 +277,7 @@ public class StickerController(AppDatabase db, StickerService st, FileService.Fi
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, PublisherMemberRole.Editor);
var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, Publisher.PublisherMemberRole.Editor);
if (permissionCheck is not OkResult)
return permissionCheck;
@@ -309,7 +308,7 @@ public class StickerController(AppDatabase db, StickerService st, FileService.Fi
if (request.ImageId is null)
return BadRequest("Image is required.");
var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, PublisherMemberRole.Editor);
var permissionCheck = await _CheckStickerPackPermissions(packId, currentUser, Publisher.PublisherMemberRole.Editor);
if (permissionCheck is not OkResult)
return permissionCheck;

View File

@@ -9,7 +9,7 @@ namespace DysonNetwork.Sphere.WebReader;
[Authorize]
[ApiController]
[Route("/api/publishers/{pubName}/feeds")]
public class WebFeedController(WebFeedService webFeed, PublisherService ps) : ControllerBase
public class WebFeedController(WebFeedService webFeed, Publisher.PublisherService ps) : ControllerBase
{
public record WebFeedRequest(
[MaxLength(8192)] string? Url,
@@ -53,7 +53,7 @@ public class WebFeedController(WebFeedService webFeed, PublisherService ps) : Co
if (publisher is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await ps.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
if (!await ps.IsMemberWithRole(publisher.Id, accountId, Publisher.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to create a web feed");
var feed = await webFeed.CreateWebFeedAsync(publisher, request);
@@ -70,7 +70,7 @@ public class WebFeedController(WebFeedService webFeed, PublisherService ps) : Co
if (publisher is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await ps.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
if (!await ps.IsMemberWithRole(publisher.Id, accountId, Publisher.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to update a web feed");
var feed = await webFeed.GetFeedAsync(id, publisherId: publisher.Id);
@@ -91,7 +91,7 @@ public class WebFeedController(WebFeedService webFeed, PublisherService ps) : Co
if (publisher is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await ps.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
if (!await ps.IsMemberWithRole(publisher.Id, accountId, Publisher.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to delete a web feed");
var feed = await webFeed.GetFeedAsync(id, publisherId: publisher.Id);
@@ -114,7 +114,7 @@ public class WebFeedController(WebFeedService webFeed, PublisherService ps) : Co
if (publisher is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await ps.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
if (!await ps.IsMemberWithRole(publisher.Id, accountId, Publisher.PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the publisher to scrape a web feed");
var feed = await webFeed.GetFeedAsync(id, publisherId: publisher.Id);