♻️ Refined custom apps

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

View File

@ -197,30 +197,6 @@ public class AppDatabase(
.HasIndex(p => p.SearchVector) .HasIndex(p => p.SearchVector)
.HasMethod("GIN"); .HasMethod("GIN");
modelBuilder.Entity<CustomApp>()
.Property(c => c.RedirectUris)
.HasConversion(
v => string.Join(",", v),
v => v.Split(",", StringSplitOptions.RemoveEmptyEntries).ToArray());
modelBuilder.Entity<CustomApp>()
.Property(c => c.PostLogoutRedirectUris)
.HasConversion(
v => v != null ? string.Join(",", v) : "",
v => !string.IsNullOrEmpty(v) ? v.Split(",", StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>());
modelBuilder.Entity<CustomApp>()
.Property(c => c.AllowedScopes)
.HasConversion(
v => v != null ? string.Join(" ", v) : "",
v => !string.IsNullOrEmpty(v) ? v.Split(" ", StringSplitOptions.RemoveEmptyEntries) : new[] { "openid", "profile", "email" });
modelBuilder.Entity<CustomApp>()
.Property(c => c.AllowedGrantTypes)
.HasConversion(
v => v != null ? string.Join(" ", v) : "",
v => !string.IsNullOrEmpty(v) ? v.Split(" ", StringSplitOptions.RemoveEmptyEntries) : new[] { "authorization_code", "refresh_token" });
modelBuilder.Entity<CustomAppSecret>() modelBuilder.Entity<CustomAppSecret>()
.HasIndex(s => s.Secret) .HasIndex(s => s.Secret)
.IsUnique(); .IsUnique();

View File

@ -158,8 +158,8 @@ public class OidcProviderService(
); );
// Add scopes as claims if provided // Add scopes as claims if provided
var effectiveScopes = scopes?.ToList() ?? client.AllowedScopes?.ToList() ?? new List<string>(); var effectiveScopes = scopes?.ToList() ?? client.OauthConfig!.AllowedScopes?.ToList() ?? [];
if (effectiveScopes.Any()) if (effectiveScopes.Count != 0)
{ {
tokenDescriptor.Subject.AddClaims( tokenDescriptor.Subject.AddClaims(
effectiveScopes.Select(scope => new Claim("scope", scope))); effectiveScopes.Select(scope => new Claim("scope", scope)));
@ -221,7 +221,7 @@ public class OidcProviderService(
return string.Equals(secret, hashedSecret, StringComparison.Ordinal); return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
} }
public async Task<string> GenerateAuthorizationCodeForExistingSessionAsync( public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
Session session, Session session,
Guid clientId, Guid clientId,
string redirectUri, string redirectUri,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,182 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Data.Migrations
{
/// <inheritdoc />
public partial class CustomAppsRefine : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "allow_offline_access",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "allowed_grant_types",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "allowed_scopes",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "client_uri",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "logo_uri",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "post_logout_redirect_uris",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "redirect_uris",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "require_pkce",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "verified_at",
table: "custom_apps");
migrationBuilder.RenameColumn(
name: "verified_as",
table: "custom_apps",
newName: "description");
migrationBuilder.AddColumn<CloudFileReferenceObject>(
name: "background",
table: "custom_apps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<CustomAppLinks>(
name: "links",
table: "custom_apps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<CustomAppOauthConfig>(
name: "oauth_config",
table: "custom_apps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<CloudFileReferenceObject>(
name: "picture",
table: "custom_apps",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<VerificationMark>(
name: "verification",
table: "custom_apps",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "background",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "links",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "oauth_config",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "picture",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "verification",
table: "custom_apps");
migrationBuilder.RenameColumn(
name: "description",
table: "custom_apps",
newName: "verified_as");
migrationBuilder.AddColumn<bool>(
name: "allow_offline_access",
table: "custom_apps",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "allowed_grant_types",
table: "custom_apps",
type: "character varying(256)",
maxLength: 256,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "allowed_scopes",
table: "custom_apps",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "client_uri",
table: "custom_apps",
type: "character varying(1024)",
maxLength: 1024,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "logo_uri",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "post_logout_redirect_uris",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "redirect_uris",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "require_pkce",
table: "custom_apps",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Instant>(
name: "verified_at",
table: "custom_apps",
type: "timestamp with time zone",
nullable: true);
}
}
}

View File

@ -1,5 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Developer; namespace DysonNetwork.Sphere.Developer;
@ -12,17 +15,38 @@ public enum CustomAppStatus
Suspended Suspended
} }
public class CustomApp : ModelBase public class CustomApp : ModelBase, IIdentifiedResource
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Slug { get; set; } = null!; [MaxLength(1024)] public string Slug { get; set; } = null!;
[MaxLength(1024)] public string Name { 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 CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
public Instant? VerifiedAt { get; set; }
[MaxLength(4096)] public string? VerifiedAs { get; set; }
// OIDC/OAuth specific properties [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[MaxLength(4096)] public string? LogoUri { 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(1024)] public string? ClientUri { get; set; }
[MaxLength(4096)] public string[] RedirectUris { get; set; } = []; [MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
[MaxLength(4096)] public string[]? PostLogoutRedirectUris { 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"]; [MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
public bool RequirePkce { get; set; } = true; public bool RequirePkce { get; set; } = true;
public bool AllowOfflineAccess { get; set; } = false; 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 public class CustomAppSecret : ModelBase

View File

@ -1,63 +1,129 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Developer; namespace DysonNetwork.Sphere.Developer;
[ApiController] [ApiController]
[Route("/developers/apps")] [Route("/developers/{pubName}/apps")]
public class CustomAppController(CustomAppService customAppService, PublisherService ps) : ControllerBase public class CustomAppController(CustomAppService customApps, PublisherService ps) : ControllerBase
{ {
public record CreateAppRequest(Guid PublisherId, string Name, string Slug); public record CustomAppRequest(
public record UpdateAppRequest(string Name, string Slug); [MaxLength(1024)] string? Slug,
[MaxLength(1024)] string? Name,
[MaxLength(4096)] string? Description,
string? PictureId,
string? BackgroundId,
CustomAppStatus? Status,
CustomAppLinks? Links,
CustomAppOauthConfig? OauthConfig
);
[HttpGet] [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); return Ok(apps);
} }
[HttpGet("{id:guid}")] [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) if (app == null)
{
return NotFound(); return NotFound();
}
return Ok(app); return Ok(app);
} }
[HttpPost] [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 (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (app == null)
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}")] [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) if (app == null)
{
return NotFound(); return NotFound();
}
try
{
app = await customApps.UpdateAppAsync(app, request);
return Ok(app); return Ok(app);
} }
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteApp(Guid id) [Authorize]
{ public async Task<IActionResult> DeleteApp(
var result = await customAppService.DeleteAppAsync(id); [FromRoute] string pubName,
if (!result) [FromRoute] Guid id
)
{ {
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 NotFound();
}
return NoContent(); return NoContent();
} }
} }

