Docker support, backend SPA mode
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "catalog:",
|
||||
"@elysiajs/static": "catalog:",
|
||||
"@elysiajs/swagger": "catalog:",
|
||||
"common": "workspace:^",
|
||||
"elysia": "catalog:",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import cors from "@elysiajs/cors";
|
||||
import { staticPlugin } from "@elysiajs/static";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import { AttachmentId, PieceId, RequestId, SessionId, Sha256, UserId } from "common";
|
||||
import * as Function from "common/Function";
|
||||
@@ -14,9 +15,9 @@ const app = new Elysia()
|
||||
|
||||
.use(swagger())
|
||||
|
||||
.use(cors({ origin: "localhost:5173" }))
|
||||
.use(cors({ origin: process.env.NODE_ENV === "production" ? false : "localhost:5173" }))
|
||||
|
||||
.decorate("db", await initDatabase())
|
||||
.decorate("db", await initDatabase(process.env.DB_PATH))
|
||||
|
||||
.resolve(async ({ db, cookie }) => {
|
||||
await db
|
||||
@@ -31,7 +32,7 @@ const app = new Elysia()
|
||||
|
||||
const res = await db
|
||||
.updateTable("Session")
|
||||
.set({ expiresAt: sql`datetime('now', '+7 days') `})
|
||||
.set({ expiresAt: sql`datetime('now', '+7 days') ` })
|
||||
.where("sessionId", "=", SessionId(sessionId))
|
||||
.returning(["userId"])
|
||||
.executeTakeFirst();
|
||||
@@ -71,396 +72,409 @@ const app = new Elysia()
|
||||
console.log(`${timestamp} ${method} ${request.url} ${ip}`);
|
||||
})
|
||||
|
||||
// --- MARK: AUTHENTICATION ------------------------------------------------
|
||||
.use(staticPlugin({
|
||||
assets: "packages/frontend/build/assets",
|
||||
prefix: "/assets",
|
||||
alwaysStatic: true,
|
||||
indexHTML: false,
|
||||
}))
|
||||
|
||||
.get("/me", async ({ user }) => {
|
||||
.group("/api", (app) => app
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
// --- MARK: AUTHENTICATION --------------------------------------------
|
||||
|
||||
return {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
admin: user.admin !== 0,
|
||||
};
|
||||
})
|
||||
.get("/me", async ({ user }) => {
|
||||
|
||||
.post("/login", async ({ db, body: { username, password }, cookie }) => {
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.selectAll()
|
||||
.where("username", "=", username)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (user === undefined) {
|
||||
return error("Unauthorized", "Invalid username or password");
|
||||
}
|
||||
|
||||
const valid = await Bun.password.verify(password, user.password);
|
||||
if (!valid) {
|
||||
return error("Unauthorized", "Invalid username or password");
|
||||
}
|
||||
|
||||
const sessionId = generateSessionId();
|
||||
await db
|
||||
.insertInto("Session")
|
||||
.values({ sessionId, userId: user.userId, expiresAt: sql`datetime('now', '+7 days')` })
|
||||
.execute();
|
||||
|
||||
const expiresAt = new Date().getTime() + 604800000;
|
||||
cookie.sessionId.set({
|
||||
value: sessionId,
|
||||
expires: new Date(expiresAt),
|
||||
httpOnly: true,
|
||||
sameSite: "none",
|
||||
secure: true,
|
||||
});
|
||||
|
||||
return {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
admin: user.admin !== 0,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
username: t.String({ minLength: 1 }),
|
||||
password: t.String({ minLength: 1 }),
|
||||
}),
|
||||
})
|
||||
|
||||
.post("/logout", async ({ db, cookie, set }) => {
|
||||
|
||||
set.status = "No Content";
|
||||
|
||||
const sessionCookie = cookie.sessionId;
|
||||
sessionCookie.remove();
|
||||
|
||||
const sessionId = sessionCookie.value;
|
||||
if (sessionId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.deleteFrom("Session")
|
||||
.where("sessionId", "=", SessionId(sessionId))
|
||||
.execute();
|
||||
})
|
||||
|
||||
// --- MARK: USER MANAGEMENT -----------------------------------------------
|
||||
|
||||
.get("/user/:userId", async ({ db, params: { userId }, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.selectFrom("User")
|
||||
.select(["userId", "username", "admin"])
|
||||
.where("userId", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (res === undefined) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
return {
|
||||
userId: res.userId,
|
||||
username: res.username,
|
||||
admin: res.admin !== 0,
|
||||
};
|
||||
}, {
|
||||
params: t.Object({
|
||||
userId: tbranded<UserId>(),
|
||||
}),
|
||||
})
|
||||
|
||||
// --- MARK: PIECE CRUD ----------------------------------------------------
|
||||
|
||||
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const pieceId = PieceId(Bun.randomUUIDv7());
|
||||
|
||||
const res = await db
|
||||
.insertInto("Piece")
|
||||
.values({ pieceId, name, composer, lyricist, arranger, createdBy: user.userId, createdAt: sql`datetime()` })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return res;
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1 }),
|
||||
composer: t.Nullable(t.String({ minLength: 1 })),
|
||||
lyricist: t.Nullable(t.String({ minLength: 1 })),
|
||||
arranger: t.Nullable(t.String({ minLength: 1 })),
|
||||
}),
|
||||
})
|
||||
|
||||
.get("/piece", async ({ db, query, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
let q = db
|
||||
.selectFrom("Piece")
|
||||
.select("pieceId")
|
||||
.orderBy(["name", "composer", "arranger"])
|
||||
.offset(query.offset ?? 0)
|
||||
.limit(query.limit ?? 100);
|
||||
|
||||
if (query.name !== undefined) {
|
||||
q = q.where("name", "like", "%" + query.name + "%");
|
||||
}
|
||||
|
||||
if (query.author !== undefined) {
|
||||
q = q.where((eb) => eb.or([
|
||||
eb("composer", "like", "%" + query.author + "%"),
|
||||
eb("arranger", "like", "%" + query.author + "%"),
|
||||
eb("lyricist", "like", "%" + query.author + "%"),
|
||||
]))
|
||||
}
|
||||
|
||||
const res = await q.execute();
|
||||
return res.map(({ pieceId }) => pieceId);
|
||||
}, {
|
||||
query: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
author: t.Optional(t.String()),
|
||||
offset: t.Optional(t.Integer({ minimum: 0 })),
|
||||
limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })),
|
||||
}),
|
||||
})
|
||||
|
||||
.get("/piece/:pieceId", async ({ db, query, params: { pieceId }, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const piece = await db
|
||||
.selectFrom("Piece")
|
||||
.selectAll()
|
||||
.where("pieceId", "=", pieceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (piece === undefined) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
const attachments = await db
|
||||
.selectFrom("Attachment")
|
||||
.selectAll()
|
||||
.where("pieceId", "=", pieceId)
|
||||
.execute();
|
||||
|
||||
return {
|
||||
...piece,
|
||||
attachments: attachments.map(({ sha256, ...rest }) => ({
|
||||
sha256: Buffer.from(sha256).toString("hex"),
|
||||
...rest,
|
||||
})),
|
||||
}
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
}),
|
||||
})
|
||||
|
||||
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.updateTable("Piece")
|
||||
.set({ name, composer, lyricist, arranger, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
|
||||
.where("pieceId", "=", pieceId)
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
return res[0];
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1 }),
|
||||
composer: t.Nullable(t.String({ minLength: 1 })),
|
||||
lyricist: t.Nullable(t.String({ minLength: 1 })),
|
||||
arranger: t.Nullable(t.String({ minLength: 1 })),
|
||||
}),
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
return {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
admin: user.admin !== 0,
|
||||
};
|
||||
})
|
||||
})
|
||||
|
||||
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => {
|
||||
.post("/login", async ({ db, body: { username, password }, cookie }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.selectAll()
|
||||
.where("username", "=", username)
|
||||
.executeTakeFirst();
|
||||
|
||||
const res = await db
|
||||
.deleteFrom("Piece")
|
||||
.where("pieceId", "=", pieceId)
|
||||
.returningAll()
|
||||
.execute();
|
||||
if (user === undefined) {
|
||||
return error("Unauthorized", "Invalid username or password");
|
||||
}
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found");
|
||||
}
|
||||
const valid = await Bun.password.verify(password, user.password);
|
||||
if (!valid) {
|
||||
return error("Unauthorized", "Invalid username or password");
|
||||
}
|
||||
|
||||
set.status = "No Content";
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
}),
|
||||
})
|
||||
const sessionId = generateSessionId();
|
||||
await db
|
||||
.insertInto("Session")
|
||||
.values({ sessionId, userId: user.userId, expiresAt: sql`datetime('now', '+7 days')` })
|
||||
.execute();
|
||||
|
||||
// --- MARK: ATTACHMENT CRUD -----------------------------------------------
|
||||
const expiresAt = new Date().getTime() + 604800000;
|
||||
cookie.sessionId.set({
|
||||
value: sessionId,
|
||||
expires: new Date(expiresAt),
|
||||
httpOnly: true,
|
||||
sameSite: "none",
|
||||
secure: true,
|
||||
});
|
||||
|
||||
.post("piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, user }) => {
|
||||
return {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
admin: user.admin !== 0,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
username: t.String({ minLength: 1 }),
|
||||
password: t.String({ minLength: 1 }),
|
||||
}),
|
||||
})
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
.post("/logout", async ({ db, cookie, set }) => {
|
||||
|
||||
const attachmentId = AttachmentId(Bun.randomUUIDv7());
|
||||
const dataArray = new Uint8Array(await data.arrayBuffer());
|
||||
set.status = "No Content";
|
||||
|
||||
const sha256 = Sha256(new Uint8Array(Bun.SHA256.byteLength));
|
||||
Bun.SHA256.hash(dataArray, sha256);
|
||||
const sessionCookie = cookie.sessionId;
|
||||
sessionCookie.remove();
|
||||
|
||||
await db
|
||||
.insertInto("File")
|
||||
.values({ sha256, data: dataArray })
|
||||
.onConflict((cb) => cb.column("sha256").doNothing())
|
||||
.execute();
|
||||
const sessionId = sessionCookie.value;
|
||||
if (sessionId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.insertInto("Attachment")
|
||||
.values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: user.userId, createdAt: sql`datetime()` })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await db
|
||||
.deleteFrom("Session")
|
||||
.where("sessionId", "=", SessionId(sessionId))
|
||||
.execute();
|
||||
})
|
||||
|
||||
return {
|
||||
...res,
|
||||
sha256: Buffer.from(res.sha256).toString("hex"),
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
filename: t.String({ minLength: 1 }),
|
||||
mediaType: t.String({ minLength: 1 }),
|
||||
data: t.File(),
|
||||
}),
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
}),
|
||||
})
|
||||
// --- MARK: USER MANAGEMENT -------------------------------------------
|
||||
|
||||
/* NOTE The piece ID is reduntant, because attachment IDs are unique for the
|
||||
* entire DB, not just per piece. However, we consider a piece to be the
|
||||
* sole owner of an attachment, i.e. attachments are not shared (attachments
|
||||
* are deduplicated on file storage level by their SHA-256 hash). Thus, we
|
||||
* reflect the ownership in the URLs.
|
||||
*/
|
||||
.get("/user/:userId", async ({ db, params: { userId }, user }) => {
|
||||
|
||||
.get("piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, user, set }) => {
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
const res = await db
|
||||
.selectFrom("User")
|
||||
.select(["userId", "username", "admin"])
|
||||
.where("userId", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
const res = await db
|
||||
.selectFrom("File")
|
||||
.innerJoin("Attachment", "File.sha256", "Attachment.sha256")
|
||||
.select(["Attachment.filename", "Attachment.mediaType", "File.data"])
|
||||
.where((eb) => eb.and([
|
||||
eb("Attachment.pieceId", "=", pieceId),
|
||||
eb("Attachment.attachmentId", "=", attachmentId),
|
||||
]))
|
||||
.executeTakeFirst();
|
||||
if (res === undefined) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
if (res === undefined) {
|
||||
return error("Not Found");
|
||||
}
|
||||
return {
|
||||
userId: res.userId,
|
||||
username: res.username,
|
||||
admin: res.admin !== 0,
|
||||
};
|
||||
}, {
|
||||
params: t.Object({
|
||||
userId: tbranded<UserId>(),
|
||||
}),
|
||||
})
|
||||
|
||||
set.headers["content-disposition"] = `attachment; filename="${res.filename}"`;
|
||||
return Bun.file(res.data, { type: res.mediaType });
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
attachmentId: tbranded<AttachmentId>(),
|
||||
}),
|
||||
})
|
||||
// --- MARK: PIECE CRUD ------------------------------------------------
|
||||
|
||||
.put("piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, user }) => {
|
||||
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.updateTable("Attachment")
|
||||
.set({ filename, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
|
||||
.where((eb) => eb.and([
|
||||
eb("pieceId", "=", pieceId),
|
||||
eb("attachmentId", "=", attachmentId),
|
||||
]))
|
||||
.returningAll()
|
||||
.execute();
|
||||
const pieceId = PieceId(Bun.randomUUIDv7());
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found");
|
||||
}
|
||||
const res = await db
|
||||
.insertInto("Piece")
|
||||
.values({ pieceId, name, composer, lyricist, arranger, createdBy: user.userId, createdAt: sql`datetime()` })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return res[0];
|
||||
}, {
|
||||
body: t.Object({
|
||||
filename: t.String({ minLength: 1 }),
|
||||
}),
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
attachmentId: tbranded<AttachmentId>(),
|
||||
}),
|
||||
})
|
||||
return res;
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1 }),
|
||||
composer: t.Nullable(t.String({ minLength: 1 })),
|
||||
lyricist: t.Nullable(t.String({ minLength: 1 })),
|
||||
arranger: t.Nullable(t.String({ minLength: 1 })),
|
||||
}),
|
||||
})
|
||||
|
||||
.delete("piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, user }) => {
|
||||
.get("/piece", async ({ db, query, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.deleteFrom("Attachment")
|
||||
.where((eb) => eb.and([
|
||||
eb("pieceId", "=", pieceId),
|
||||
eb("attachmentId", "=", attachmentId),
|
||||
]))
|
||||
.returningAll()
|
||||
.execute();
|
||||
let q = db
|
||||
.selectFrom("Piece")
|
||||
.select("pieceId")
|
||||
.orderBy(["name", "composer", "arranger"])
|
||||
.offset(query.offset ?? 0)
|
||||
.limit(query.limit ?? 100);
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found");
|
||||
}
|
||||
if (query.name !== undefined) {
|
||||
q = q.where("name", "like", "%" + query.name + "%");
|
||||
}
|
||||
|
||||
set.status = "No Content";
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
attachmentId: tbranded<AttachmentId>(),
|
||||
}),
|
||||
})
|
||||
if (query.author !== undefined) {
|
||||
q = q.where((eb) => eb.or([
|
||||
eb("composer", "like", "%" + query.author + "%"),
|
||||
eb("arranger", "like", "%" + query.author + "%"),
|
||||
eb("lyricist", "like", "%" + query.author + "%"),
|
||||
]))
|
||||
}
|
||||
|
||||
const res = await q.execute();
|
||||
return res.map(({ pieceId }) => pieceId);
|
||||
}, {
|
||||
query: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
author: t.Optional(t.String()),
|
||||
offset: t.Optional(t.Integer({ minimum: 0 })),
|
||||
limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })),
|
||||
}),
|
||||
})
|
||||
|
||||
.get("/piece/:pieceId", async ({ db, query, params: { pieceId }, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const piece = await db
|
||||
.selectFrom("Piece")
|
||||
.selectAll()
|
||||
.where("pieceId", "=", pieceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (piece === undefined) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
const attachments = await db
|
||||
.selectFrom("Attachment")
|
||||
.selectAll()
|
||||
.where("pieceId", "=", pieceId)
|
||||
.execute();
|
||||
|
||||
return {
|
||||
...piece,
|
||||
attachments: attachments.map(({ sha256, ...rest }) => ({
|
||||
sha256: Buffer.from(sha256).toString("hex"),
|
||||
...rest,
|
||||
})),
|
||||
}
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
}),
|
||||
})
|
||||
|
||||
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.updateTable("Piece")
|
||||
.set({ name, composer, lyricist, arranger, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
|
||||
.where("pieceId", "=", pieceId)
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
return res[0];
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1 }),
|
||||
composer: t.Nullable(t.String({ minLength: 1 })),
|
||||
lyricist: t.Nullable(t.String({ minLength: 1 })),
|
||||
arranger: t.Nullable(t.String({ minLength: 1 })),
|
||||
}),
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
})
|
||||
})
|
||||
|
||||
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.deleteFrom("Piece")
|
||||
.where("pieceId", "=", pieceId)
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
set.status = "No Content";
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
}),
|
||||
})
|
||||
|
||||
// --- MARK: ATTACHMENT CRUD -------------------------------------------
|
||||
|
||||
.post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const attachmentId = AttachmentId(Bun.randomUUIDv7());
|
||||
const dataArray = new Uint8Array(await data.arrayBuffer());
|
||||
|
||||
const sha256 = Sha256(new Uint8Array(Bun.SHA256.byteLength));
|
||||
Bun.SHA256.hash(dataArray, sha256);
|
||||
|
||||
await db
|
||||
.insertInto("File")
|
||||
.values({ sha256, data: dataArray })
|
||||
.onConflict((cb) => cb.column("sha256").doNothing())
|
||||
.execute();
|
||||
|
||||
const res = await db
|
||||
.insertInto("Attachment")
|
||||
.values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: user.userId, createdAt: sql`datetime()` })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return {
|
||||
...res,
|
||||
sha256: Buffer.from(res.sha256).toString("hex"),
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
filename: t.String({ minLength: 1 }),
|
||||
mediaType: t.String({ minLength: 1 }),
|
||||
data: t.File(),
|
||||
}),
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
}),
|
||||
})
|
||||
|
||||
/* NOTE The piece ID is reduntant, because attachment IDs are unique for
|
||||
* the entire DB, not just per piece. However, we consider a piece to be
|
||||
* the sole owner of an attachment, i.e. attachments are not shared
|
||||
* (attachments are deduplicated on file storage level by their SHA-256
|
||||
* hash). Thus, we reflect the ownership in the URLs.
|
||||
*/
|
||||
|
||||
.get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, user, set }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.selectFrom("File")
|
||||
.innerJoin("Attachment", "File.sha256", "Attachment.sha256")
|
||||
.select(["Attachment.filename", "Attachment.mediaType", "File.data"])
|
||||
.where((eb) => eb.and([
|
||||
eb("Attachment.pieceId", "=", pieceId),
|
||||
eb("Attachment.attachmentId", "=", attachmentId),
|
||||
]))
|
||||
.executeTakeFirst();
|
||||
|
||||
if (res === undefined) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
set.headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(res.filename)}`;
|
||||
set.headers["content-type"] = res.mediaType;
|
||||
return new File([res.data], res.filename, { type: res.mediaType });
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
attachmentId: tbranded<AttachmentId>(),
|
||||
}),
|
||||
})
|
||||
|
||||
.put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.updateTable("Attachment")
|
||||
.set({ filename, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
|
||||
.where((eb) => eb.and([
|
||||
eb("pieceId", "=", pieceId),
|
||||
eb("attachmentId", "=", attachmentId),
|
||||
]))
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
return res[0];
|
||||
}, {
|
||||
body: t.Object({
|
||||
filename: t.String({ minLength: 1 }),
|
||||
}),
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
attachmentId: tbranded<AttachmentId>(),
|
||||
}),
|
||||
})
|
||||
|
||||
.delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.deleteFrom("Attachment")
|
||||
.where((eb) => eb.and([
|
||||
eb("pieceId", "=", pieceId),
|
||||
eb("attachmentId", "=", attachmentId),
|
||||
]))
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
set.status = "No Content";
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: tbranded<PieceId>(),
|
||||
attachmentId: tbranded<AttachmentId>(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
.get("*", () => Bun.file("packages/frontend/build/index.html"))
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user