Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions backend/src/Loopless.Api/Endpoints/ProfileEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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. <img> 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,
Expand Down
3 changes: 2 additions & 1 deletion backend/src/Loopless.Api/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"AccessKey": "",
"SecretKey": "",
"ServiceUrl": "",
"ForcePathStyle": false
"ForcePathStyle": false,
"PublicBaseUrl": "http://localhost:8081"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ public async Task<PublicUserDto> 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;
Expand Down
13 changes: 13 additions & 0 deletions backend/src/Loopless.Application/Interfaces/IFileStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,17 @@ Task<string> GetDownloadUrlAsync(
string fileName,
TimeSpan expiresIn,
CancellationToken cancellationToken = default);

/// <summary>Fetch an object's bytes + content type (for proxying through the API).</summary>
Task<StorageObject> GetObjectAsync(
string objectKey,
CancellationToken cancellationToken = default);

/// <summary>
/// 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).
/// </summary>
string GetPublicUrl(string objectKey);
}

public sealed record StorageObject(Stream Content, string ContentType);
30 changes: 30 additions & 0 deletions backend/src/Loopless.Infrastructure/Storage/FileStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,36 @@ public Task<string> GetDownloadUrlAsync(
return Task.FromResult(url);
}

public async Task<StorageObject> 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))
Expand Down
7 changes: 7 additions & 0 deletions backend/src/Loopless.Infrastructure/Storage/StorageOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,11 @@ public sealed class StorageOptions
public string? SecretKey { get; set; }
public string? ServiceUrl { get; set; }
public bool ForcePathStyle { get; set; }

/// <summary>
/// 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).
/// </summary>
public string? PublicBaseUrl { get; set; }
}
3 changes: 3 additions & 0 deletions devops/helm/loopless/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion frontend/client/components/profile/EnterpriseProfileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion frontend/client/components/profile/FreelancerProfileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Loading