View File

@ -1,39 +1,71 @@
using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Developer; 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 var app = new CustomApp
{ {
Name = name, Slug = request.Slug!,
Slug = slug, Name = request.Name!,
PublisherId = publisher.Id 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); db.CustomApps.Add(app);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return app; 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) 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(); 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 (request.Slug is not null)
if (app == 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
);
} }
app.Name = name; if (request.BackgroundId is not null)
app.Slug = slug; {
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
);
}
db.Update(app);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return app; return app;
@ -68,6 +143,8 @@ public class CustomAppService(AppDatabase db, PublisherService ps)
db.CustomApps.Remove(app); db.CustomApps.Remove(app);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await fileRefService.DeleteResourceReferencesAsync(app.ResourceIdentifier);
return true; return true;
} }
} }

View File

@ -6,6 +6,7 @@ using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Chat; using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Connection.WebReader; using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet; using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -1507,25 +1508,9 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("id"); .HasColumnName("id");
b.Property<bool>("AllowOfflineAccess") b.Property<CloudFileReferenceObject>("Background")
.HasColumnType("boolean") .HasColumnType("jsonb")
.HasColumnName("allow_offline_access"); .HasColumnName("background");
b.Property<string>("AllowedGrantTypes")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("allowed_grant_types");
b.Property<string>("AllowedScopes")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("allowed_scopes");
b.Property<string>("ClientUri")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("client_uri");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
@ -1535,10 +1520,14 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<string>("LogoUri") b.Property<string>("Description")
.HasMaxLength(4096) .HasMaxLength(4096)
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")
.HasColumnName("logo_uri"); .HasColumnName("description");
b.Property<CustomAppLinks>("Links")
.HasColumnType("jsonb")
.HasColumnName("links");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
@ -1546,25 +1535,18 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
.HasColumnName("name"); .HasColumnName("name");
b.Property<string>("PostLogoutRedirectUris") b.Property<CustomAppOauthConfig>("OauthConfig")
.HasMaxLength(4096) .HasColumnType("jsonb")
.HasColumnType("character varying(4096)") .HasColumnName("oauth_config");
.HasColumnName("post_logout_redirect_uris");
b.Property<CloudFileReferenceObject>("Picture")
.HasColumnType("jsonb")
.HasColumnName("picture");
b.Property<Guid>("PublisherId") b.Property<Guid>("PublisherId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("publisher_id"); .HasColumnName("publisher_id");
b.Property<string>("RedirectUris")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("redirect_uris");
b.Property<bool>("RequirePkce")
.HasColumnType("boolean")
.HasColumnName("require_pkce");
b.Property<string>("Slug") b.Property<string>("Slug")
.IsRequired() .IsRequired()
.HasMaxLength(1024) .HasMaxLength(1024)
@ -1579,14 +1561,9 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at"); .HasColumnName("updated_at");
b.Property<string>("VerifiedAs") b.Property<VerificationMark>("Verification")
.HasMaxLength(4096) .HasColumnType("jsonb")
.HasColumnType("character varying(4096)") .HasColumnName("verification");
.HasColumnName("verified_as");
b.Property<Instant?>("VerifiedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("verified_at");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_custom_apps"); .HasName("pk_custom_apps");
@ -3618,7 +3595,7 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.PublisherFeature", b => modelBuilder.Entity("DysonNetwork.Sphere.Publisher.PublisherFeature", b =>
{ {
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher") b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
.WithMany() .WithMany("Features")
.HasForeignKey("PublisherId") .HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .IsRequired()
@ -3980,6 +3957,8 @@ namespace DysonNetwork.Sphere.Migrations
{ {
b.Navigation("Collections"); b.Navigation("Collections");
b.Navigation("Features");
b.Navigation("Members"); b.Navigation("Members");
b.Navigation("Posts"); b.Navigation("Posts");

View File

@ -8,7 +8,7 @@ using DysonNetwork.Sphere.Developer;
namespace DysonNetwork.Sphere.Pages.Auth; namespace DysonNetwork.Sphere.Pages.Auth;
public class AuthorizeModel(OidcProviderService oidcService) : PageModel public class AuthorizeModel(OidcProviderService oidcService, IConfiguration configuration) : PageModel
{ {
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; } [BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
@ -71,10 +71,17 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
return NotFound("Client not found"); return NotFound("Client not found");
} }
var config = client.OauthConfig;
if (config is null)
{
ModelState.AddModelError("client_id", "Client was not available for use OAuth / OIDC");
return BadRequest("Client was not enabled for OAuth / OIDC");
}
// Validate redirect URI for non-Developing apps // Validate redirect URI for non-Developing apps
if (client.Status != CustomAppStatus.Developing) if (client.Status != CustomAppStatus.Developing)
{ {
if (!string.IsNullOrEmpty(RedirectUri) && !(client.RedirectUris?.Contains(RedirectUri) ?? false)) if (!string.IsNullOrEmpty(RedirectUri) && !(config.RedirectUris?.Contains(RedirectUri) ?? false))
{ {
return BadRequest(new ErrorResponse return BadRequest(new ErrorResponse
{ {
@ -93,9 +100,10 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
} }
// Show authorization page // Show authorization page
var baseUrl = configuration["BaseUrl"];
AppName = client.Name; AppName = client.Name;
AppLogo = client.LogoUri; AppLogo = client.Picture is not null ? $"{baseUrl}/files/{client.Picture.Id}" : null;
AppUri = client.ClientUri; AppUri = config.ClientUri;
RequestedScopes = (Scope ?? "openid profile").Split(' ').Distinct().ToArray(); RequestedScopes = (Scope ?? "openid profile").Split(' ').Distinct().ToArray();
return Page(); return Page();
@ -114,7 +122,7 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
if (existingSession != null) if (existingSession != null)
{ {
// Reuse existing session // Reuse existing session
authCode = await oidcService.GenerateAuthorizationCodeForExistingSessionAsync( authCode = await oidcService.GenerateAuthorizationCodeForReuseSessionAsync(
session: existingSession, session: existingSession,
clientId: ClientId, clientId: ClientId,
redirectUri: RedirectUri, redirectUri: RedirectUri,
@ -126,7 +134,7 @@ public class AuthorizeModel(OidcProviderService oidcService) : PageModel
} }
else else
{ {
// Create new session (existing flow) // Create a new session (existing flow)
authCode = await oidcService.GenerateAuthorizationCodeAsync( authCode = await oidcService.GenerateAuthorizationCodeAsync(
clientId: ClientId, clientId: ClientId,
userId: currentUser.Id, userId: currentUser.Id,

View File

@ -8,6 +8,13 @@ namespace DysonNetwork.Sphere.Publisher;
public class PublisherService(AppDatabase db, FileReferenceService fileRefService, ICacheService cache) public class PublisherService(AppDatabase db, FileReferenceService fileRefService, ICacheService cache)
{ {
public async Task<Publisher?> GetPublisherByName(string name)
{
return await db.Publishers
.Where(e => e.Name == name)
.FirstOrDefaultAsync();
}
private const string UserPublishersCacheKey = "accounts:{0}:publishers"; private const string UserPublishersCacheKey = "accounts:{0}:publishers";
public async Task<List<Publisher>> GetUserPublishers(Guid userId) public async Task<List<Publisher>> GetUserPublishers(Guid userId)
@ -336,7 +343,7 @@ public class PublisherService(AppDatabase db, FileReferenceService fileRefServic
f.PublisherId == publisherId && f.Flag == flag && f.PublisherId == publisherId && f.Flag == flag &&
(f.ExpiredAt == null || f.ExpiredAt > now) (f.ExpiredAt == null || f.ExpiredAt > now)
); );
if (featureFlag is not null) isEnabled = true; isEnabled = featureFlag is not null;
await cache.SetAsync(cacheKey, isEnabled!.Value, TimeSpan.FromMinutes(5)); await cache.SetAsync(cacheKey, isEnabled!.Value, TimeSpan.FromMinutes(5));
return isEnabled.Value; return isEnabled.Value;