Attachment CRUD API, show piece table and add piece form

This commit is contained in:
2024-11-19 23:38:58 +01:00
parent 5f0b8a69d3
commit 080cb1b490
6 changed files with 355 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
import cors from "@elysiajs/cors";
import { PieceId, RequestId, SessionId } from "common";
import { AttachmentId, PieceId, RequestId, SessionId, Sha256 } from "common";
import * as Function from "common/Function";
import { Elysia, error, t } from "elysia";
import { Elysia, error, form, t } from "elysia";
import { sql } from "kysely";
import { generateSessionId, initDatabase } from "./database";
@@ -10,8 +10,11 @@ const tbranded = <T>() => t.Transform(t.String())
.Encode(Function.unsafeCoerce<T, string>);
const app = new Elysia()
.use(cors({ origin: "localhost:5173" }))
.decorate("db", await initDatabase())
.resolve(async ({ db, cookie }) => {
await db
.deleteFrom("Session")
@@ -46,6 +49,7 @@ const app = new Elysia()
return { user };
})
.onTransform(async ({ db, request, server }) => {
const requestId = RequestId(Bun.randomUUIDv7("hex"));
@@ -63,6 +67,9 @@ const app = new Elysia()
console.log(`${timestamp} ${method} ${request.url} ${ip}`);
})
// --- AUTHENTICATION ------------------------------------------------------
.get("/me", async ({ user }) => {
if (user === null) {
@@ -75,6 +82,7 @@ const app = new Elysia()
admin: user.admin !== 0,
};
})
.post("/login", async ({ db, body: { username, password }, cookie }) => {
const user = await db
@@ -118,6 +126,7 @@ const app = new Elysia()
password: t.String({ minLength: 1 }),
}),
})
.post("/logout", async ({ db, cookie, set }) => {
set.status = "No Content";
@@ -135,10 +144,13 @@ const app = new Elysia()
.where("sessionId", "=", SessionId(sessionId))
.execute();
})
// --- PIECE CRUD ----------------------------------------------------------
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
if (user === null) {
return error(401);
return error("Unauthorized");
}
const pieceId = PieceId(Bun.randomUUIDv7());
@@ -158,10 +170,11 @@ const app = new Elysia()
arranger: t.Nullable(t.String({ minLength: 1 })),
}),
})
.get("/piece", async ({ db, user }) => {
if (user === null) {
return error(401);
return error("Unauthorized");
}
const res = await db
@@ -172,10 +185,11 @@ const app = new Elysia()
return res;
})
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => {
if (user === null) {
return error(401);
return error("Unauthorized");
}
const res = await db
@@ -189,7 +203,7 @@ const app = new Elysia()
return error("Not Found");
}
return res;
return res[0];
}, {
body: t.Object({
name: t.String({ minLength: 1 }),
@@ -201,10 +215,11 @@ const app = new Elysia()
pieceId: tbranded<PieceId>(),
})
})
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => {
if (user === null) {
return error(401);
return error("Unauthorized");
}
const res = await db
@@ -223,6 +238,164 @@ const app = new Elysia()
pieceId: tbranded<PieceId>(),
}),
})
// --- 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")
.ignore()
.values({ sha256, data: dataArray })
.execute();
const res = await db
.insertInto("Attachment")
.values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: user.userId, createdAt: sql`datetime()` })
.returningAll()
.executeTakeFirstOrThrow();
return res;
}, {
body: t.Object({
filename: t.String({ minLength: 1 }),
mediaType: t.String({ minLength: 1 }),
data: t.File(),
}),
params: t.Object({
pieceId: tbranded<PieceId>(),
}),
})
.get("piece/:pieceId/attachment", async ({ db, params: { pieceId }, user }) => {
if (user === null) {
return error("Unauthorized");
}
const res = await db
.selectFrom("Attachment")
.selectAll()
.where("pieceId", "=", pieceId)
.orderBy("filename")
.execute();
return res;
}, {
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 }) => {
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");
}
return form({
filename: res.filename,
mediaType: res.mediaType,
data: 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>(),
}),
})
// -------------------------------------------------------------------------
.listen(process.env.PORT || 3000);
export type App = typeof app;

View File

@@ -165,6 +165,13 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kys
.$call(systemInformation)
.execute();
await db.schema
.createIndex("Attachment_pieceId_filename")
.ifNotExists()
.on("Attachment")
.columns(["pieceId", "filename"])
.execute();
const { count } = await db
.selectFrom("User")
.select((eb) => eb.fn.countAll().as("count"))