fix: add upload/delete file endpoints and wire avatar update through profile

- Add POST /upload and DELETE /assets/:id routes to backend (session auth via session_token cookie)
- Add avatar arg to updateProfile GraphQL mutation and resolver
- Fix frontend to pass avatarId correctly on save, preserve existing avatar when unchanged
- Ignore 404 on file delete (already gone is fine)
- Remove broken folder lookup (getFolders is a stub, backend has no folder concept)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 18:22:22 +01:00
parent a050e886cb
commit 56b57486dc
4 changed files with 65 additions and 6 deletions

View File

@@ -45,6 +45,7 @@ builder.mutationField("updateProfile", (t) =>
artistName: t.arg.string(), artistName: t.arg.string(),
description: t.arg.string(), description: t.arg.string(),
tags: t.arg.stringList(), tags: t.arg.stringList(),
avatar: t.arg.string(),
}, },
resolve: async (_root, args, ctx) => { resolve: async (_root, args, ctx) => {
if (!ctx.currentUser) throw new GraphQLError("Unauthorized"); if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
@@ -58,6 +59,7 @@ builder.mutationField("updateProfile", (t) =>
if (args.description !== undefined && args.description !== null) if (args.description !== undefined && args.description !== null)
updates.description = args.description; updates.description = args.description;
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags; if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
if (args.avatar !== undefined) updates.avatar = args.avatar;
await ctx.db await ctx.db
.update(users) .update(users)

View File

@@ -7,7 +7,8 @@ import { createYoga } from "graphql-yoga";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { files } from "./db/schema/index"; import { files } from "./db/schema/index";
import path from "path"; import path from "path";
import { existsSync } from "fs"; import { existsSync, mkdirSync } from "fs";
import { writeFile, rm } from "fs/promises";
import sharp from "sharp"; import sharp from "sharp";
import { schema } from "./graphql/index"; import { schema } from "./graphql/index";
import { buildContext } from "./graphql/context"; import { buildContext } from "./graphql/context";
@@ -120,6 +121,54 @@ async function main() {
return reply.sendFile(path.join(id, filename)); return reply.sendFile(path.join(id, filename));
}); });
// Upload a file: POST /upload (multipart, requires session)
fastify.post("/upload", async (request, reply) => {
const token = request.cookies["session_token"];
if (!token) return reply.status(401).send({ error: "Unauthorized" });
const sessionData = await redis.get(`session:${token}`);
if (!sessionData) return reply.status(401).send({ error: "Unauthorized" });
const { id: userId } = JSON.parse(sessionData);
const data = await request.file();
if (!data) return reply.status(400).send({ error: "No file provided" });
const id = crypto.randomUUID();
const filename = data.filename;
const mime_type = data.mimetype;
const dir = path.join(UPLOAD_DIR, id);
mkdirSync(dir, { recursive: true });
const buffer = await data.toBuffer();
await writeFile(path.join(dir, filename), buffer);
const [file] = await db
.insert(files)
.values({ id, filename, mime_type, filesize: buffer.byteLength, uploaded_by: userId })
.returning();
return reply.status(201).send(file);
});
// Delete a file: DELETE /assets/:id (requires session)
fastify.delete("/assets/:id", async (request, reply) => {
const token = request.cookies["session_token"];
if (!token) return reply.status(401).send({ error: "Unauthorized" });
const sessionData = await redis.get(`session:${token}`);
if (!sessionData) return reply.status(401).send({ error: "Unauthorized" });
const { id } = request.params as { id: string };
const result = await db.select().from(files).where(eq(files.id, id)).limit(1);
if (!result[0]) return reply.status(404).send({ error: "File not found" });
await db.delete(files).where(eq(files.id, id));
const dir = path.join(UPLOAD_DIR, id);
await rm(dir, { recursive: true, force: true });
return reply.status(200).send({ ok: true });
});
fastify.get("/health", async (_request, reply) => { fastify.get("/health", async (_request, reply) => {
return reply.send({ status: "ok", timestamp: new Date().toISOString() }); return reply.send({ status: "ok", timestamp: new Date().toISOString() });
}); });

View File

@@ -573,6 +573,7 @@ const UPDATE_PROFILE_MUTATION = gql`
$artistName: String $artistName: String
$description: String $description: String
$tags: [String!] $tags: [String!]
$avatar: String
) { ) {
updateProfile( updateProfile(
firstName: $firstName firstName: $firstName
@@ -580,6 +581,7 @@ const UPDATE_PROFILE_MUTATION = gql`
artistName: $artistName artistName: $artistName
description: $description description: $description
tags: $tags tags: $tags
avatar: $avatar
) { ) {
id id
email email
@@ -609,6 +611,7 @@ export async function updateProfile(user: Partial<User> & { password?: string })
artistName: user.artist_name, artistName: user.artist_name,
description: user.description, description: user.description,
tags: user.tags, tags: user.tags,
avatar: user.avatar,
}, },
); );
return data.updateProfile; return data.updateProfile;
@@ -652,7 +655,8 @@ export async function removeFile(id: string) {
method: "DELETE", method: "DELETE",
credentials: "include", credentials: "include",
}); });
if (!response.ok) throw new Error(`Failed to delete file: ${response.statusText}`); if (!response.ok && response.status !== 404)
throw new Error(`Failed to delete file: ${response.statusText}`);
}, },
{ fileId: id }, { fileId: id },
); );

View File

@@ -62,16 +62,20 @@
isProfileError = false; isProfileError = false;
profileError = ""; profileError = "";
let avatarId = undefined; let avatarId: string | null | undefined = undefined;
if (!avatar?.id && data.authStatus.user!.avatar) { if (!avatar?.id && data.authStatus.user!.avatar) {
// User removed their avatar
await removeFile(data.authStatus.user!.avatar); await removeFile(data.authStatus.user!.avatar);
avatarId = null;
} else if (avatar?.id) {
// Keep existing avatar
avatarId = avatar.id;
} }
if (avatar?.file) { if (avatar?.file) {
const formData = new FormData(); const formData = new FormData();
formData.append("folder", data.folders.find((f) => f.name === "avatars")!.id); formData.append("file", avatar.file);
formData.append("file", avatar.file!);
const result = await uploadFile(formData); const result = await uploadFile(formData);
avatarId = result.id; avatarId = result.id;
} }
@@ -82,7 +86,7 @@
artist_name: artistName, artist_name: artistName,
description, description,
tags, tags,
avatar: avatarId, avatar: avatarId ?? undefined,
}); });
toast.success($_("me.settings.toast_update")); toast.success($_("me.settings.toast_update"));
invalidateAll(); invalidateAll();