♻️ Refined custom apps
This commit is contained in:
		| @@ -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!; | ||||
| } | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user