import cors from "@elysiajs/cors"; import { staticPlugin } from "@elysiajs/static"; import { swagger } from "@elysiajs/swagger"; import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256, UserId } from "common"; import * as Function from "common/Function"; import { Elysia, error, t } from "elysia"; import { sql } from "kysely"; import { generateSessionId, initDatabase, RepertoireEntry } from "./database"; const tbranded = () => t.Transform(t.String()) .Decode(Function.unsafeCoerce) .Encode(Function.unsafeCoerce); const app = new Elysia() .use(swagger()) .use(cors({ origin: process.env.NODE_ENV === "production" ? false : "localhost:5173" })) .decorate("db", await initDatabase(process.env.DB_PATH)) .resolve(async ({ db, cookie }) => { await db .deleteFrom("Session") .where(sql`datetime()`, ">=", "expiresAt") .execute(); const sessionId = cookie.sessionId.value; if (sessionId === undefined) { return { user: null }; } const res = await db .updateTable("Session") .set({ expiresAt: sql`datetime('now', '+7 days') ` }) .where("sessionId", "=", SessionId(sessionId)) .returning(["userId"]) .executeTakeFirst(); if (res === undefined) { return { user: null }; } const user = await db .selectFrom("User") .selectAll() .where("userId", "=", res.userId) .executeTakeFirst(); if (user === undefined) { return { user: null }; } return { user }; }) .onTransform(async ({ db, request, server }) => { const requestId = RequestId(Bun.randomUUIDv7("hex")); const timestamp = new Date().toISOString(); const { method } = request; const url = new URL(request.url); const { pathname } = url; const query = JSON.stringify(Object.fromEntries(url.searchParams.entries())); const ip = server?.requestIP(request)?.address ?? null; await db .insertInto("AccessLog") .values({ requestId, timestamp, method, pathname, query, ip }) .execute(); console.log(`${timestamp} ${method} ${request.url} ${ip}`); }) .use(staticPlugin({ assets: "packages/frontend/build/assets", prefix: "/assets", alwaysStatic: true, indexHTML: false, })) .group("/api/v1", (app) => app // --- MARK: AUTHENTICATION -------------------------------------------- .get("/me", ({ user }) => { if (user === null) { return error("Unauthorized"); } return { userId: user.userId, username: user.username, admin: user.admin !== 0, }; }) .post("/login", async ({ db, body: { username, password }, cookie }) => { 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(); }) .post("/change-password", async ({ db, body: { username, currentPassword, newPassword }, set }) => { 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(currentPassword, user.password); if (!valid) { return error("Unauthorized", "Invalid username or password"); } const password = await Bun.password.hash(newPassword); const res = await db .updateTable("User") .set({ password }) .where("username", "=", username) .returningAll() .execute(); if (res.length === 0) { return error("Unauthorized", "Invalid username or password"); } set.status = "No Content"; }, { body: t.Object({ username: t.String({ minLength: 1 }), currentPassword: t.String({ minLength: 1 }), newPassword: t.String({ minLength: 1 }), }), }) // --- 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(), }), }) // --- 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, 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(), }), }) .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(), }), }) .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(), }), }) // --- 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(), }), }) /* 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(), attachmentId: tbranded(), }), }) .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(), attachmentId: tbranded(), }), }) .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(), attachmentId: tbranded(), }), }) // --- MARK: REPERTOIRE CRUD ------------------------------------------- .post("/repertoire", async ({ db, body: { name, entries }, user }) => { if (user === null) { return error("Unauthorized"); } const repertoireId = RepertoireId(Bun.randomUUIDv7()); const repertoire = await db .insertInto("Repertoire") .values({ repertoireId, name, createdBy: user.userId, createdAt: sql`datetime()` }) .returningAll() .executeTakeFirstOrThrow(); const dbEntries: RepertoireEntry[] = []; for (let i = 0; i < entries.length; ++i) { const entry = entries[i]; const dbEntry = await db .insertInto("RepertoireEntry") .values({ ...entry, repertoireId, order: i }) .returningAll() .executeTakeFirstOrThrow(); dbEntries.push(dbEntry); } return { ...repertoire, entries: dbEntries, }; }, { body: t.Object({ name: t.String({ minLength: 1 }), entries: t.Array(t.Object({ pieceId: tbranded(), })), }), }) .get("/repertoire", async ({ db, query, user }) => { if (user === null) { return error("Unauthorized"); } let q = db .selectFrom("Repertoire") .select("repertoireId") .orderBy(["name"]) .offset(query.offset ?? 0) .limit(query.limit ?? 100); if (query.name !== undefined) { q = q.where("name", "like", "%" + query.name + "%"); } const res = await q.execute(); return res.map(({ repertoireId }) => repertoireId); }, { query: t.Object({ name: t.Optional(t.String()), offset: t.Optional(t.Integer({ minimum: 0 })), limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })), }), }) .get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, user }) => { if (user === null) { return error("Unauthorized"); } const repertoire = await db .selectFrom("Repertoire") .selectAll() .where("repertoireId", "=", repertoireId) .executeTakeFirst(); if (repertoire === undefined) { return error("Not Found"); } const entries = await db .selectFrom("RepertoireEntry") .selectAll() .where("repertoireId", "=", repertoireId) .orderBy("order") .execute(); return { ...repertoire, entries, }; }, { params: t.Object({ repertoireId: tbranded(), }), }) .put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, user }) => { if (user === null) { return error("Unauthorized"); } const res = await db .updateTable("Repertoire") .set({ name, modifiedBy: user.userId, modifiedAt: sql`datetime()` }) .where("repertoireId", "=", repertoireId) .returningAll() .execute(); if (res.length === 0) { return error("Not Found"); } await db .deleteFrom("RepertoireEntry") .where("repertoireId", "=", repertoireId) .execute(); const dbEntries: RepertoireEntry[] = []; for (let i = 0; i < entries.length; ++i) { const entry = entries[i]; const dbEntry = await db .insertInto("RepertoireEntry") .values({ ...entry, repertoireId, order: i }) .returningAll() .executeTakeFirstOrThrow(); dbEntries.push(dbEntry); } return { ...res, entries: dbEntries, }; }, { body: t.Object({ name: t.String({ minLength: 1 }), entries: t.Array(t.Object({ pieceId: tbranded(), })), }), params: t.Object({ repertoireId: tbranded(), }), }) .delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, user }) => { if (user === null) { return error("Unauthorized"); } const res = await db .deleteFrom("Repertoire") .where("repertoireId", "=", repertoireId) .returningAll() .execute(); if (res.length === 0) { return error("Not Found"); } set.status = "No Content"; }, { params: t.Object({ repertoireId: tbranded(), }), // eslint-disable-next-line @stylistic/comma-dangle -- a comma would confuse the TS compiler here }) ) .get("*", () => Bun.file("packages/frontend/build/index.html")); // ------------------------------------------------------------------------- app.listen(process.env.PORT || 3000); export type App = typeof app;