Complete the develop service

This commit is contained in:
2025-08-07 20:30:34 +08:00
parent 00cdd1bc5d
commit ee8e9df12e
12 changed files with 313 additions and 143 deletions

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Develop.Identity;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Develop;
@@ -7,6 +8,11 @@ public class AppDatabase(
IConfiguration configuration
) : DbContext(options)
{
public DbSet<Developer> Developers { get; set; }
public DbSet<CustomApp> CustomApps { get; set; }
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(

View File

@@ -31,8 +31,8 @@ public class CustomApp : ModelBase, IIdentifiedResource
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
public Guid PublisherId { get; set; }
public Publisher.Publisher Developer { get; set; } = null!;
public Guid DeveloperId { get; set; }
public Developer Developer { get; set; } = null!;
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
}

View File

@@ -7,7 +7,7 @@ namespace DysonNetwork.Develop.Identity;
[ApiController]
[Route("/api/developers/{pubName}/apps")]
public class CustomAppController(CustomAppService customApps, PublisherService ps) : ControllerBase
public class CustomAppController(CustomAppService customApps, DeveloperService ds) : ControllerBase
{
public record CustomAppRequest(
[MaxLength(1024)] string? Slug,
@@ -23,19 +23,19 @@ public class CustomAppController(CustomAppService customApps, PublisherService p
[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);
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound();
var apps = await customApps.GetAppsByPublisherAsync(developer.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 developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound();
var app = await customApps.GetAppAsync(id, publisherId: publisher.Id);
var app = await customApps.GetAppAsync(id, developerId: developer.Id);
if (app == null)
return NotFound();
@@ -51,17 +51,15 @@ public class CustomAppController(CustomAppService customApps, PublisherService p
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();
var developer = await ds.GetDeveloperByName(pubName);
if (developer 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");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create a custom app");
try
{
var app = await customApps.CreateAppAsync(publisher, request);
var app = await customApps.CreateAppAsync(developer, request);
return Ok(app);
}
catch (InvalidOperationException ex)
@@ -80,13 +78,13 @@ public class CustomAppController(CustomAppService customApps, PublisherService p
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
var developer = await ds.GetDeveloperByName(pubName);
if (developer 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");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to update a custom app");
var app = await customApps.GetAppAsync(id, publisherId: publisher.Id);
var app = await customApps.GetAppAsync(id, developerId: developer.Id);
if (app == null)
return NotFound();
@@ -110,13 +108,13 @@ public class CustomAppController(CustomAppService customApps, PublisherService p
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();
var developer = await ds.GetDeveloperByName(pubName);
if (developer 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");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to delete a custom app");
var app = await customApps.GetAppAsync(id, publisherId: publisher.Id);
var app = await customApps.GetAppAsync(id, developerId: developer.Id);
if (app == null)
return NotFound();

View File

@@ -1,5 +1,6 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Develop.Identity;
@@ -10,7 +11,7 @@ public class CustomAppService(
)
{
public async Task<CustomApp?> CreateAppAsync(
Publisher.Publisher pub,
Developer pub,
CustomAppController.CustomAppRequest request
)
{
@@ -22,7 +23,7 @@ public class CustomAppService(
Status = request.Status ?? CustomAppStatus.Developing,
Links = request.Links,
OauthConfig = request.OauthConfig,
PublisherId = pub.Id
DeveloperId = pub.Id
};
if (request.PictureId is not null)
@@ -73,17 +74,17 @@ public class CustomAppService(
return app;
}
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? publisherId = null)
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? developerId = null)
{
var query = db.CustomApps.Where(a => a.Id == id).AsQueryable();
if (publisherId.HasValue)
query = query.Where(a => a.PublisherId == publisherId.Value);
if (developerId.HasValue)
query = query.Where(a => a.DeveloperId == developerId.Value);
return await query.FirstOrDefaultAsync();
}
public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId)
{
return await db.CustomApps.Where(a => a.PublisherId == publisherId).ToListAsync();
return await db.CustomApps.Where(a => a.DeveloperId == publisherId).ToListAsync();
}
public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)

View File

@@ -0,0 +1,75 @@
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Data;
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
namespace DysonNetwork.Develop.Identity;
public class Developer
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid PublisherId { get; set; }
[NotMapped] public PublisherInfo? Publisher { get; set; }
}
public class PublisherInfo
{
public Guid Id { get; set; }
public PublisherType Type { get; set; }
public string Name { get; set; } = string.Empty;
public string Nick { get; set; } = string.Empty;
public string? Bio { get; set; }
public CloudFileReferenceObject? Picture { get; set; }
public CloudFileReferenceObject? Background { get; set; }
public VerificationMark? Verification { get; set; }
public Guid? AccountId { get; set; }
public Guid? RealmId { get; set; }
public static PublisherInfo FromProto(Publisher proto)
{
var info = new PublisherInfo
{
Id = Guid.Parse(proto.Id),
Type = proto.Type == PublisherType.PubIndividual
? PublisherType.PubIndividual
: PublisherType.PubOrganizational,
Name = proto.Name,
Nick = proto.Nick,
Bio = string.IsNullOrEmpty(proto.Bio) ? null : proto.Bio,
Verification = proto.VerificationMark is not null
? VerificationMark.FromProtoValue(proto.VerificationMark)
: null,
AccountId = string.IsNullOrEmpty(proto.AccountId) ? null : Guid.Parse(proto.AccountId),
RealmId = string.IsNullOrEmpty(proto.RealmId) ? null : Guid.Parse(proto.RealmId)
};
if (proto.Picture != null)
{
info.Picture = new CloudFileReferenceObject
{
Id = proto.Picture.Id,
Name = proto.Picture.Name,
MimeType = proto.Picture.MimeType,
Hash = proto.Picture.Hash,
Size = proto.Picture.Size
};
}
if (proto.Background != null)
{
info.Background = new CloudFileReferenceObject
{
Id = proto.Background.Id,
Name = proto.Background.Name,
MimeType = proto.Background.MimeType,
Hash = proto.Background.Hash,
Size = (long)proto.Background.Size
};
}
return info;
}
}

View File

@@ -1,8 +1,9 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Develop.Identity;
@@ -11,42 +12,28 @@ namespace DysonNetwork.Develop.Identity;
public class DeveloperController(
AppDatabase db,
PublisherService.PublisherServiceClient ps,
ActionLogService.ActionLogServiceClient als
ActionLogService.ActionLogServiceClient als,
DeveloperService ds
)
: ControllerBase
{
[HttpGet("{name}")]
public async Task<ActionResult<Publisher.Publisher>> GetDeveloper(string name)
public async Task<ActionResult<Developer>> GetDeveloper(string name)
{
var publisher = await db.Publishers
.Where(e => e.Name == name)
.FirstOrDefaultAsync();
if (publisher is null) return NotFound();
return Ok(publisher);
var developer = await ds.GetDeveloperByName(name);
if (developer is null) return NotFound();
return Ok(developer);
}
[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");
var developer = await ds.GetDeveloperByName(name);
if (developer is null) return NotFound();
// Get custom apps count
var customAppsCount = await db.CustomApps
.Where(a => a.PublisherId == publisher.Id)
.Where(a => a.DeveloperId == developer.Id)
.CountAsync();
var stats = new DeveloperStats
@@ -59,75 +46,63 @@ public class DeveloperController(
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Publisher.Publisher>>> ListJoinedDevelopers()
public async Task<ActionResult<List<Developer>>> ListJoinedDevelopers()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var pubResponse = await ps.ListPublishersAsync(new ListPublishersRequest { AccountId = currentUser.Id });
var pubIds = pubResponse.Publishers.Select(p => p.Id).Select(Guid.Parse).ToList();
var members = await db.PublisherMembers
.Where(m => m.AccountId == accountId)
.Where(m => m.JoinedAt != null)
.Include(e => e.Publisher)
.ToListAsync();
var developerQuery = db.Developers
.Where(d => pubIds.Contains(d.PublisherId))
.AsQueryable();
var totalCount = await developerQuery.CountAsync();
Response.Headers.Append("X-Total", totalCount.ToString());
var developers = await developerQuery.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();
return Ok(developers);
}
[HttpPost("{name}/enroll")]
[Authorize]
[RequiredPermission("global", "developers.create")]
public async Task<ActionResult<Publisher.Publisher>> EnrollDeveloperProgram(string name)
public async Task<ActionResult<Developer>> 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();
PublisherInfo? pub;
try
{
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name });
pub = PublisherInfo.FromProto(pubResponse.Publisher);
} catch (RpcException ex)
{
return NotFound(ex.Status.Detail);
}
// 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
var permResponse = await ps.IsPublisherMemberAsync(new IsPublisherMemberRequest
{
PublisherId = publisher.Id,
Flag = PublisherFeatureFlag.Develop,
ExpiredAt = null
PublisherId = pub.Id.ToString(),
AccountId = currentUser.Id,
Role = PublisherMemberRole.Owner
});
if (!permResponse.Valid) return StatusCode(403, "You must be the owner of the publisher to join the developer program");
var hasDeveloper = await db.Developers.AnyAsync(d => d.PublisherId == pub.Id);
if (hasDeveloper) return BadRequest("Publisher is already in the developer program");
var developer = new Developer
{
Id = Guid.NewGuid(),
PublisherId = pub.Id
};
db.PublisherFeatures.Add(feature);
db.Developers.Add(developer);
await db.SaveChangesAsync();
_ = als.CreateActionLogAsync(new CreateActionLogRequest
@@ -135,15 +110,15 @@ public class DeveloperController(
Action = "developers.enroll",
Meta =
{
{ "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Id.ToString()) },
{ "publisher_name", Google.Protobuf.WellKnownTypes.Value.ForString(publisher.Name) }
{ "publisher_id", Google.Protobuf.WellKnownTypes.Value.ForString(pub.Id.ToString()) },
{ "publisher_name", Google.Protobuf.WellKnownTypes.Value.ForString(pub.Name) }
},
AccountId = currentUser.Id,
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return Ok(publisher);
return Ok(developer);
}
public class DeveloperStats

View File

@@ -0,0 +1,49 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Develop.Identity;
public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceClient ps)
{
public async Task<Developer> LoadDeveloperPublisher(Developer developer)
{
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Id = developer.PublisherId.ToString() });
developer.Publisher = PublisherInfo.FromProto(pubResponse.Publisher);
return developer;
}
public async Task<Developer?> GetDeveloperByName(string name)
{
try
{
var pubResponse = await ps.GetPublisherAsync(new GetPublisherRequest { Name = name });
var pubId = Guid.Parse(pubResponse.Publisher.Id);
var developer = await db.Developers.FirstOrDefaultAsync(d => d.Id == pubId);
return developer;
}
catch (RpcException)
{
return null;
}
}
public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
{
try
{
var permResponse = await ps.IsPublisherMemberAsync(new IsPublisherMemberRequest
{
PublisherId = pubId.ToString(),
AccountId = accountId.ToString(),
Role = role
});
return permResponse.Valid;
}
catch (RpcException)
{
return false;
}
}
}

View File

@@ -3,6 +3,7 @@ using Microsoft.OpenApi.Models;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using System.Text.Json;
using DysonNetwork.Develop.Identity;
using DysonNetwork.Shared.Cache;
namespace DysonNetwork.Develop.Startup;
@@ -41,6 +42,9 @@ public static class ServiceCollectionExtensions
options.SupportedUICultures = supportedCultures;
});
services.AddScoped<DeveloperService>();
services.AddScoped<CustomAppService>();
return services;
}