10 Commits
Author SHA1 Message Date
AdminandClaude Opus 4.8 573539ea1b cleanup(worker): drop empty Worker BackgroundService; fix gateway ticket guard
- Remove the no-op Worker BackgroundService and its registration; the process
  is kept alive and does its work through the MassTransit bus and the upload
  intent jobs (their own hosted services)
- Fix the gateway middleware guard to reject on string.IsNullOrEmpty(prefix)
  instead of 'is null', which no longer matched TicketValidator returning
  string.Empty for invalid tickets (invalid tickets skipped the 401 and an
  empty object key could slip through)

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:13:20 +02:00
Admin e15e587267 Formatting 2026-07-05 22:07:43 +02:00
Admin dece2c77e8 Empty string 2026-07-05 22:06:50 +02:00
AdminandClaude Opus 4.8 5aeca4deb1 refactor(gateway): simplify prefix extraction in TicketValidator
Read the prefix claim via ClaimsIdentity.FindFirst instead of a three-part
ternary over the claims dictionary, and drop the XML summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:59:55 +02:00
AdminandClaude Opus 4.8 45ce66a6ee harden(gateway): prefix-boundary + path-traversal checks; simplify ticket validation
Tighten the stream-ticket authorization in the gateway middleware:
- Enforce a path-segment boundary on the prefix check instead of a bare
  StartsWith, so a ticket for 'users/12' no longer grants 'users/1234/...'
  (exact-match still allowed for single-object grants)
- Reject object keys containing '..'/'.' segments before proxying, closing a
  path-traversal bypass that SeaweedFS would otherwise resolve outside the
  authorized prefix

Also simplify TicketValidator.ValidateTicketAsync from ErrorOr<string> to
string? (null = invalid) since it has a single failure mode; drop the now
unused ErrorOr package reference from the gateway.

Verified: build clean; the authorization predicate passes a table of cases
covering both the escalation and traversal vulnerabilities.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:50:43 +02:00
AdminandClaude Opus 4.8 a40de35a57 refactor(api): OpenAPI response metadata, cached user sync, ProblemDetails
- Document every endpoint's responses (Produces<T>/status codes/validation)
  so the generated OpenAPI/Scalar docs carry response schemas and status codes
  instead of empty responses
- Cache recently-synced user ids (IMemoryCache, 15 min) in the JWT
  TokenValidated handler so user provisioning no longer hits the DB on every
  authenticated request
- Add ProblemDetails + UseExceptionHandler for consistent RFC7807 errors
- Add ToCreatedResult ErrorOr helper and use it in the create endpoints
- Normalize the playlists route to lowercase (/playlists)

Verified: booted the Api against RabbitMQ + Postgres in Docker and inspected
/openapi/v1.json — operations now expose response schemas/status codes and the
route is /playlists.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:04:15 +02:00
AdminandClaude Opus 4.8 1d40abcab7 feat(deploy): consolidated dev stack with auto-provisioned Zitadel
Single docker-compose stack for local development (Postgres, Zitadel,
RabbitMQ, SeaweedFS + bucket init, Jaeger) plus an optional overlay to run
the .NET apps in Docker. up.ps1 orchestrates the full bootstrap: stack up,
stream-ticket keys, EF Core migrations and Zitadel provisioning.

- Zitadel is provisioned automatically (service-account PAT -> project +
  OIDC app with JWT access token); the generated ClientId is wired into
  Musify.Api user-secrets and .env. No manual console steps.
- ZITADEL_EXTERNAL_DOMAIN=host.docker.internal so token validation works
  both with local apps and full-docker.
- App Dockerfiles copy AppSettings*.json to lowercase so config loads on
  case-sensitive Linux; Worker image ships ffmpeg.
- Scripts split by responsibility (common/stream-keys/migrate/provision);
  apps overlay de-duped via YAML anchors.
- Remove the superseded standalone seaweedfs compose; point docs/development
  at the unified stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:32:40 +02:00
Admin d6c269d80f Formatting 2026-07-05 16:04:53 +02:00
AdminandClaude Opus 4.8 431b56a072 refactor(infra): register auditable interceptor via DI
Register AuditableEntityInterceptor as a singleton and attach it through the
AddDbContext options callback instead of newing it up inside
Database.OnConfiguring. The interceptor is stateless, so a single shared
instance is reused across contexts and its wiring lives in the DI layer.
The design-time factory keeps working (interceptor is runtime-only, irrelevant
for migrations).

Verified: build + ef migrations list (design-time context) OK; the Worker host
builds and validates the full DI graph (reaches endpoint configuration) with no
captive-dependency or interceptor error.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:14:03 +02:00
AdminandClaude Opus 4.8 c2258569a0 refactor(infra): S3 client singleton, ffmpeg pipe ordering, tidy streams/jobs
- Register IAmazonS3 as a singleton instead of scoped: the AWS SDK client is
  thread-safe and manages its own connection pool, so one instance per DI scope
  (per request / per job iteration) was wasteful and risked socket exhaustion
- AudioTranscoderService: start reading stdout/stderr before writing stdin to
  remove the latent pipe-buffer deadlock shape
- Dispose the resized-picture MemoryStream in ResizePictureActivity
- Background jobs use PeriodicTimer instead of while + Task.Delay

