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);