diff --git a/packages/backend/src/graphql/resolvers/users.ts b/packages/backend/src/graphql/resolvers/users.ts index d2e1994..a1e2c9e 100644 --- a/packages/backend/src/graphql/resolvers/users.ts +++ b/packages/backend/src/graphql/resolvers/users.ts @@ -45,6 +45,7 @@ builder.mutationField("updateProfile", (t) => artistName: t.arg.string(), description: t.arg.string(), tags: t.arg.stringList(), + avatar: t.arg.string(), }, resolve: async (_root, args, ctx) => { if (!ctx.currentUser) throw new GraphQLError("Unauthorized"); @@ -58,6 +59,7 @@ builder.mutationField("updateProfile", (t) => if (args.description !== undefined && args.description !== null) updates.description = args.description; if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags; + if (args.avatar !== undefined) updates.avatar = args.avatar; await ctx.db .update(users) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index f75cb1c..f2cab34 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -7,7 +7,8 @@ import { createYoga } from "graphql-yoga"; import { eq } from "drizzle-orm"; import { files } from "./db/schema/index"; import path from "path"; -import { existsSync } from "fs"; +import { existsSync, mkdirSync } from "fs"; +import { writeFile, rm } from "fs/promises"; import sharp from "sharp"; import { schema } from "./graphql/index"; import { buildContext } from "./graphql/context"; @@ -120,6 +121,54 @@ async function main() { 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) => { return reply.send({ status: "ok", timestamp: new Date().toISOString() }); }); diff --git a/packages/frontend/src/lib/services.ts b/packages/frontend/src/lib/services.ts index 1eebf6d..4a9a980 100644 --- a/packages/frontend/src/lib/services.ts +++ b/packages/frontend/src/lib/services.ts @@ -573,6 +573,7 @@ const UPDATE_PROFILE_MUTATION = gql` $artistName: String $description: String $tags: [String!] + $avatar: String ) { updateProfile( firstName: $firstName @@ -580,6 +581,7 @@ const UPDATE_PROFILE_MUTATION = gql` artistName: $artistName description: $description tags: $tags + avatar: $avatar ) { id email @@ -609,6 +611,7 @@ export async function updateProfile(user: Partial & { password?: string }) artistName: user.artist_name, description: user.description, tags: user.tags, + avatar: user.avatar, }, ); return data.updateProfile; @@ -652,7 +655,8 @@ export async function removeFile(id: string) { method: "DELETE", 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 }, ); diff --git a/packages/frontend/src/routes/me/+page.svelte b/packages/frontend/src/routes/me/+page.svelte index 2b16917..193195f 100644 --- a/packages/frontend/src/routes/me/+page.svelte +++ b/packages/frontend/src/routes/me/+page.svelte @@ -62,16 +62,20 @@ isProfileError = false; profileError = ""; - let avatarId = undefined; + let avatarId: string | null | undefined = undefined; if (!avatar?.id && data.authStatus.user!.avatar) { + // User removed their avatar await removeFile(data.authStatus.user!.avatar); + avatarId = null; + } else if (avatar?.id) { + // Keep existing avatar + avatarId = avatar.id; } if (avatar?.file) { 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); avatarId = result.id; } @@ -82,7 +86,7 @@ artist_name: artistName, description, tags, - avatar: avatarId, + avatar: avatarId ?? undefined, }); toast.success($_("me.settings.toast_update")); invalidateAll();