Verified: Worker boots against RabbitMQ + Postgres in Docker with no captive-
dependency error (dev scope validation) and both jobs start.

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:07:49 +02:00
42 changed files with 1043 additions and 151 deletions
+10
View File
@@ -0,0 +1,10 @@
**/bin/
**/obj/
**/.vs/
**/.idea/
.git/
.github/
.agents/
.claude/
**/DesignSettings.json
*.user
+25
View File
@@ -0,0 +1,25 @@
# Build context: backend/ (docker compose -f deploy/docker-compose.apps.yml build)
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore: copy central config + csproj files first to cache the layer.
COPY Directory.Build.props Directory.Packages.props nuget.config ./
COPY Musify.Domain/Musify.Domain.csproj Musify.Domain/
COPY Musify.Application/Musify.Application.csproj Musify.Application/
COPY Musify.Infrastructure/Musify.Infrastructure.csproj Musify.Infrastructure/
COPY Musify.Api/Musify.Api.csproj Musify.Api/
RUN dotnet restore Musify.Api/Musify.Api.csproj
COPY . .
RUN dotnet publish Musify.Api/Musify.Api.csproj -c Release -o /app --no-restore
# Config files are AppSettings*.json (PascalCase); on case-sensitive Linux the host
# looks for appsettings*.json. Add lowercase copies so they get loaded.
RUN cp /app/AppSettings.json /app/appsettings.json \
&& cp /app/AppSettings.Development.json /app/appsettings.Development.json
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app ./
ENV ASPNETCORE_URLS=http://+:5111
EXPOSE 5111
ENTRYPOINT ["dotnet", "Musify.Api.dll"]
@@ -17,6 +17,7 @@ public static class AuthenticationExtensions
services.AddSingleton(serviceProvider => services.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<AuthenticationConfiguration>>().Value); serviceProvider.GetRequiredService<IOptions<AuthenticationConfiguration>>().Value);
services.AddMemoryCache();
services.AddScoped<JwtBearerEventsHandler>(); services.AddScoped<JwtBearerEventsHandler>();
services.ConfigureOptions<JwtBearerOptionsSetup>(); services.ConfigureOptions<JwtBearerOptionsSetup>();
@@ -1,12 +1,18 @@
using Mediator; using Mediator;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Caching.Memory;
using Musify.Application.Users; using Musify.Application.Users;
using System.Security.Claims; using System.Security.Claims;
namespace Musify.Api.Authentication; namespace Musify.Api.Authentication;
public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents public sealed class JwtBearerEventsHandler(
IMediator mediator,
IMemoryCache cache,
ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents
{ {
private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15);
public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext)
{ {
var principal = tokenValidatedContext.Principal!; var principal = tokenValidatedContext.Principal!;
@@ -18,6 +24,10 @@ public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger<JwtBearer
return; return;
} }
var cacheKey = $"user-synced:{userId}";
if (cache.TryGetValue(cacheKey, out _))
return;
var username = principal.FindFirstValue(ClaimTypes.Name) var username = principal.FindFirstValue(ClaimTypes.Name)
?? principal.FindFirstValue("preferred_username") ?? principal.FindFirstValue("preferred_username")
?? principal.FindFirstValue(ClaimTypes.Email) ?? principal.FindFirstValue(ClaimTypes.Email)
@@ -32,6 +42,8 @@ public sealed class JwtBearerEventsHandler(IMediator mediator, ILogger<JwtBearer
await mediator.Send( await mediator.Send(
new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl), new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl),
tokenValidatedContext.HttpContext.RequestAborted); tokenValidatedContext.HttpContext.RequestAborted);
cache.Set(cacheKey, true, UserSyncCacheTtl);
} }
catch (Exception exception) catch (Exception exception)
{ {
@@ -1,5 +1,8 @@
using Mediator; using Mediator;
using Musify.Application.PlayLists; using Musify.Application.PlayLists;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Tracks.Responses;
using Musify.Application.Shared;
using Musify.Api.Authentication; using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.DataTransferObjects.PlayLists;
using Musify.Api.Extensions; using Musify.Api.Extensions;
@@ -11,57 +14,85 @@ public static class PlayListEndpoints
{ {
public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app)
{ {
var group = app.MapGroup("/playLists") var group = app.MapGroup("/playlists")
.WithTags("PlayLists"); .WithTags("PlayLists");
group.MapGet("/", GetPlayLists) group.MapGet("/", GetPlayLists)
.WithName("GetPlayLists") .WithName("GetPlayLists")
.WithSummary("Get Paginated PlayLists."); .WithSummary("Get Paginated PlayLists.")
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
group.MapGet("/{id}", GetPlayListById) group.MapGet("/{id}", GetPlayListById)
.WithName("GetPlayListById") .WithName("GetPlayListById")
.WithSummary("Get A PlayList By Id."); .WithSummary("Get A PlayList By Id.")
.Produces<PlayListApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetPlayListsByUserId) group.MapGet("/users/{userId}", GetPlayListsByUserId)
.WithName("GetPlayListsByUserId") .WithName("GetPlayListsByUserId")
.WithSummary("Get Paginated PlayLists For A User."); .WithSummary("Get Paginated PlayLists For A User.")
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
group.MapGet("/{playlistId}/tracks", GetPlayListTracks) group.MapGet("/{playlistId}/tracks", GetPlayListTracks)
.WithName("GetPlayListTracks") .WithName("GetPlayListTracks")
.WithSummary("Get Paginated Tracks Of A PlayList."); .WithSummary("Get Paginated Tracks Of A PlayList.")
.Produces<PaginatedResponse<TrackApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", CreatePlayList) group.MapPost("/", CreatePlayList)
.WithName("CreatePlayList") .WithName("CreatePlayList")
.WithSummary("Create A New PlayList.") .WithSummary("Create A New PlayList.")
.AddEndpointFilter<ValidationFilter<CreatePlayListRequest>>() .AddEndpointFilter<ValidationFilter<CreatePlayListRequest>>()
.RequireAuthorization(); .RequireAuthorization()
.Produces<PlayListApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/{playlistId}", UpdatePlayList) group.MapPut("/{playlistId}", UpdatePlayList)
.WithName("UpdatePlayList") .WithName("UpdatePlayList")
.WithSummary("Update An Existing PlayList.") .WithSummary("Update An Existing PlayList.")
.AddEndpointFilter<ValidationFilter<UpdatePlayListRequest>>() .AddEndpointFilter<ValidationFilter<UpdatePlayListRequest>>()
.RequireAuthorization(); .RequireAuthorization()
.Produces<PlayListApplicationResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapDelete("/{playlistId}", DeletePlayList) group.MapDelete("/{playlistId}", DeletePlayList)
.WithName("DeletePlayList") .WithName("DeletePlayList")
.WithSummary("Delete A PlayList.") .WithSummary("Delete A PlayList.")
.RequireAuthorization(); .RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/upload-picture", RequestPlayListPictureUpload) group.MapPost("/upload-picture", RequestPlayListPictureUpload)
.WithName("RequestPlayListPictureUpload") .WithName("RequestPlayListPictureUpload")
.WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.") .WithSummary("Request A Pre-Signed URL To Upload A PlayList Picture.")
.AddEndpointFilter<ValidationFilter<RequestPlayListPictureUploadRequest>>() .AddEndpointFilter<ValidationFilter<RequestPlayListPictureUploadRequest>>()
.RequireAuthorization(); .RequireAuthorization()
.Produces<PlayListPictureUploadResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{playlistId}/tracks/{trackId}", AddTrackToPlayList) group.MapPost("/{playlistId}/tracks/{trackId}", AddTrackToPlayList)
.WithName("AddTrackToPlayList") .WithName("AddTrackToPlayList")
.WithSummary("Add A Track To A PlayList.") .WithSummary("Add A Track To A PlayList.")
.RequireAuthorization(); .RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList) group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
.WithName("RemoveTrackFromPlayList") .WithName("RemoveTrackFromPlayList")
.WithSummary("Remove A Track From A PlayList.") .WithSummary("Remove A Track From A PlayList.")
.RequireAuthorization(); .RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
return app; return app;
} }
@@ -118,10 +149,7 @@ public static class PlayListEndpoints
new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId), new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId),
cancellationToken); cancellationToken);
if (result.IsError) return result.ToCreatedResult(playList => $"/playlists/{playList.Id}");
return result.ToHttpResult();
return Results.Created($"/playlists/{result.Value.Id}", result.Value);
} }
private static async Task<IResult> UpdatePlayList( private static async Task<IResult> UpdatePlayList(
+31 -11
View File
@@ -1,5 +1,7 @@
using Mediator; using Mediator;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Application.Shared;
using Musify.Api.Authentication; using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.Extensions; using Musify.Api.Extensions;
@@ -16,37 +18,58 @@ public static class TrackEndpoints
group.MapGet("/", GetTracks) group.MapGet("/", GetTracks)
.WithName("GetTracks") .WithName("GetTracks")
.WithSummary("Get Paginated Tracks."); .WithSummary("Get Paginated Tracks.")
.Produces<PaginatedResponse<TrackApplicationResponse>>();
group.MapGet("/{id}", GetTrackById) group.MapGet("/{id}", GetTrackById)
.WithName("GetTrackById") .WithName("GetTrackById")
.WithSummary("Get A Track By Id."); .WithSummary("Get A Track By Id.")
.Produces<TrackApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/stream", GetTrackStream) group.MapGet("/{id}/stream", GetTrackStream)
.WithName("GetTrackStream") .WithName("GetTrackStream")
.WithSummary("Get A Streaming Manifest URL And Ticket For A Track.") .WithSummary("Get A Streaming Manifest URL And Ticket For A Track.")
.RequireAuthorization(); .RequireAuthorization()
.Produces<TrackStreamResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetTracksByUserId) group.MapGet("/users/{userId}", GetTracksByUserId)
.WithName("GetTracksByUserId") .WithName("GetTracksByUserId")
.WithSummary("Get Paginated Tracks For A User."); .WithSummary("Get Paginated Tracks For A User.")
.Produces<PaginatedResponse<TrackApplicationResponse>>();
group.MapPost("/", CreateTrack) group.MapPost("/", CreateTrack)
.WithName("CreateTrack") .WithName("CreateTrack")
.WithSummary("Create A New Track.") .WithSummary("Create A New Track.")
.AddEndpointFilter<ValidationFilter<CreateTrackRequest>>() .AddEndpointFilter<ValidationFilter<CreateTrackRequest>>()
.RequireAuthorization(); .RequireAuthorization()
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapDelete("/{trackId}", DeleteTrack) group.MapDelete("/{trackId}", DeleteTrack)
.WithName("DeleteTrack") .WithName("DeleteTrack")
.WithSummary("Delete A Track.") .WithSummary("Delete A Track.")
.RequireAuthorization(); .RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapPost("/upload-urls", RequestTrackUploadUrls) group.MapPost("/upload-urls", RequestTrackUploadUrls)
.WithName("RequestTrackUploadUrls") .WithName("RequestTrackUploadUrls")
.WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.") .WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.")
.AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>() .AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>()
.RequireAuthorization(); .RequireAuthorization()
.Produces<TrackUploadUrlsResponse>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
return app; return app;
} }
@@ -102,10 +125,7 @@ public static class TrackEndpoints
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId), new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId),
cancellationToken); cancellationToken);
if (result.IsError) return result.ToCreatedResult(track => $"/tracks/{track.Id}");
return result.ToHttpResult();
return Results.Created($"/tracks/{result.Value.Id}", result.Value);
} }
private static async Task<IResult> DeleteTrack( private static async Task<IResult> DeleteTrack(
+12 -7
View File
@@ -1,5 +1,7 @@
using Mediator; using Mediator;
using Musify.Application.Users; using Musify.Application.Users;
using Musify.Application.Users.Responses;
using Musify.Application.Shared;
using Musify.Api.DataTransferObjects.Users; using Musify.Api.DataTransferObjects.Users;
using Musify.Api.Extensions; using Musify.Api.Extensions;
using Musify.Api.Filters; using Musify.Api.Filters;
@@ -15,16 +17,22 @@ public static class UserEndpoints
group.MapGet("/", GetUsers) group.MapGet("/", GetUsers)
.WithName("GetUsers") .WithName("GetUsers")
.WithSummary("Get Paginated Users."); .WithSummary("Get Paginated Users.")
.Produces<PaginatedResponse<UserApplicationResponse>>();
group.MapGet("/{id}", GetUserById) group.MapGet("/{id}", GetUserById)
.WithName("GetUserById") .WithName("GetUserById")
.WithSummary("Get A User By Id."); .WithSummary("Get A User By Id.")
.Produces<UserApplicationResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", CreateUser) group.MapPost("/", CreateUser)
.WithName("CreateUser") .WithName("CreateUser")
.WithSummary("Create A New User.") .WithSummary("Create A New User.")
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>(); .AddEndpointFilter<ValidationFilter<CreateUserRequest>>()
.Produces<UserApplicationResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.Produces(StatusCodes.Status409Conflict);
return app; return app;
} }
@@ -58,9 +66,6 @@ public static class UserEndpoints
new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName), new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName),
cancellationToken); cancellationToken);
if (result.IsError) return result.ToCreatedResult(user => $"/users/{user.Id}");
return result.ToHttpResult();
return Results.Created($"/users/{result.Value.Id}", result.Value);
} }
} }
@@ -15,6 +15,11 @@ public static class ErrorOrHttpExtensions
: Results.Ok(result.Value); : Results.Ok(result.Value);
} }
public static IResult ToCreatedResult<T>(this ErrorOr<T> result, Func<T, string> locationFactory) =>
result.IsError
? Problem(result.Errors)
: Results.Created(locationFactory(result.Value), result.Value);
private static IResult Problem(List<Error> errors) private static IResult Problem(List<Error> errors)
{ {
if (errors.All(e => e.Type == ErrorType.Validation)) if (errors.All(e => e.Type == ErrorType.Validation))
@@ -36,7 +36,7 @@ public sealed class OpenApiOptionsSetup(IOptions<AuthenticationConfiguration> op
}; };
return Task.CompletedTask; return Task.CompletedTask;
}); } );
options.AddOperationTransformer((operation, context, _) => options.AddOperationTransformer((operation, context, _) =>
{ {
@@ -58,9 +58,9 @@ public sealed class OpenApiOptionsSetup(IOptions<AuthenticationConfiguration> op
{ {
[new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] =
_configuration.Scopes.ToList(), _configuration.Scopes.ToList(),
}); } );
return Task.CompletedTask; return Task.CompletedTask;
}); } );
} }
} }
+4
View File
@@ -11,6 +11,8 @@ builder.Services
.AddApplicationServices(builder.Configuration) .AddApplicationServices(builder.Configuration)
.AddObservability(builder.Configuration); .AddObservability(builder.Configuration);
builder.Services.AddProblemDetails();
if (builder.Environment.IsDevelopment()) if (builder.Environment.IsDevelopment())
{ {
builder.Services.AddCors(options => options.AddPolicy(devCorsPolicy, policy => builder.Services.AddCors(options => options.AddPolicy(devCorsPolicy, policy =>
@@ -19,6 +21,8 @@ if (builder.Environment.IsDevelopment())
var app = builder.Build(); var app = builder.Build();
app.UseExceptionHandler();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseCors(devCorsPolicy); app.UseCors(devCorsPolicy);
@@ -84,7 +84,7 @@ namespace Musify.Application.PlayLists
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey); logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key} )", intent.Id, request.UserId, tempKey);
return new PlayListPictureUploadResponse( return new PlayListPictureUploadResponse(
intent.Id, intent.Id,
@@ -24,7 +24,7 @@ namespace Musify.Application.Tracks
{ {
var track = await database.Tracks.AsNoTracking() var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId) .Where(t => t.Id == request.TrackId)
.Select(t => new { t.Id, t.AudioTranscodeProcessingStatus, t.AudioFolderName }) .Select(t => new { t.Id, t.AudioTranscodeProcessingStatus, t.AudioFolderName } )
.SingleOrDefaultAsync(cancellationToken); .SingleOrDefaultAsync(cancellationToken);
if (track is null) if (track is null)
@@ -15,11 +15,13 @@ namespace Musify.Infrastructure.Jobs
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
logger.LogInformation("TemporalUploadsCleanUpJob started (interval: {Interval}s, prefix: {Prefix})", logger.LogInformation("TemporalUploadsCleanUpJob started (interval: {Interval}s, prefix: {Prefix} )",
uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds, uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds,
uploadIntentConfiguration.TemporalRootPrefix); uploadIntentConfiguration.TemporalRootPrefix);
while (!stoppingToken.IsCancellationRequested) using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds));
do
{ {
try try
{ {
@@ -29,9 +31,8 @@ namespace Musify.Infrastructure.Jobs
{ {
logger.LogError(exception, "Error in TemporalUploadsCleanUpJob"); logger.LogError(exception, "Error in TemporalUploadsCleanUpJob");
} }
await Task.Delay(TimeSpan.FromSeconds(uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds), stoppingToken);
} }
while (await timer.WaitForNextTickAsync(stoppingToken));
} }
async Task RunAsync(CancellationToken cancellationToken) async Task RunAsync(CancellationToken cancellationToken)
@@ -18,7 +18,9 @@ namespace Musify.Infrastructure.Jobs
{ {
logger.LogInformation("UploadIntentExpirationJob started (interval: {Interval}s)", uploadIntentConfiguration.ExpirationJobIntervalSeconds); logger.LogInformation("UploadIntentExpirationJob started (interval: {Interval}s)", uploadIntentConfiguration.ExpirationJobIntervalSeconds);
while (!stoppingToken.IsCancellationRequested) using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.ExpirationJobIntervalSeconds));
do
{ {
try try
{ {
@@ -28,9 +30,8 @@ namespace Musify.Infrastructure.Jobs
{ {
logger.LogError(exception, "Error in UploadIntentExpirationJob"); logger.LogError(exception, "Error in UploadIntentExpirationJob");
} }
await Task.Delay(TimeSpan.FromSeconds(uploadIntentConfiguration.ExpirationJobIntervalSeconds), stoppingToken);
} }
while (await timer.WaitForNextTickAsync(stoppingToken));
} }
async Task RunAsync(CancellationToken cancellationToken) async Task RunAsync(CancellationToken cancellationToken)
@@ -30,7 +30,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures
await using var fileStream = File.OpenRead(sourceFilePath); await using var fileStream = File.OpenRead(sourceFilePath);
var resizedPicture = await pictureHandler.ResizePictureAsWebpAsync( await using var resizedPicture = await pictureHandler.ResizePictureAsWebpAsync(
fileStream, fileStream,
executeContext.Arguments.Width, executeContext.Arguments.Width,
executeContext.Arguments.Height, executeContext.Arguments.Height,
@@ -27,7 +27,7 @@ namespace Musify.Infrastructure.MassTransit
outbox.QueryDelay = TimeSpan.FromSeconds(1); outbox.QueryDelay = TimeSpan.FromSeconds(1);
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
}); } );
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
ConfigureRabbitMqHost( ConfigureRabbitMqHost(
@@ -61,7 +61,7 @@ namespace Musify.Infrastructure.MassTransit
outbox.QueryDelay = TimeSpan.FromSeconds(1); outbox.QueryDelay = TimeSpan.FromSeconds(1);
outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30);
}); } );
options.SetKebabCaseEndpointNameFormatter(); options.SetKebabCaseEndpointNameFormatter();
@@ -73,7 +73,7 @@ namespace Musify.Infrastructure.MassTransit
repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; repository.ConcurrencyMode = ConcurrencyMode.Pessimistic;
repository.ExistingDbContext<Database>(); repository.ExistingDbContext<Database>();
repository.UsePostgres(); repository.UsePostgres();
}); } );
options.AddSagaStateMachine<PlayListProcessingStateMachine, PlayListProcessingState>() options.AddSagaStateMachine<PlayListProcessingStateMachine, PlayListProcessingState>()
.EntityFrameworkRepository(repository => .EntityFrameworkRepository(repository =>
@@ -81,13 +81,13 @@ namespace Musify.Infrastructure.MassTransit
repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; repository.ConcurrencyMode = ConcurrencyMode.Pessimistic;
repository.ExistingDbContext<Database>(); repository.ExistingDbContext<Database>();
repository.UsePostgres(); repository.UsePostgres();
}); } );
options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) =>
{ {
UseStandardRetry(endpointConfigurator); UseStandardRetry(endpointConfigurator);
endpointConfigurator.UseEntityFrameworkOutbox<Database>(registrationContext); endpointConfigurator.UseEntityFrameworkOutbox<Database>(registrationContext);
}); } );
options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) =>
{ {
@@ -96,7 +96,7 @@ namespace Musify.Infrastructure.MassTransit
busRegistrationContext.GetRequiredService<MassTransitConfiguration>()); busRegistrationContext.GetRequiredService<MassTransitConfiguration>());
busFactoryConfigurator.ConfigureEndpoints(busRegistrationContext); busFactoryConfigurator.ConfigureEndpoints(busRegistrationContext);
}); } );
} ); } );
return serviceDescriptors; return serviceDescriptors;
@@ -28,7 +28,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas
context.Saga.UpdatedAt = DateTime.UtcNow; context.Saga.UpdatedAt = DateTime.UtcNow;
context.Saga.Bucket = context.Message.Bucket; context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey; context.Saga.PictureKey = context.Message.PictureDestinationKey;
}) } )
.TransitionTo(Processing)); .TransitionTo(Processing));
During(Processing, During(Processing,
@@ -33,7 +33,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas
context.Saga.Bucket = context.Message.Bucket; context.Saga.Bucket = context.Message.Bucket;
context.Saga.PictureKey = context.Message.PictureDestinationKey; context.Saga.PictureKey = context.Message.PictureDestinationKey;
context.Saga.AudioKey = context.Message.AudioDestinationKey; context.Saga.AudioKey = context.Message.AudioDestinationKey;
}) } )
.TransitionTo(Processing)); .TransitionTo(Processing));
During(Processing, During(Processing,
@@ -42,14 +42,14 @@ namespace Musify.Infrastructure.MassTransit.Sagas
{ {
context.Saga.PictureProcessed = true; context.Saga.PictureProcessed = true;
context.Saga.UpdatedAt = DateTime.UtcNow; context.Saga.UpdatedAt = DateTime.UtcNow;
}) } )
.If(context => context.Saga.AudioProcessed, binder => binder.Finalize()), .If(context => context.Saga.AudioProcessed, binder => binder.Finalize()),
When(AudioProcessed) When(AudioProcessed)
.Then(context => .Then(context =>
{ {
context.Saga.AudioProcessed = true; context.Saga.AudioProcessed = true;
context.Saga.UpdatedAt = DateTime.UtcNow; context.Saga.UpdatedAt = DateTime.UtcNow;
}) } )
.If(context => context.Saga.PictureProcessed, binder => binder.Finalize()), .If(context => context.Saga.PictureProcessed, binder => binder.Finalize()),
When(PictureFailed) When(PictureFailed)
.Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow)
@@ -15,7 +15,6 @@ namespace Musify.Infrastructure.Persistence
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder.UseNpgsql(configuration.ConnectionString); optionsBuilder.UseNpgsql(configuration.ConnectionString);
optionsBuilder.AddInterceptors(new AuditableEntityInterceptor());
} }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -18,7 +19,11 @@ namespace Musify.Infrastructure.Persistence
serviceDescriptors.AddSingleton(serviceProvider => serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value); serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value);
serviceDescriptors.AddDbContext<Database>(); serviceDescriptors.AddSingleton<AuditableEntityInterceptor>();
serviceDescriptors.AddDbContext<Database>((serviceProvider, options) =>
options.AddInterceptors(serviceProvider.GetRequiredService<AuditableEntityInterceptor>()));
serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>()); serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
return serviceDescriptors; return serviceDescriptors;
@@ -101,7 +101,10 @@ namespace Musify.Infrastructure.Services
using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: true); using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: true);
process.Start(); process.Start();
logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId} ) in {WorkingDirectory}", operationName, process.Id, workingDirectory);
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
if (inputStream.CanSeek) if (inputStream.CanSeek)
inputStream.Position = 0; inputStream.Position = 0;
@@ -109,7 +112,7 @@ namespace Musify.Infrastructure.Services
await inputStream.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); await inputStream.CopyToAsync(process.StandardInput.BaseStream, cancellationToken);
process.StandardInput.Close(); process.StandardInput.Close();
return await WaitForProcessResultAsync(process, operationName, cancellationToken); return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken);
} }
async Task<FfmpegExecutionResult> ExecuteFfmpegAsync( async Task<FfmpegExecutionResult> ExecuteFfmpegAsync(
@@ -121,9 +124,12 @@ namespace Musify.Infrastructure.Services
using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: false); using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: false);
process.Start(); process.Start();
logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId} ) in {WorkingDirectory}", operationName, process.Id, workingDirectory);
return await WaitForProcessResultAsync(process, operationName, cancellationToken); var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken);
} }
Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput) Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput)
@@ -144,12 +150,11 @@ namespace Musify.Infrastructure.Services
async Task<FfmpegExecutionResult> WaitForProcessResultAsync( async Task<FfmpegExecutionResult> WaitForProcessResultAsync(
Process process, Process process,
Task<string> outputTask,
Task<string> errorTask,
string operationName, string operationName,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linkedCancellationTokens.CancelAfter(audioTranscoderConfiguration.TranscodingTimeout); linkedCancellationTokens.CancelAfter(audioTranscoderConfiguration.TranscodingTimeout);
@@ -159,7 +164,7 @@ namespace Musify.Infrastructure.Services
} }
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{ {
logger.LogError("[{Operation}] ffmpeg timed out after {Timeout} (PID: {ProcessId})", logger.LogError("[{Operation}] ffmpeg timed out after {Timeout} (PID: {ProcessId} )",
operationName, audioTranscoderConfiguration.TranscodingTimeout, process.Id); operationName, audioTranscoderConfiguration.TranscodingTimeout, process.Id);
TryKillProcess(process); TryKillProcess(process);
@@ -169,7 +174,7 @@ namespace Musify.Infrastructure.Services
var standardError = await errorTask; var standardError = await errorTask;
var standardOutput = await outputTask; var standardOutput = await outputTask;
logger.LogDebug("[{Operation}] ffmpeg finished (PID: {ProcessId}). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B", logger.LogDebug("[{Operation}] ffmpeg finished (PID: {ProcessId} ). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B",
operationName, process.Id, process.ExitCode, standardOutput.Length, standardError.Length); operationName, process.Id, process.ExitCode, standardOutput.Length, standardError.Length);
return new FfmpegExecutionResult(process.ExitCode, standardOutput, standardError); return new FfmpegExecutionResult(process.ExitCode, standardOutput, standardError);
@@ -184,7 +189,7 @@ namespace Musify.Infrastructure.Services
} }
catch (Exception exception) catch (Exception exception)
{ {
logger.LogError(exception, "Failed to kill ffmpeg process (PID: {ProcessId})", process.Id); logger.LogError(exception, "Failed to kill ffmpeg process (PID: {ProcessId} )", process.Id);
} }
} }
@@ -21,7 +21,7 @@ namespace Musify.Infrastructure.Services
serviceDescriptors.AddSingleton(serviceProvider => serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<InfrastructureStorageConfiguration>>().Value); serviceProvider.GetRequiredService<IOptions<InfrastructureStorageConfiguration>>().Value);
serviceDescriptors.AddScoped<IAmazonS3>(serviceProvider => serviceDescriptors.AddSingleton<IAmazonS3>(serviceProvider =>
{ {
var configuration = serviceProvider.GetRequiredService<InfrastructureStorageConfiguration>(); var configuration = serviceProvider.GetRequiredService<InfrastructureStorageConfiguration>();
var s3Configuration = new AmazonS3Config var s3Configuration = new AmazonS3Config
@@ -276,7 +276,7 @@ namespace Musify.Infrastructure.Services
{ {
listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken);
if (listResponse.S3Objects is null or { Count: 0 }) if (listResponse.S3Objects is null or { Count: 0 } )
break; break;
var deleteRequest = new DeleteObjectsRequest var deleteRequest = new DeleteObjectsRequest
@@ -0,0 +1,17 @@
# Build context: backend/
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Directory.Build.props Directory.Packages.props nuget.config ./
COPY Musify.StreamingGateway/Musify.StreamingGateway.csproj Musify.StreamingGateway/
RUN dotnet restore Musify.StreamingGateway/Musify.StreamingGateway.csproj
COPY . .
RUN dotnet publish Musify.StreamingGateway/Musify.StreamingGateway.csproj -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app ./
ENV ASPNETCORE_URLS=http://+:8081
EXPOSE 8081
ENTRYPOINT ["dotnet", "Musify.StreamingGateway.dll"]
@@ -1,5 +1,4 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using ErrorOr;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -35,25 +34,17 @@ public sealed class TicketValidator : IDisposable
}; };
} }
public async Task<ErrorOr<string>> ValidateTicketAsync(string? token) public async Task<string> ValidateTicketAsync(string? token)
{ {
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
return Error.Unauthorized(); return string.Empty;
var result = await _handler.ValidateTokenAsync(token, _validationParameters); var result = await _handler.ValidateTokenAsync(token, _validationParameters);
if (!result.IsValid) if (!result.IsValid)
return Error.Unauthorized(); return string.Empty;
if (!result.Claims.TryGetValue("prefix", out var claim)) var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value;
return Error.Unauthorized(); return string.IsNullOrEmpty(prefix)? string.Empty : prefix;
if (claim is not string prefix)
return Error.Unauthorized();
if (string.IsNullOrEmpty(prefix))
return Error.Unauthorized();
return prefix;
} }
public void Dispose() => _rsa.Dispose(); public void Dispose() => _rsa.Dispose();
@@ -28,19 +28,27 @@ public sealed class TicketValidationMiddleware(
token = context.Request.Headers[options.HeaderName].ToString(); token = context.Request.Headers[options.HeaderName].ToString();
} }
var validation = await ticketValidator.ValidateTicketAsync(token); var prefix = await ticketValidator.ValidateTicketAsync(token);
if (validation.IsError) if (string.IsNullOrEmpty(prefix))
{ {
logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path); logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path);
context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return; return;
} }
var prefix = validation.Value;
var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty; var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty;
if (!objectKey.StartsWith(prefix, StringComparison.Ordinal))
if (HasTraversalSegment(objectKey))
{
logger.LogWarning("Rejected media request {ObjectKey}: contains a path traversal segment", objectKey);
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var prefixBoundary = prefix.EndsWith('/') ? prefix : prefix + "/";
if (!string.Equals(objectKey, prefix, StringComparison.Ordinal)
&& !objectKey.StartsWith(prefixBoundary, StringComparison.Ordinal))
{ {
logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix); logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix);
context.Response.StatusCode = StatusCodes.Status403Forbidden; context.Response.StatusCode = StatusCodes.Status403Forbidden;
@@ -51,6 +59,9 @@ public sealed class TicketValidationMiddleware(
await next(context); await next(context);
} }
private static bool HasTraversalSegment(string objectKey) =>
objectKey.Split('/').Any(segment => segment is ".." or ".");
private static void StripQueryParameter(HttpContext context, string name) private static void StripQueryParameter(HttpContext context, string name)
{ {
var remaining = context.Request.Query var remaining = context.Request.Query
@@ -5,7 +5,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ErrorOr" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" /> <PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
<PackageReference Include="Yarp.ReverseProxy" /> <PackageReference Include="Yarp.ReverseProxy" />
</ItemGroup> </ItemGroup>
+25
View File
@@ -0,0 +1,25 @@
# Build context: backend/
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Directory.Build.props Directory.Packages.props nuget.config ./
COPY Musify.Domain/Musify.Domain.csproj Musify.Domain/
COPY Musify.Application/Musify.Application.csproj Musify.Application/
COPY Musify.Infrastructure/Musify.Infrastructure.csproj Musify.Infrastructure/
COPY Musify.Worker/Musify.Worker.csproj Musify.Worker/
RUN dotnet restore Musify.Worker/Musify.Worker.csproj
COPY . .
RUN dotnet publish Musify.Worker/Musify.Worker.csproj -c Release -o /app --no-restore
# AppSettings*.json (PascalCase) -> lowercase copies for case-sensitive Linux.
RUN cp /app/AppSettings.json /app/appsettings.json \
&& cp /app/AppSettings.Development.json /app/appsettings.Development.json
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# ffmpeg: the Worker transcodes audio to .m4a.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app ./
ENTRYPOINT ["dotnet", "Musify.Worker.dll"]
-3
View File
@@ -2,7 +2,6 @@ using Musify.Application.Configuration;
using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.MassTransit;
using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Persistence;
using Musify.Infrastructure.Services; using Musify.Infrastructure.Services;
using Musify.Worker;
var builder = Host.CreateApplicationBuilder(); var builder = Host.CreateApplicationBuilder();
@@ -20,8 +19,6 @@ services.AddDatabase(configuration);
services.AddMassTransitConsumers(configuration); services.AddMassTransitConsumers(configuration);
services.AddUploadIntentJobs(configuration); services.AddUploadIntentJobs(configuration);
services.AddHostedService<Worker>();
var host = builder.Build(); var host = builder.Build();
host.Run(); host.Run();
-9
View File
@@ -1,9 +0,0 @@
namespace Musify.Worker
{
public class Worker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
}
}
}
+35
View File
@@ -0,0 +1,35 @@
# Dev stack config (deploy/docker-compose.yml).
# Copy to deploy/.env and adjust. .env is not versioned (.gitignore ignores *.env).
# PostgreSQL (shared by Musify and Zitadel). Local apps use localhost:59000.
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=musify_db
POSTGRES_HOST_PORT=59000
# RabbitMQ (MassTransit)
RABBITMQ_DEFAULT_USER=guest
RABBITMQ_DEFAULT_PASS=guest
# Zitadel (OIDC). The masterkey MUST be exactly 32 characters.
ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters
# The external domain/port forms the token issuer and must match everywhere.
# host.docker.internal resolves the same from browser, local apps and containers,
# so auth works both locally and full-docker. Changing it needs a re-init
# (up.ps1 -Destroy; up.ps1). Admin login: <user>@zitadel.<domain>.
ZITADEL_EXTERNAL_DOMAIN=host.docker.internal
ZITADEL_EXTERNAL_PORT=8080
ZITADEL_ADMIN_USERNAME=admin
ZITADEL_ADMIN_PASSWORD=Password1!
# SeaweedFS (S3). Must match deploy/seaweedfs/s3.conf.
S3_ACCESS_KEY=admin_access_key
S3_SECRET_KEY=admin_secret_key
S3_BUCKET=webapi-storage
# Host paths used by up.ps1 and the apps overlay.
STREAM_KEYS_DIR=D:/weed
WORKER_TEMP_DIR=D:/tempsFilesDev
# OIDC ClientId, filled in by zitadel/provision.ps1 (used by the docker path).
AUTH_CLIENT_ID=
+192
View File
@@ -0,0 +1,192 @@
# Development deploy
Everything the backend needs for development, in one place. A single
`docker-compose.yml` brings up **PostgreSQL, Zitadel, RabbitMQ, SeaweedFS and
Jaeger**; the .NET apps run from the IDE (or, optionally, in Docker too).
## Quick start
```powershell
# From the repo root (Musify/)
./deploy/up.ps1
```
`up.ps1` does the full bootstrap:
1. Creates `deploy/.env` from `.env.example` if missing.
2. Starts the stack (`docker compose up -d`) and waits for Postgres to be healthy.
3. Creates the `webapi-storage` bucket in SeaweedFS (`seaweedfs-init` service).
4. Generates the RS256 stream-ticket keys in `D:\weed` if missing.
5. Creates the Worker temp directory (`D:\tempsFilesDev`).
6. Applies the EF Core migrations to `musify_db`.
7. **Provisions Zitadel automatically** (project + OIDC app with JWT token) and
wires the ClientId into `Musify.Api` user-secrets and `.env`.
When it finishes it prints a summary with URLs and credentials. **No manual steps.**
### Without the script (infra only)
```powershell
docker compose -f deploy/docker-compose.yml up -d
```
The compose file still creates the bucket on its own; run migrations with
`dotnet ef database update` (see [../docs/development.md](../docs/development.md)).
## Services and ports
| Service | Port(s) | Used for |
|---|---|---|
| PostgreSQL | `59000` → 5432 | API + Worker (EF Core) and the Zitadel database |
| Zitadel | `8080` | OIDC/OAuth2 (console at `/ui/console`) |
| RabbitMQ | `5672` / `15672` | MassTransit / management UI |
| SeaweedFS S3 | `8333` | S3 (presigned PUT/GET) |
| SeaweedFS filer | `8888` | serves files → StreamingGateway |
| SeaweedFS master | `9333` | admin/UI |
| Jaeger | `16686` / `4317` / `4318` | OTLP traces (UI / gRPC / HTTP) |
Ports are published to the host, so apps run locally connect without changing
`AppSettings.Development.json` (Postgres, RabbitMQ, S3, OTLP).
## Authentication (Zitadel) — automatic
There are **no manual steps**. `up.ps1` runs `zitadel/provision.ps1`, which
idempotently:
1. Uses a **service account** with a **PAT** that Zitadel writes on init
(`zitadel/.output/admin-sa.pat`).
2. Creates (or reuses) the **project** `Musify` and an **OIDC app** (PKCE,
`devMode`, **access token = JWT** — required for `JwtBearer` to validate).
3. Captures the generated **ClientId** and writes it to:
- **`Musify.Api` user-secrets** (for running the apps locally), and
- `deploy/.env` as `AUTH_CLIENT_ID` (for the full-docker path).
The values stay stable while the Postgres volume persists. After `up.ps1 -Destroy`
the next `up.ps1` re-provisions (new ClientId, re-wired).
- Console: `http://host.docker.internal:8080/ui/console`
- Admin login: `admin@zitadel.host.docker.internal` / `Password1!` (see `.env`).
- Test login: open Scalar (`http://localhost:5111/scalar/v1`) and authorize.
### Why `host.docker.internal`
`ZITADEL_EXTERNAL_DOMAIN=host.docker.internal` forms the token *issuer* and
resolves the **same** from the browser, local apps and containers, so token
validation works both ways (local and full-docker). Zitadel routes by the `Host`
header, so the domain must be identical everywhere. If you only run the apps
locally you can use `localhost`. Changing the domain requires a re-init:
`up.ps1 -Destroy; up.ps1`.
## Running the apps
### Recommended: from the IDE (best for debugging)
```powershell
dotnet run --project backend/Musify.Api # :5111
dotnet run --project backend/Musify.StreamingGateway # :8081
dotnet run --project backend/Musify.Worker # background
```
Host requirements: **.NET 10 SDK**, **ffmpeg** on PATH (Worker audio transcode).
### Optional: everything in Docker
```powershell
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.apps.yml up -d --build
```
Builds and runs `api`, `worker` and `gateway` as containers (the Worker already
ships ffmpeg). The stream-ticket keys are mounted from `D:\weed` (configurable via
`STREAM_KEYS_DIR` in `.env`). Auth works in this mode too (the API downloads the
Zitadel JWKS via `host.docker.internal`).
> Note: on Linux the apps look for `appsettings*.json` in lowercase. The `Api`/
> `Worker` projects use `AppSettings*.json` (PascalCase), so their Dockerfiles
> create lowercase copies at publish time. On Windows it did not matter
> (case-insensitive).
## Useful commands
```powershell
./deploy/up.ps1 -SkipMigrations # bring up without touching the DB
./deploy/up.ps1 -Down # stop and remove containers (keep data)
./deploy/up.ps1 -Destroy # stop and remove containers + volumes (wipe data)
docker compose -f deploy/docker-compose.yml logs -f zitadel
docker compose -f deploy/docker-compose.yml ps
```
## Project layout — what each file does
```
deploy/
├─ docker-compose.yml # infrastructure stack (single source of truth)
├─ docker-compose.apps.yml # optional overlay: the 3 .NET apps in Docker
├─ .env # local config (secrets/ports); not versioned
├─ .env.example # config template (versioned)
├─ up.ps1 # orchestrator: up / down / destroy
├─ README.md # this file
├─ scripts/
│ ├─ common.ps1 # shared helpers (Read-DotEnv, Write-Step)
│ ├─ stream-keys.ps1 # generates the RS256 stream-ticket key pair
│ └─ migrate.ps1 # applies EF Core migrations
├─ seaweedfs/
│ └─ s3.conf # SeaweedFS S3 identities/credentials
└─ zitadel/
├─ provision.ps1 # creates project + OIDC app, wires the ClientId
└─ .output/ # PAT + init secrets written by Zitadel (not versioned)
backend/
├─ Musify.Api.Dockerfile # API image
├─ Musify.Worker.Dockerfile # Worker image (includes ffmpeg)
├─ Musify.StreamingGateway.Dockerfile # Gateway image
└─ .dockerignore
```
### Compose
- **`docker-compose.yml`** — the infra stack (`postgres`, `zitadel`, `rabbitmq`,
`seaweedfs`, the one-shot `seaweedfs-init` bucket creator, and `jaeger`). This is
the single source of truth for services, ports and volumes. Values come from `.env`.
- **`docker-compose.apps.yml`** — an optional overlay that adds the `api`, `worker`
and `gateway` services. It merges with the infra file (same `musify-dev` project
and network) and shares the backend env via YAML anchors. Used to run the whole
stack in Docker; for active development prefer running the apps from the IDE.
### Config
- **`.env`** — the actual values used by compose (Postgres/RabbitMQ/Zitadel/S3
credentials, ports, host paths, `AUTH_CLIENT_ID`). Ignored by git (`*.env`).
- **`.env.example`** — the versioned template. Copy it to `.env` (or let `up.ps1`
do it) and adjust.
### Scripts (PowerShell)
- **`up.ps1`** — the orchestrator. Parses `.env`, starts the stack, waits for
Postgres, then calls the sub-scripts in order (keys → migrations → provisioning)
and prints the summary. Also handles `-Down` / `-Destroy` / `-SkipMigrations`.
- **`scripts/common.ps1`** — shared helpers dot-sourced by the others: `Read-DotEnv`
(parse `.env` into a hashtable) and `Write-Step` (step logging).
- **`scripts/stream-keys.ps1`** — generates the RS256 key pair that the API uses to
sign stream tickets and the Gateway uses to verify them. No-op if the keys exist.
- **`scripts/migrate.ps1`** — sets the connection string in user-secrets and runs
`dotnet ef database update` (startup project = `Musify.Infrastructure`).
- **`zitadel/provision.ps1`** — reads the service-account PAT, creates/reuses the
Zitadel project and OIDC app (JWT token), and wires the ClientId into
`Musify.Api` user-secrets and `.env`. Idempotent; can be run standalone.
### Service configs
- **`seaweedfs/s3.conf`** — the S3 identities (access/secret keys) SeaweedFS loads.
Must match `InfrastructureStorage:*` in the app settings and `S3_*` in `.env`.
- **`zitadel/.output/`** — created at runtime; holds the PAT Zitadel writes on init
(`admin-sa.pat`). Not versioned; wiped by `up.ps1 -Destroy`.
### Dockerfiles (in `backend/`)
- **`Musify.Api.Dockerfile`** / **`Musify.Worker.Dockerfile`** /
**`Musify.StreamingGateway.Dockerfile`** — multi-stage builds for each host. The
Worker image also installs ffmpeg. Built by the apps overlay; build context is
`backend/` so restore can copy the central props and all csproj files.
- **`.dockerignore`** — keeps `bin/`, `obj/`, IDE folders and `DesignSettings.json`
out of the build context.
+96
View File
@@ -0,0 +1,96 @@
name: musify-dev
# Optional overlay: runs the .NET apps in Docker. Merges with docker-compose.yml.
#
# docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.apps.yml up -d --build
#
# For active development prefer infra in Docker + apps from the IDE (easier to
# debug). Auth works in both modes: ZITADEL_EXTERNAL_DOMAIN=host.docker.internal
# resolves the same from the browser, local apps and containers, so the token
# issuer matches everywhere (containers reach the host via extra_hosts).
x-backend-depends: &backend-depends
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
seaweedfs:
condition: service_healthy
x-backend-env: &backend-env
Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
MassTransit__Address: "rabbitmq://rabbitmq:5672"
MassTransit__Username: ${RABBITMQ_DEFAULT_USER}
MassTransit__Password: ${RABBITMQ_DEFAULT_PASS}
InfrastructureStorage__Address: "http://seaweedfs:8333"
InfrastructureStorage__AccessKey: ${S3_ACCESS_KEY}
InfrastructureStorage__SecretAccessKey: ${S3_SECRET_KEY}
InfrastructureStorage__ForcePathStyle: "true"
InfrastructureStorage__UseHttp: "true"
ApplicationStorage__Bucket: ${S3_BUCKET}
OpenTelemetry__OtlpEndpoint: "http://jaeger:4317"
services:
api:
build:
context: ../backend
dockerfile: Musify.Api.Dockerfile
container_name: musify-api
depends_on: *backend-depends
environment:
<<: *backend-env
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:5111
StreamGateway__PublicBaseUrl: "http://localhost:8081"
StreamTicket__PrivateKeyPath: "/keys/stream_private.pem"
# Auth values provisioned by zitadel/provision.ps1 (AUTH_CLIENT_ID in .env).
Authentication__MetadataAddress: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/.well-known/openid-configuration"
Authentication__IssuerAddress: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}"
Authentication__ClientId: "${AUTH_CLIENT_ID}"
Authentication__AudienceAddress: "${AUTH_CLIENT_ID}"
Authentication__AuthorizationEndpoint: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/oauth/v2/authorize?prompt=login"
Authentication__TokenEndpoint: "http://${ZITADEL_EXTERNAL_DOMAIN}:${ZITADEL_EXTERNAL_PORT}/oauth/v2/token"
Authentication__ScalarRedirectUri: "http://localhost:5111/scalar/"
Authentication__Scopes__0: "openid"
Authentication__Scopes__1: "profile"
Authentication__Scopes__2: "email"
Authentication__RequireHttpsMetadata: "false"
extra_hosts:
- "${ZITADEL_EXTERNAL_DOMAIN}:host-gateway"
volumes:
- "${STREAM_KEYS_DIR:-D:/weed}:/keys:ro"
ports:
- "5111:5111"
restart: unless-stopped
worker:
build:
context: ../backend
dockerfile: Musify.Worker.Dockerfile
container_name: musify-worker
depends_on: *backend-depends
environment:
<<: *backend-env
DOTNET_ENVIRONMENT: Development
AudioTranscoder__Routes__WorkingDirectory: "/tmp/musify"
Workers__Routes__TemporaryFilesDirectory: "/tmp/musify"
restart: unless-stopped
gateway:
build:
context: ../backend
dockerfile: Musify.StreamingGateway.Dockerfile
container_name: musify-gateway
depends_on:
seaweedfs:
condition: service_healthy
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8081
StreamTicket__PublicKeyPath: "/keys/stream_public.pem"
ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://seaweedfs:8888/"
volumes:
- "${STREAM_KEYS_DIR:-D:/weed}:/keys:ro"
ports:
- "8081:8081"
restart: unless-stopped
+146
View File
@@ -0,0 +1,146 @@
name: musify-dev
# Infrastructure stack for Musify development: everything the backend needs
# (Postgres, Zitadel, RabbitMQ, SeaweedFS + bucket, Jaeger/OTLP). The .NET apps
# run from the IDE and connect over the published ports. Config in deploy/.env
#
# docker compose -f deploy/docker-compose.yml up -d
services:
postgres:
image: postgres:17-alpine
container_name: musify-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "${POSTGRES_HOST_PORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
zitadel:
image: ghcr.io/zitadel/zitadel:latest
container_name: musify-zitadel
command: 'start-from-init --masterkeyFromEnv --tlsMode disabled'
environment:
ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY}
ZITADEL_EXTERNALSECURE: "false"
ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNAL_DOMAIN}
ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNAL_PORT}
ZITADEL_TLS_ENABLED: "false"
# Reuses the same Postgres: the admin user creates the 'zitadel' database
# and the 'zitadel' user on first start.
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_USER}
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
# Initial console admin. Login: <user>@zitadel.<domain>
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false"
# Service account + PAT for automatic provisioning. Zitadel writes the PAT
# to PATPATH on init; provision.ps1 uses it to create the project and app.
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z"
ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat
depends_on:
postgres:
condition: service_healthy
ports:
- "${ZITADEL_EXTERNAL_PORT}:8080"
volumes:
- ./zitadel/.output:/output
restart: unless-stopped
rabbitmq:
image: rabbitmq:4-management-alpine
container_name: musify-rabbitmq
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS}
ports:
- "5672:5672" # AMQP -> MassTransit (API + Worker)
- "15672:15672" # management UI
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
seaweedfs:
image: chrislusf/seaweedfs:latest
container_name: musify-seaweedfs
# All-in-one dev server: master + volume + filer + S3 gateway.
command: >
server
-ip.bind=0.0.0.0
-dir=/data
-master.volumeSizeLimitMB=1024
-volume.max=0
-filer
-s3
-s3.config=/etc/seaweedfs/s3.conf
ports:
- "9333:9333" # master (admin/UI)
- "8888:8888" # filer HTTP -> StreamingGateway
- "8333:8333" # S3 API -> WebApi/Worker (InfrastructureStorage:Address)
volumes:
- seaweedfs_data:/data
- ./seaweedfs/s3.conf:/etc/seaweedfs/s3.conf:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
# One-shot: creates the app bucket in SeaweedFS (it is not auto-created).
seaweedfs-init:
image: amazon/aws-cli:latest
container_name: musify-seaweedfs-init
depends_on:
seaweedfs:
condition: service_healthy
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
AWS_DEFAULT_REGION: us-east-1
entrypoint: ["/bin/sh", "-c"]
command:
- |
aws --endpoint-url http://seaweedfs:8333 s3 mb s3://${S3_BUCKET} 2>/dev/null || true
aws --endpoint-url http://seaweedfs:8333 s3 ls
restart: "no"
# OTLP collector + trace UI (MassTransit/EF/HTTP spans). OtlpEndpoint = :4317
jaeger:
image: jaegertracing/all-in-one:latest
container_name: musify-jaeger
environment:
COLLECTOR_OTLP_ENABLED: "true"
ports:
- "16686:16686" # UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
restart: unless-stopped
volumes:
postgres_data:
rabbitmq_data:
seaweedfs_data:
+15
View File
@@ -0,0 +1,15 @@
#requires -Version 5.1
# Shared helpers for the deploy scripts.
function Write-Step([string]$Message) { Write-Host "`n==> $Message" -ForegroundColor Cyan }
# Parses a .env file into a hashtable (ignores comments and blank lines).
function Read-DotEnv([string]$Path) {
$values = @{}
if (Test-Path $Path) {
Get-Content $Path | ForEach-Object {
if ($_ -match '^\s*([^#=\s][^=]*)=(.*)$') { $values[$matches[1].Trim()] = $matches[2].Trim() }
}
}
return $values
}
+15
View File
@@ -0,0 +1,15 @@
#requires -Version 5.1
# Applies EF Core migrations to the Musify database.
[CmdletBinding()]
param([Parameter(Mandatory)][string]$ConnectionString)
$ErrorActionPreference = "Stop"
$infraProject = Resolve-Path (Join-Path $PSScriptRoot "..\..\backend\Musify.Infrastructure")
# The design-time factory reads the connection string from user-secrets.
dotnet user-secrets set "Database:ConnectionString" $ConnectionString --project $infraProject | Out-Null
# Infrastructure is also the startup project: it owns the IDesignTimeDbContextFactory
# and the EFCore.Design package (Musify.Api does not reference Design).
dotnet ef database update --project $infraProject --startup-project $infraProject --context Database
if ($LASTEXITCODE -ne 0) { throw "EF Core migration failed" }
+28
View File
@@ -0,0 +1,28 @@
#requires -Version 5.1
# Generates the RS256 key pair used to sign (API) and verify (Gateway) stream tickets.
# No-op if both keys already exist.
[CmdletBinding()]
param([Parameter(Mandatory)][string]$KeysDir)
$ErrorActionPreference = "Stop"
$privateKey = Join-Path $KeysDir "stream_private.pem"
$publicKey = Join-Path $KeysDir "stream_public.pem"
if ((Test-Path $privateKey) -and (Test-Path $publicKey)) {
Write-Host "Stream-ticket keys already present in $KeysDir"
return
}
if (-not (Test-Path $KeysDir)) { New-Item -ItemType Directory -Path $KeysDir | Out-Null }
if (Get-Command openssl -ErrorAction SilentlyContinue) {
& openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out $privateKey
& openssl rsa -in $privateKey -pubout -out $publicKey
} else {
$rsa = [System.Security.Cryptography.RSA]::Create(2048)
[System.IO.File]::WriteAllText($privateKey, $rsa.ExportPkcs8PrivateKeyPem())
[System.IO.File]::WriteAllText($publicKey, $rsa.ExportSubjectPublicKeyInfoPem())
$rsa.Dispose()
}
Write-Host "Generated stream-ticket keys in $KeysDir"
-32
View File
@@ -1,32 +0,0 @@
services:
seaweedfs:
image: chrislusf/seaweedfs:latest
container_name: seaweedfs
# All-in-one dev server: master + volume + filer + S3 gateway.
command: >
server
-ip.bind=0.0.0.0
-dir=/data
-master.volumeSizeLimitMB=1024
-volume.max=0
-filer
-s3
-s3.config=/etc/seaweedfs/s3.conf
ports:
- "9333:9333" # master (admin/UI)
- "8888:8888" # filer HTTP -> used by the Media Gateway
- "8333:8333" # S3 API -> used by the WebApi (InfrastructureStorage:Address)
# Volume server (8080) is left unpublished on purpose: it only needs to be
# reachable internally, and 8080 clashes with Zitadel on the host.
volumes:
- seaweedfs_data:/data
- ./s3.conf:/etc/seaweedfs/s3.conf:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"]
interval: 10s
timeout: 5s
retries: 10
volumes:
seaweedfs_data:
+121
View File
@@ -0,0 +1,121 @@
#requires -Version 5.1
<#
.SYNOPSIS
Brings up the Musify development environment.
.DESCRIPTION
Starts the infrastructure stack, generates the stream-ticket keys, applies EF
Core migrations and provisions Zitadel (project + OIDC app + ClientId).
.EXAMPLE
./up.ps1 # Bring everything up
./up.ps1 -SkipMigrations # Skip the database migration step
./up.ps1 -Down # Stop and remove containers (keep volumes)
./up.ps1 -Destroy # Stop and remove containers + volumes (wipe data)
#>
[CmdletBinding()]
param(
[switch]$Down,
[switch]$Destroy,
[switch]$SkipMigrations
)
$ErrorActionPreference = "Stop"
$deployDir = $PSScriptRoot
$composeFile = Join-Path $deployDir "docker-compose.yml"
. (Join-Path $deployDir "scripts\common.ps1")
# Teardown -------------------------------------------------------------------
if ($Down -or $Destroy) {
if ($Destroy) {
Write-Step "Stopping the stack and REMOVING volumes (data is wiped)"
docker compose -f $composeFile down -v --remove-orphans
# Drop the Zitadel init secrets (PAT) so the next start re-initializes cleanly.
Get-ChildItem (Join-Path $deployDir "zitadel\.output") -File -ErrorAction SilentlyContinue | Remove-Item -Force
} else {
Write-Step "Stopping the stack (volumes are kept)"
docker compose -f $composeFile down --remove-orphans
}
return
}
# Config ---------------------------------------------------------------------
$envFile = Join-Path $deployDir ".env"
if (-not (Test-Path $envFile)) {
Write-Step "Creating .env from .env.example"
Copy-Item (Join-Path $deployDir ".env.example") $envFile
}
$cfg = Read-DotEnv $envFile
$streamKeysDir = if ($cfg['STREAM_KEYS_DIR']) { $cfg['STREAM_KEYS_DIR'] } else { "D:/weed" }
$workerTempDir = if ($cfg['WORKER_TEMP_DIR']) { $cfg['WORKER_TEMP_DIR'] } else { "D:/tempsFilesDev" }
# Infrastructure -------------------------------------------------------------
Write-Step "Starting the infrastructure stack"
docker compose -f $composeFile up -d
if ($LASTEXITCODE -ne 0) { throw "docker compose up failed" }
Write-Step "Waiting for Postgres to become healthy"
$deadline = (Get-Date).AddMinutes(2)
do {
Start-Sleep -Seconds 2
$status = docker inspect --format '{{.State.Health.Status}}' musify-postgres 2>$null
} until ($status -eq "healthy" -or (Get-Date) -gt $deadline)
if ($status -ne "healthy") { throw "Postgres did not become healthy in time" }
# Stream-ticket keys ---------------------------------------------------------
Write-Step "Ensuring stream-ticket keys"
& (Join-Path $deployDir "scripts\stream-keys.ps1") -KeysDir $streamKeysDir
# Worker temp directory ------------------------------------------------------
if (-not (Test-Path $workerTempDir)) {
Write-Step "Creating the Worker temp directory: $workerTempDir"
New-Item -ItemType Directory -Path $workerTempDir | Out-Null
}
# Database migrations --------------------------------------------------------
if ($SkipMigrations) {
Write-Host "`nSkipping migrations (-SkipMigrations)."
} else {
Write-Step "Applying EF Core migrations"
$conn = "Host=localhost;Port=$($cfg['POSTGRES_HOST_PORT']);Database=$($cfg['POSTGRES_DB']);Username=$($cfg['POSTGRES_USER']);Password=$($cfg['POSTGRES_PASSWORD'])"
& (Join-Path $deployDir "scripts\migrate.ps1") -ConnectionString $conn
}
# Zitadel provisioning -------------------------------------------------------
Write-Step "Provisioning Zitadel (project + OIDC app + ClientId)"
try {
& (Join-Path $deployDir "zitadel\provision.ps1")
} catch {
Write-Host "Warning: Zitadel provisioning failed: $($_.Exception.Message)" -ForegroundColor Yellow
}
# Summary --------------------------------------------------------------------
$zDomain = $cfg['ZITADEL_EXTERNAL_DOMAIN']
$zPort = $cfg['ZITADEL_EXTERNAL_PORT']
Write-Host @"
============================================================
Musify development environment READY
============================================================
Service URL / port
------------------ --------------------------------------
PostgreSQL localhost:$($cfg['POSTGRES_HOST_PORT']) (db: $($cfg['POSTGRES_DB']))
Zitadel console http://$zDomain`:$zPort/ui/console
RabbitMQ mgmt http://localhost:15672 ($($cfg['RABBITMQ_DEFAULT_USER'])/$($cfg['RABBITMQ_DEFAULT_PASS']))
SeaweedFS S3 http://localhost:8333 (bucket: $($cfg['S3_BUCKET']))
SeaweedFS filer http://localhost:8888
SeaweedFS master http://localhost:9333
Jaeger traces http://localhost:16686 (OTLP: localhost:4317)
Zitadel admin: $($cfg['ZITADEL_ADMIN_USERNAME'])@zitadel.$zDomain / $($cfg['ZITADEL_ADMIN_PASSWORD'])
Auth is provisioned automatically; ClientId is in Musify.Api user-secrets and .env.
Log in via Scalar: http://localhost:5111/scalar/v1
Run the apps from the IDE or:
dotnet run --project backend/Musify.Api # :5111
dotnet run --project backend/Musify.StreamingGateway # :8081
dotnet run --project backend/Musify.Worker # background
============================================================
"@ -ForegroundColor Green
+2
View File
@@ -0,0 +1,2 @@
# Secrets Zitadel writes on init (do not version)
.output/
+121
View File
@@ -0,0 +1,121 @@
#requires -Version 5.1
<#
.SYNOPSIS
Provisions Zitadel for Musify (idempotent).
.DESCRIPTION
Creates the project and the OIDC app (PKCE, JWT access token) and wires the
generated ClientId into Musify.Api user-secrets and deploy/.env (AUTH_CLIENT_ID).
Uses the service-account PAT that Zitadel writes on init (zitadel/.output/admin-sa.pat).
Re-running reuses the existing project/app and refreshes the wired values.
#>
[CmdletBinding()]
param(
[string]$ProjectName = "Musify",
[string]$AppName = "Musify API (Scalar)"
)
$ErrorActionPreference = "Stop"
$deployDir = Split-Path $PSScriptRoot -Parent
$apiProject = Resolve-Path (Join-Path $deployDir "..\backend\Musify.Api")
$patFile = Join-Path $PSScriptRoot ".output\admin-sa.pat"
. (Join-Path $deployDir "scripts\common.ps1")
$cfg = Read-DotEnv (Join-Path $deployDir ".env")
$issuer = "http://$($cfg['ZITADEL_EXTERNAL_DOMAIN']):$($cfg['ZITADEL_EXTERNAL_PORT'])"
$apiBase = "http://localhost:5111"
$scalarRedirect = "$apiBase/scalar/"
# Zitadel writes the PAT during init; on a cold start it may take a moment.
$deadline = (Get-Date).AddMinutes(3)
while (-not (Test-Path $patFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 3 }
if (-not (Test-Path $patFile)) { throw "PAT not found ($patFile). Check 'docker logs musify-zitadel'." }
$headers = @{ Authorization = "Bearer $((Get-Content $patFile -Raw).Trim())" }
$mgmt = "$issuer/management/v1"
function Invoke-Zitadel($method, $path, $body) {
$params = @{ Method = $method; Uri = "$mgmt$path"; Headers = $headers; ContentType = "application/json"; TimeoutSec = 20 }
if ($body) { $params.Body = ($body | ConvertTo-Json -Depth 10) }
Invoke-RestMethod @params
}
Write-Step "Waiting for Zitadel at $issuer"
$deadline = (Get-Date).AddMinutes(2)
do {
try { Invoke-RestMethod "$issuer/.well-known/openid-configuration" -TimeoutSec 5 | Out-Null; $ready = $true }
catch { $ready = $false; Start-Sleep 3 }
} until ($ready -or (Get-Date) -gt $deadline)
if (-not $ready) { throw "Zitadel did not respond in time." }
# Project (idempotent) -------------------------------------------------------
Write-Step "Project '$ProjectName'"
$project = (Invoke-Zitadel POST "/projects/_search" @{
queries = @(@{ nameQuery = @{ name = $ProjectName; method = "TEXT_QUERY_METHOD_EQUALS" } })
}).result | Where-Object { $_.name -eq $ProjectName } | Select-Object -First 1
if ($project) {
$projectId = $project.id
Write-Host " reused (id $projectId)"
} else {
$projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id
Write-Host " created (id $projectId)"
}
# OIDC app (idempotent) ------------------------------------------------------
Write-Step "OIDC app '$AppName'"
$app = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
queries = @(@{ nameQuery = @{ name = $AppName; method = "TEXT_QUERY_METHOD_EQUALS" } })
}).result | Where-Object { $_.name -eq $AppName } | Select-Object -First 1
if ($app) {
$clientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($app.id)" $null).app.oidcConfig.clientId
Write-Host " reused (clientId $clientId)"
} else {
$clientId = (Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
name = $AppName
redirectUris = @($scalarRedirect, "$apiBase/scalar/oauth2-redirect.html")
postLogoutRedirectUris = @($scalarRedirect)
responseTypes = @("OIDC_RESPONSE_TYPE_CODE")
grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE")
appType = "OIDC_APP_TYPE_USER_AGENT"
authMethodType = "OIDC_AUTH_METHOD_TYPE_NONE"
version = "OIDC_VERSION_1_0"
devMode = $true # allow http redirect URIs
accessTokenType = "OIDC_TOKEN_TYPE_JWT" # required so JwtBearer can validate
accessTokenRoleAssertion = $true
idTokenRoleAssertion = $true
idTokenUserinfoAssertion = $true
}).clientId
Write-Host " created (clientId $clientId)"
}
# Wire the config: user-secrets (local runs) + .env (docker runs) -------------
Write-Step "Wiring Authentication into Musify.Api user-secrets"
@{
"Authentication:ClientId" = $clientId
"Authentication:AudienceAddress" = $clientId
"Authentication:IssuerAddress" = $issuer
"Authentication:MetadataAddress" = "$issuer/.well-known/openid-configuration"
"Authentication:AuthorizationEndpoint" = "$issuer/oauth/v2/authorize?prompt=login"
"Authentication:TokenEndpoint" = "$issuer/oauth/v2/token"
"Authentication:ScalarRedirectUri" = $scalarRedirect
"Authentication:RequireHttpsMetadata" = "false"
}.GetEnumerator() | ForEach-Object {
dotnet user-secrets set $_.Key $_.Value --project $apiProject | Out-Null
}
$envPath = Join-Path $deployDir ".env"
$envLines = Get-Content $envPath
if ($envLines -match '^\s*AUTH_CLIENT_ID=') {
$envLines = $envLines -replace '^\s*AUTH_CLIENT_ID=.*', "AUTH_CLIENT_ID=$clientId"
} else {
$envLines += "AUTH_CLIENT_ID=$clientId"
}
Set-Content -Path $envPath -Value $envLines -Encoding utf8
Write-Host "`nZitadel provisioned:" -ForegroundColor Green
Write-Host " Project: $ProjectName ($projectId)"
Write-Host " ClientId: $clientId"
Write-Host " Issuer: $issuer"
+17 -16
View File
@@ -4,21 +4,21 @@ Cómo levantar todo en local y probarlo.
## Dependencias (Docker) ## Dependencias (Docker)
Todo (PostgreSQL, Zitadel, RabbitMQ, SeaweedFS, Jaeger) vive en un solo stack en
[`deploy/`](../deploy/README.md). Bootstrap completo (levanta + bucket + claves + migraciones):
```powershell ```powershell
# SeaweedFS (almacenamiento) ./deploy/up.ps1
docker compose -f deploy\seaweedfs\docker-compose.yml up -d
# PostgreSQL + pgAdmin
docker compose -f "C:\Users\XShin\OneDrive\Desktop\postgresql\docker-compose.yml" up -d
# Zitadel (auth)
docker compose -f "C:\Users\XShin\OneDrive\Pictures\zitadel-compose\docker-compose.yml" up -d
# RabbitMQ (contenedor suelto)
docker start rabbitmq
``` ```
> Nota: usa `docker compose up` (no `docker start`) para Postgres/Zitadel: si sus redes se borraron, compose las recrea. O solo la infraestructura:
```powershell
docker compose -f deploy\docker-compose.yml up -d
```
Config (usuarios/claves/puertos) en `deploy/.env`. Ver [deploy/README.md](../deploy/README.md)
para el detalle y el paso de configuración de Zitadel (crear la app OIDC y copiar el ClientId).
## Aplicaciones (.NET) ## Aplicaciones (.NET)
@@ -44,14 +44,15 @@ DB: `musify_db`, usuario `postgres`/`postgres`. Bucket S3: `webapi-storage` (cre
## Migraciones ## Migraciones
No hay migración automática al arrancar. Para aplicar cambios de esquema: No hay migración automática al arrancar. `deploy/up.ps1` las aplica en el bootstrap;
para hacerlo a mano tras cambios de esquema:
```powershell ```powershell
cd MusifyBackend\Infrastructure dotnet ef database update --project backend\Musify.Infrastructure --startup-project backend\Musify.Infrastructure --context Database
dotnet ef database update --context Database
``` ```
La cadena de conexión para `dotnet ef` se lee de `DesignSettings.json` / user-secrets del proyecto Infrastructure. La cadena de conexión para `dotnet ef` se lee de `DesignSettings.json` / user-secrets del proyecto Infrastructure
(`up.ps1` la deja en user-secrets automáticamente).
## Stream-tickets (claves RS256) ## Stream-tickets (claves RS256)