fix: use presigned S3 URLs for avatar images instead of /uploads/ paths
All checks were successful
CI / ci (push) Successful in 1m11s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 14s

Avatar images were rendered via /uploads/ which doesn't exist since
the S3 migration. Now the server enriches profile responses with
avatarImageUrl (presigned S3 URL) and the frontend uses it directly.
Also fixed the public profile page at /users/:id.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 23:02:45 +02:00
parent 62916a8397
commit b647e23f91
5 changed files with 37 additions and 12 deletions

View File

@@ -11,7 +11,8 @@ export function ProfileSection() {
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
const [bio, setBio] = useState(""); const [bio, setBio] = useState("");
const [avatarUrl, setAvatarUrl] = useState<string | null>(null); const [avatarFilename, setAvatarFilename] = useState<string | null>(null);
const [avatarDisplayUrl, setAvatarDisplayUrl] = useState<string | null>(null);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [message, setMessage] = useState<{ const [message, setMessage] = useState<{
type: "success" | "error"; type: "success" | "error";
@@ -24,7 +25,8 @@ export function ProfileSection() {
if (profile && !dirty) { if (profile && !dirty) {
setDisplayName(profile.displayName ?? ""); setDisplayName(profile.displayName ?? "");
setBio(profile.bio ?? ""); setBio(profile.bio ?? "");
setAvatarUrl(profile.avatarUrl ?? null); setAvatarFilename(profile.avatarUrl ?? null);
setAvatarDisplayUrl(profile.avatarImageUrl ?? null);
} }
}, [profile, dirty]); }, [profile, dirty]);
@@ -34,7 +36,7 @@ export function ProfileSection() {
try { try {
await updateProfile.mutateAsync({ await updateProfile.mutateAsync({
displayName: displayName.trim() || undefined, displayName: displayName.trim() || undefined,
avatarUrl, avatarUrl: avatarFilename,
bio: bio.trim() || undefined, bio: bio.trim() || undefined,
}); });
setDirty(false); setDirty(false);
@@ -63,13 +65,16 @@ export function ProfileSection() {
return; return;
} }
const localPreview = URL.createObjectURL(file);
setAvatarDisplayUrl(localPreview);
setUploading(true); setUploading(true);
setMessage(null); setMessage(null);
try { try {
const result = await apiUpload<{ filename: string }>("/api/images", file); const result = await apiUpload<{ filename: string }>("/api/images", file);
setAvatarUrl(result.filename); setAvatarFilename(result.filename);
setDirty(true); setDirty(true);
} catch { } catch {
setAvatarDisplayUrl(null);
setMessage({ type: "error", text: "Avatar upload failed." }); setMessage({ type: "error", text: "Avatar upload failed." });
} finally { } finally {
setUploading(false); setUploading(false);
@@ -92,9 +97,9 @@ export function ProfileSection() {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="relative w-16 h-16 rounded-full overflow-hidden cursor-pointer group shrink-0" className="relative w-16 h-16 rounded-full overflow-hidden cursor-pointer group shrink-0"
> >
{avatarUrl ? ( {avatarDisplayUrl ? (
<img <img
src={`/uploads/${avatarUrl}`} src={avatarDisplayUrl}
alt="Avatar" alt="Avatar"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@@ -145,11 +150,12 @@ export function ProfileSection() {
> >
{uploading ? "Uploading..." : "Change avatar"} {uploading ? "Uploading..." : "Change avatar"}
</button> </button>
{avatarUrl && ( {avatarFilename && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setAvatarUrl(null); setAvatarFilename(null);
setAvatarDisplayUrl(null);
setDirty(true); setDirty(true);
}} }}
className="block text-xs text-red-500 hover:text-red-700 mt-0.5" className="block text-xs text-red-500 hover:text-red-700 mt-0.5"

View File

@@ -5,6 +5,7 @@ interface PublicProfile {
id: number; id: number;
displayName: string | null; displayName: string | null;
avatarUrl: string | null; avatarUrl: string | null;
avatarImageUrl: string | null;
bio: string | null; bio: string | null;
setups: { id: number; name: string; createdAt: string }[]; setups: { id: number; name: string; createdAt: string }[];
} }

View File

@@ -52,9 +52,9 @@ function PublicProfilePage() {
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-10"> <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
{/* Profile header */} {/* Profile header */}
<div className="flex items-center gap-5 mb-8"> <div className="flex items-center gap-5 mb-8">
{profile.avatarUrl ? ( {profile.avatarImageUrl ? (
<img <img
src={`/uploads/${profile.avatarUrl}`} src={profile.avatarImageUrl}
alt={displayName} alt={displayName}
className="w-20 h-20 rounded-full object-cover" className="w-20 h-20 rounded-full object-cover"
/> />

View File

@@ -14,6 +14,7 @@ import {
listApiKeys, listApiKeys,
} from "../services/auth.service.ts"; } from "../services/auth.service.ts";
import { updateProfile } from "../services/profile.service.ts"; import { updateProfile } from "../services/profile.service.ts";
import { getImageUrl } from "../services/storage.service.ts";
type Env = { Variables: { db?: any; userId?: number } }; type Env = { Variables: { db?: any; userId?: number } };
@@ -97,7 +98,12 @@ app.put(
const data = c.req.valid("json"); const data = c.req.valid("json");
const updated = await updateProfile(db, userId, data); const updated = await updateProfile(db, userId, data);
if (!updated) return c.json({ error: "User not found" }, 404); if (!updated) return c.json({ error: "User not found" }, 404);
return c.json(updated); return c.json({
...updated,
avatarImageUrl: updated.avatarUrl
? await getImageUrl(updated.avatarUrl)
: null,
});
}, },
); );

View File

@@ -1,9 +1,21 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { parseId } from "../lib/params.ts"; import { parseId } from "../lib/params.ts";
import { getPublicProfile } from "../services/profile.service.ts"; import { getPublicProfile } from "../services/profile.service.ts";
import { getImageUrl } from "../services/storage.service.ts";
type Env = { Variables: { db?: any; userId?: number } }; type Env = { Variables: { db?: any; userId?: number } };
async function enrichAvatarUrl<T extends { avatarUrl: string | null }>(
record: T,
): Promise<T & { avatarImageUrl: string | null }> {
return {
...record,
avatarImageUrl: record.avatarUrl
? await getImageUrl(record.avatarUrl)
: null,
};
}
const app = new Hono<Env>(); const app = new Hono<Env>();
// GET /:id/profile — Public profile (no auth required) // GET /:id/profile — Public profile (no auth required)
@@ -15,7 +27,7 @@ app.get("/:id/profile", async (c) => {
const profile = await getPublicProfile(db, id); const profile = await getPublicProfile(db, id);
if (!profile) return c.json({ error: "User not found" }, 404); if (!profile) return c.json({ error: "User not found" }, 404);
return c.json(profile); return c.json(await enrichAvatarUrl(profile));
}); });
export { app as profileRoutes }; export { app as profileRoutes };