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:
@@ -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)
|
||||||
|
|||||||
@@ -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() });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user