♻️ Refined custom apps

This commit is contained in:
2025-06-29 20:32:08 +08:00
parent cdeed3c318
commit c4ea15097e
10 changed files with 4448 additions and 141 deletions

View File

@ -1,5 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using NodaTime;
namespace DysonNetwork.Sphere.Developer;
@ -12,17 +15,38 @@ public enum CustomAppStatus
Suspended
}
public class CustomApp : ModelBase
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;
public Instant? VerifiedAt { get; set; }
[MaxLength(4096)] public string? VerifiedAs { get; set; }
// OIDC/OAuth specific properties
[MaxLength(4096)] public string? LogoUri { get; set; }
[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 : ModelBase
{
[MaxLength(8192)] public string? HomePage { get; set; }
[MaxLength(8192)] public string? PrivacyPolicy { get; set; }
[MaxLength(8192)] public string? TermsOfService { get; set; }
}
public class CustomAppOauthConfig : ModelBase
{
[MaxLength(1024)] public string? ClientUri { get; set; }
[MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
[MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; }
@ -30,11 +54,6 @@ public class CustomApp : ModelBase
[MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
public bool RequirePkce { get; set; } = true;
public bool AllowOfflineAccess { get; set; } = false;
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
public Guid PublisherId { get; set; }
public Publisher.Publisher Developer { get; set; } = null!;
}
public class CustomAppSecret : ModelBase
@ -44,7 +63,7 @@ public class CustomAppSecret : ModelBase
[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,63 +1,129 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Developer;
[ApiController]
[Route("/developers/apps")]
public class CustomAppController(CustomAppService customAppService, PublisherService ps) : ControllerBase
[Route("/developers/{pubName}/apps")]
public class CustomAppController(CustomAppService customApps, PublisherService ps) : ControllerBase
{
public record CreateAppRequest(Guid PublisherId, string Name, string Slug);
public record UpdateAppRequest(string Name, string Slug);
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([FromQuery] Guid publisherId)
public async Task<IActionResult> ListApps([FromRoute] string pubName)
{
var apps = await customAppService.GetAppsByPublisherAsync(publisherId);
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(Guid id)
public async Task<IActionResult> GetApp([FromRoute] string pubName, Guid id)
{
var app = await customAppService.GetAppAsync(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]
public async Task<IActionResult> CreateApp([FromBody] CreateAppRequest request)
[Authorize]
public async Task<IActionResult> CreateApp([FromRoute] string pubName, [FromBody] CustomAppRequest request)
{
var app = await customAppService.CreateAppAsync(request.PublisherId, request.Name, request.Slug);
if (app == null)
if (HttpContext.Items["CurrentUser"] is not Account.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, 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
{
return BadRequest("Invalid publisher ID or missing developer feature flag");
var app = await customApps.CreateAppAsync(publisher, request);
return Ok(app);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
return CreatedAtAction(nameof(GetApp), new { id = app.Id }, app);
}
[HttpPatch("{id:guid}")]
public async Task<IActionResult> UpdateApp(Guid id, [FromBody] UpdateAppRequest request)
[Authorize]
public async Task<IActionResult> UpdateApp(
[FromRoute] string pubName,
[FromRoute] Guid id,
[FromBody] CustomAppRequest request
)
{
var app = await customAppService.UpdateAppAsync(id, request.Name, request.Slug);
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, 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);
}
return Ok(app);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteApp(Guid id)
[Authorize]
public async Task<IActionResult> DeleteApp(
[FromRoute] string pubName,
[FromRoute] Guid id
)
{
var result = await customAppService.DeleteAppAsync(id);
if (!result)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
if (!await ps.IsMemberWithRole(publisher.Id, 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,29 +1,58 @@
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Developer;
public class CustomAppService(AppDatabase db, PublisherService ps)
public class CustomAppService(AppDatabase db, FileReferenceService fileRefService)
{
public async Task<CustomApp?> CreateAppAsync(Guid publisherId, string name, string slug)
public async Task<CustomApp?> CreateAppAsync(
Publisher.Publisher pub,
CustomAppController.CustomAppRequest request
)
{
var publisher = await db.Publishers.FirstOrDefaultAsync(p => p.Id == publisherId);
if (publisher == null)
{
return null;
}
if (!await ps.HasFeature(publisherId, "developer"))
{
return null;
}
var app = new CustomApp
{
Name = name,
Slug = slug,
PublisherId = publisher.Id
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 db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
if (picture is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Picture = picture.ToReferenceObject();
// Create a new reference
await fileRefService.CreateReferenceAsync(
picture.Id,
"custom-apps.picture",
app.ResourceIdentifier
);
}
if (request.BackgroundId is not null)
{
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
if (background is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
app.Background = background.ToReferenceObject();
// Create a new reference
await fileRefService.CreateReferenceAsync(
background.Id,
"custom-apps.background",
app.ResourceIdentifier
);
}
db.CustomApps.Add(app);
await db.SaveChangesAsync();
@ -31,9 +60,12 @@ public class CustomAppService(AppDatabase db, PublisherService ps)
return app;
}
public async Task<CustomApp?> GetAppAsync(Guid id)
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? publisherId = null)
{
return await db.CustomApps.FindAsync(id);
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)
@ -41,17 +73,60 @@ public class CustomAppService(AppDatabase db, PublisherService ps)
return await db.CustomApps.Where(a => a.PublisherId == publisherId).ToListAsync();
}
public async Task<CustomApp?> UpdateAppAsync(Guid id, string name, string slug)
public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
{
var app = await db.CustomApps.FindAsync(id);
if (app == null)
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)
{
return null;
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
if (picture is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
if (app.Picture is not null)
await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier, "custom-apps.picture");
app.Picture = picture.ToReferenceObject();
// Create a new reference
await fileRefService.CreateReferenceAsync(
picture.Id,
"custom-apps.picture",
app.ResourceIdentifier
);
}
if (request.BackgroundId is not null)
{
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
if (background is null)
throw new InvalidOperationException("Invalid picture id, unable to find the file on cloud.");
if (app.Background is not null)
await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier, "custom-apps.background");
app.Background = background.ToReferenceObject();
// Create a new reference
await fileRefService.CreateReferenceAsync(
background.Id,
"custom-apps.background",
app.ResourceIdentifier
);
}
app.Name = name;
app.Slug = slug;
db.Update(app);
await db.SaveChangesAsync();
return app;
@ -67,6 +142,8 @@ public class CustomAppService(AppDatabase db, PublisherService ps)
db.CustomApps.Remove(app);
await db.SaveChangesAsync();
await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier);
return true;
}