From 7228f0b82d6c1c22e36ec85f4c34856ab907d0a6 Mon Sep 17 00:00:00 2001 From: Enes1998 <104234112+Enes1998@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:21:12 +0200 Subject: [PATCH] fix(avatar): serve avatars via API proxy so uploaded images display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UploadAvatarCommandHandler stored the raw S3 object key in user.AvatarUrl, and MinIO is internal-only (no public ingress, no extra subdomain allowed) — so neither the key nor an internal presigned URL is browser-reachable. Result: avatars never rendered. - Add an anonymous GET /api/v1/profile/avatar?key=... endpoint that streams the object from MinIO through the API (the api host is public). - IFileStorageService: GetObjectAsync (buffered) + GetPublicUrl(key); StorageOptions gains PublicBaseUrl (values.yaml=https://api.project-01.gjirafa.dev, dev=:8081). - Upload now stores the absolute proxy URL, so every render site works unchanged. - Profile cards update authStore on upload so the top-right avatar refreshes live. Note: pre-existing avatars stored as raw keys stay broken until re-uploaded. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Endpoints/ProfileEndpoints.cs | 25 ++++++++++++++++ .../Loopless.Api/appsettings.Development.json | 3 +- .../UploadAvatarCommandHandler.cs | 6 ++-- .../Interfaces/IFileStorageService.cs | 13 ++++++++ .../Storage/FileStorageService.cs | 30 +++++++++++++++++++ .../Storage/StorageOptions.cs | 7 +++++ devops/helm/loopless/values.yaml | 3 ++ .../profile/EnterpriseProfileCard.tsx | 6 +++- .../profile/FreelancerProfileCard.tsx | 6 +++- 9 files changed, 94 insertions(+), 5 deletions(-) diff --git a/backend/src/Loopless.Api/Endpoints/ProfileEndpoints.cs b/backend/src/Loopless.Api/Endpoints/ProfileEndpoints.cs index a0c36c1..635cbd8 100644 --- a/backend/src/Loopless.Api/Endpoints/ProfileEndpoints.cs +++ b/backend/src/Loopless.Api/Endpoints/ProfileEndpoints.cs @@ -1,4 +1,5 @@ using Loopless.Application.DTOs; +using Loopless.Application.Interfaces; using Loopless.Application.Features.Profile.UpdateEnterpriseProfile; using Loopless.Application.Features.Profile.UpdateFreelancerProfile; using Loopless.Application.Features.Profile.UploadAvatar; @@ -14,6 +15,30 @@ public static IEndpointRouteBuilder MapProfileEndpoints(this IEndpointRouteBuild { var group = app.MapGroup("/api/v1/profile").WithTags("Profile"); + // Anonymous avatar proxy: MinIO is internal-only, so avatars are served through + // the API. tags can't send auth headers, hence no RequireAuthorization. + group.MapGet("/avatar", async ( + [FromQuery] string key, + IFileStorageService storage, + CancellationToken ct) => + { + if (string.IsNullOrWhiteSpace(key)) + return Results.BadRequest(); + try + { + var obj = await storage.GetObjectAsync(key, ct); + return Results.Stream(obj.Content, obj.ContentType); + } + catch + { + return Results.NotFound(); + } + }) + .WithName("GetAvatar") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + group.MapPut("/freelancer", async ( [FromBody] UpdateFreelancerProfileCommand command, ISender sender, diff --git a/backend/src/Loopless.Api/appsettings.Development.json b/backend/src/Loopless.Api/appsettings.Development.json index b00a0fc..7151ff3 100644 --- a/backend/src/Loopless.Api/appsettings.Development.json +++ b/backend/src/Loopless.Api/appsettings.Development.json @@ -46,6 +46,7 @@ "AccessKey": "", "SecretKey": "", "ServiceUrl": "", - "ForcePathStyle": false + "ForcePathStyle": false, + "PublicBaseUrl": "http://localhost:8081" } } diff --git a/backend/src/Loopless.Application/Features/Profile/UploadAvatar/UploadAvatarCommandHandler.cs b/backend/src/Loopless.Application/Features/Profile/UploadAvatar/UploadAvatarCommandHandler.cs index 713cd36..de860b4 100644 --- a/backend/src/Loopless.Application/Features/Profile/UploadAvatar/UploadAvatarCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Profile/UploadAvatar/UploadAvatarCommandHandler.cs @@ -26,14 +26,16 @@ public async Task Handle(UploadAvatarCommand request, Cancellatio var safeFileName = Path.GetFileName(request.FileName); var objectKey = $"avatars/{user.Id}/{safeFileName}"; - var url = await storage.UploadAsync( + await storage.UploadAsync( request.Content, objectKey, request.ContentType, request.ContentLength, cancellationToken); - user.AvatarUrl = url; + // Store a browser-reachable URL (proxied via the API), not the raw S3 key — + // MinIO is internal-only so the key/presigned-internal URL won't load in a browser. + user.AvatarUrl = storage.GetPublicUrl(objectKey); await db.SaveChangesAsync(cancellationToken); var fp = user.FreelancerProfile; diff --git a/backend/src/Loopless.Application/Interfaces/IFileStorageService.cs b/backend/src/Loopless.Application/Interfaces/IFileStorageService.cs index 1f95479..c6c3e58 100644 --- a/backend/src/Loopless.Application/Interfaces/IFileStorageService.cs +++ b/backend/src/Loopless.Application/Interfaces/IFileStorageService.cs @@ -14,4 +14,17 @@ Task GetDownloadUrlAsync( string fileName, TimeSpan expiresIn, CancellationToken cancellationToken = default); + + /// Fetch an object's bytes + content type (for proxying through the API). + Task GetObjectAsync( + string objectKey, + CancellationToken cancellationToken = default); + + /// + /// Build a stable, browser-reachable URL that serves the object via the API avatar + /// proxy (MinIO is internal-only, so raw keys/presigned-internal URLs don't load). + /// + string GetPublicUrl(string objectKey); } + +public sealed record StorageObject(Stream Content, string ContentType); diff --git a/backend/src/Loopless.Infrastructure/Storage/FileStorageService.cs b/backend/src/Loopless.Infrastructure/Storage/FileStorageService.cs index e348978..66ee19d 100644 --- a/backend/src/Loopless.Infrastructure/Storage/FileStorageService.cs +++ b/backend/src/Loopless.Infrastructure/Storage/FileStorageService.cs @@ -69,6 +69,36 @@ public Task GetDownloadUrlAsync( return Task.FromResult(url); } + public async Task GetObjectAsync( + string objectKey, + CancellationToken cancellationToken = default) + { + EnsureConfigured(); + + using var response = await _client.GetObjectAsync(_options.BucketName, objectKey, cancellationToken); + + // Buffer into memory (avatars/files served here are small) so the object stream + // can be safely consumed after the S3 response is disposed. + var buffer = new MemoryStream(); + await response.ResponseStream.CopyToAsync(buffer, cancellationToken); + buffer.Position = 0; + + var contentType = string.IsNullOrWhiteSpace(response.Headers.ContentType) + ? "application/octet-stream" + : response.Headers.ContentType; + + return new StorageObject(buffer, contentType); + } + + public string GetPublicUrl(string objectKey) + { + var encodedKey = Uri.EscapeDataString(objectKey); + var baseUrl = _options.PublicBaseUrl?.TrimEnd('/'); + return string.IsNullOrWhiteSpace(baseUrl) + ? $"/api/v1/profile/avatar?key={encodedKey}" + : $"{baseUrl}/api/v1/profile/avatar?key={encodedKey}"; + } + private void EnsureConfigured() { if (string.IsNullOrWhiteSpace(_options.BucketName)) diff --git a/backend/src/Loopless.Infrastructure/Storage/StorageOptions.cs b/backend/src/Loopless.Infrastructure/Storage/StorageOptions.cs index 835037e..e4e20d4 100644 --- a/backend/src/Loopless.Infrastructure/Storage/StorageOptions.cs +++ b/backend/src/Loopless.Infrastructure/Storage/StorageOptions.cs @@ -10,4 +10,11 @@ public sealed class StorageOptions public string? SecretKey { get; set; } public string? ServiceUrl { get; set; } public bool ForcePathStyle { get; set; } + + /// + /// Public base URL of the API (e.g. https://api.project-01.gjirafa.dev). Used to build + /// browser-reachable avatar URLs that proxy through the backend, since MinIO is + /// internal-only. Empty → a relative URL is used (dev/same-origin). + /// + public string? PublicBaseUrl { get; set; } } diff --git a/devops/helm/loopless/values.yaml b/devops/helm/loopless/values.yaml index 635da10..de2722b 100644 --- a/devops/helm/loopless/values.yaml +++ b/devops/helm/loopless/values.yaml @@ -118,6 +118,9 @@ config: Storage__BucketName: "loopless-uploads" Storage__Region: "eu-central-1" Storage__ForcePathStyle: "true" + # Public API base for browser-reachable avatar URLs (MinIO is internal-only; avatars + # are proxied via GET /api/v1/profile/avatar). + Storage__PublicBaseUrl: "https://api.project-01.gjirafa.dev" # CORS: allow the prod web + admin origins (backend reads Cors:AllowedOrigins). Cors__AllowedOrigins__0: "https://web.project-01.gjirafa.dev" Cors__AllowedOrigins__1: "https://admin.project-01.gjirafa.dev" diff --git a/frontend/client/components/profile/EnterpriseProfileCard.tsx b/frontend/client/components/profile/EnterpriseProfileCard.tsx index f67625e..6b25404 100644 --- a/frontend/client/components/profile/EnterpriseProfileCard.tsx +++ b/frontend/client/components/profile/EnterpriseProfileCard.tsx @@ -21,6 +21,7 @@ import { X, } from "lucide-react"; import type { PublicUser } from "@/types/auth"; +import { useAuthStore } from "@/stores/authStore"; import { StartConversationButton } from "@/components/messaging/StartConversationButton"; import { updateEnterpriseProfile, @@ -99,7 +100,10 @@ export function EnterpriseProfileCard({ profile, isSelf }: Props) { const avatarMutation = useMutation({ mutationFn: (file: File) => uploadAvatar(file), - onSuccess: () => { + onSuccess: (updated) => { + // Reflect the new avatar in the top-right menu immediately (authStore.user). + const u = useAuthStore.getState().user; + if (u) useAuthStore.getState().setUser({ ...u, avatarUrl: updated.avatarUrl }); queryClient.invalidateQueries({ queryKey: ["users", "me"] }); queryClient.invalidateQueries({ queryKey: ["users", profile.id] }); setAvatarPreview(null); diff --git a/frontend/client/components/profile/FreelancerProfileCard.tsx b/frontend/client/components/profile/FreelancerProfileCard.tsx index 257bac5..456e59b 100644 --- a/frontend/client/components/profile/FreelancerProfileCard.tsx +++ b/frontend/client/components/profile/FreelancerProfileCard.tsx @@ -21,6 +21,7 @@ import { X, } from "lucide-react"; import type { PublicUser } from "@/types/auth"; +import { useAuthStore } from "@/stores/authStore"; import { StartConversationButton } from "@/components/messaging/StartConversationButton"; import { InviteToProjectButton } from "@/components/profile/InviteToProjectButton"; import { SkillsEditor } from "@/components/profile/SkillsEditor"; @@ -103,7 +104,10 @@ export function FreelancerProfileCard({ profile, isSelf }: Props) { const avatarMutation = useMutation({ mutationFn: (file: File) => uploadAvatar(file), - onSuccess: () => { + onSuccess: (updated) => { + // Reflect the new avatar in the top-right menu immediately (authStore.user). + const u = useAuthStore.getState().user; + if (u) useAuthStore.getState().setUser({ ...u, avatarUrl: updated.avatarUrl }); queryClient.invalidateQueries({ queryKey: ["users", "me"] }); queryClient.invalidateQueries({ queryKey: ["users", profile.id] }); setAvatarPreview(null);