♻️ Extract the Developer to new service, add PublisherServiceGrpc
This commit is contained in:
68
DysonNetwork.Develop/Identity/CustomApp.cs
Normal file
68
DysonNetwork.Develop/Identity/CustomApp.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
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!;
|
||||
}
|
128
DysonNetwork.Develop/Identity/CustomAppController.cs
Normal file
128
DysonNetwork.Develop/Identity/CustomAppController.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
171
DysonNetwork.Develop/Identity/CustomAppService.cs
Normal file
171
DysonNetwork.Develop/Identity/CustomAppService.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
153
DysonNetwork.Develop/Identity/DeveloperController.cs
Normal file
153
DysonNetwork.Develop/Identity/DeveloperController.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/developers")]
|
||||
public class DeveloperController(
|
||||
AppDatabase db,
|
||||
PublisherService.PublisherServiceClient 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; }
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user