import cors from "@elysiajs/cors"; import { staticPlugin } from "@elysiajs/static"; import { swagger } from "@elysiajs/swagger"; import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, Sha256_Hex } from "common"; import { Effect, Option, pipe } from "effect"; import { Elysia, error, t } from "elysia"; import { sql } from "kysely"; import { CLIENT_ID, EXTERNAL_OAUTH_CONFIGURATION, getUser, INTERNAL_OAUTH_CONFIGURATION, makeAuthorizationUrl, REDIRECT_URI, revalidateTokens } from "./auth"; import * as Db from "./database"; import * as Model from "./model"; import { DbFromInstance } from "./services/db"; import { SessionFromValue } from "./services/session"; const app = new Elysia() .use(swagger({ scalarConfig: { authentication: { securitySchemes: { cookieAuth: { type: "apiKey", in: "cookie", name: "sessionId", }, }, }, }, swaggerOptions: { withCredentials: true, }, })) .use(cors({ origin: process.env.NODE_ENV === "production" ? false : "localhost:5173" })) .decorate("db", await Db.initDatabase(process.env.DB_PATH)) .resolve(async ({ db, cookie }) => { await db .deleteFrom("Session") .where(sql`datetime()`, ">=", "expiresAt") .execute(); const sessionId = (cookie.sessionId.value as SessionId | undefined) ?? Db.generateSessionId(); const expiresAt = new Date().getTime() + 604800000; cookie.sessionId.set({ value: sessionId, expires: new Date(expiresAt), httpOnly: true, sameSite: "none", secure: true, }); const returning = [ "sessionId", "accessToken", "codeVerifier", "external", "idToken", "refreshToken", "state", ] as const; let session = await db .updateTable("Session") .set({ expiresAt: sql`datetime('now', '+7 days') ` }) .where("sessionId", "=", sessionId) .returning(returning) .executeTakeFirst(); if (session === undefined) { session = await db .insertInto("Session") .values({ sessionId, expiresAt: sql`datetime('now', '+7 days')` }) .returning(returning) .executeTakeFirstOrThrow(); } const { accessToken, idToken, refreshToken, roles, userId } = await pipe( { accessToken: Option.fromNullable(session.accessToken), idToken: Option.fromNullable(session.idToken), refreshToken: Option.fromNullable(session.refreshToken), external: Boolean(session.external), }, revalidateTokens, Effect.runPromise, ); await db .updateTable("Session") .set({ accessToken: pipe( accessToken, Option.map((at) => at.token), Option.getOrNull, ), idToken: pipe( idToken, Option.map((it) => it.token), Option.getOrNull, ), refreshToken: Option.getOrNull(refreshToken), }) .execute(); return { session: { sessionId: session.sessionId, accessToken, idToken, refreshToken, roles, userId, codeVerifier: Option.fromNullable(session.codeVerifier), external: pipe( session.external, Option.fromNullable, Option.map((e) => e !== 0), ), state: Option.fromNullable(session.state), }, }; }) .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", ({ session: { idToken, roles } }) => { return Option.match(idToken, { onNone: () => error("Unauthorized", "Session invalid or expired"), onSome: ({ payload: { oid, name } }) => ({ userId: oid, username: name, roles: roles as string[], }), }); }, { response: { 200: Model.Me, 401: t.Literal("Session invalid or expired"), }, }) .get("/login", async ({ db, query, redirect, session: { sessionId } }) => { const url = await pipe( makeAuthorizationUrl({ external: "external" in query }), Effect.provide([ DbFromInstance(db), SessionFromValue(sessionId), ]), Effect.runPromise, ); return redirect(url, 302) as unknown as void; }, { response: { 302: t.Void(), }, }) .post("/login", async ({ db, request, redirect, session: { sessionId, external, codeVerifier } }) => { const data = await request.formData(); const code = data.get("code") as string | null; const state = data.get("state") as string | null; if (code !== null && state !== null && Option.isSome(external) && Option.isSome(codeVerifier)) { const { tokenEndpoint } = external.value ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION; const res = await fetch(tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ "client_id": CLIENT_ID, "code": code, "redirect_uri": REDIRECT_URI, "grant_type": "authorization_code", "code_verifier": codeVerifier.value, "client_secret": process.env.CLIENT_SECRET!, }).toString(), }); const { access_token: accessToken, refresh_token: refreshToken, id_token: idToken, } = await res.json() as { access_token: string, refresh_token: string, id_token: string }; await db .updateTable("Session") .set({ accessToken, refreshToken, idToken, codeVerifier: null, state: null, }) .where("sessionId", "=", sessionId) .execute(); } return redirect(process.env.NODE_ENV === "production" ? "https://music.renati.me/" : "http://localhost:5173/", 303) as unknown as void; }, { response: { 303: t.Void(), }, }) .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(); }, { response: { 204: t.Void(), }, }) // --- MARK: USER MANAGEMENT ------------------------------------------- .get("/user/:userId", async ({ params: { userId }, session: { accessToken } }) => { if (Option.isNone(accessToken)) { return error("Unauthorized", "Session invalid or expired"); } const res = await pipe( { accessToken: accessToken.value.token, userId }, getUser, Effect.runPromise, ); return Option.match(res, { onNone: () => error("Not Found", undefined), onSome: ({ displayName }) => ({ userId, displayName }), }); }, { params: t.Object({ userId: Model.UserId, }), response: { 200: Model.User, 401: t.Literal("Session invalid or expired"), 404: t.Void(), }, }) // --- MARK: PIECE CRUD ------------------------------------------------ .post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } if (!idToken.value.payload.roles.includes("Editor")) { return error("Forbidden", "Must be an Editor"); } const pieceId = PieceId(Bun.randomUUIDv7()); const res = await db .insertInto("Piece") .values({ pieceId, name, composer, lyricist, arranger, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` }) .returningAll() .executeTakeFirstOrThrow(); return { ...res, attachments: [], }; }, { body: Model.Piece_Post, response: { 200: Model.Piece, 401: t.Literal("Session invalid or expired"), 403: t.Literal("Must be an Editor"), }, }) .get("/piece", async ({ db, query, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } 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: Model.Piece_Query, response: { 200: t.Array(Model.PieceId), 401: t.Literal("Session invalid or expired"), }, }) .get("/piece/:pieceId", async ({ db, params: { pieceId }, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } const piece = await db .selectFrom("Piece") .selectAll() .where("pieceId", "=", pieceId) .executeTakeFirst(); if (piece === undefined) { return error("Not Found", undefined); } const attachments = await db .selectFrom("Attachment") .selectAll() .where("pieceId", "=", pieceId) .execute(); return { ...piece, attachments: attachments.map(({ sha256, ...rest }) => ({ sha256: Sha256_Hex(Buffer.from(sha256).toString("hex")), ...rest, })), }; }, { params: t.Object({ pieceId: Model.PieceId, }), response: { 200: Model.Piece, 401: t.Literal("Session invalid or expired"), 404: t.Void(), }, }) .put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } if (!idToken.value.payload.roles.includes("Editor")) { return error("Forbidden", "Must be an Editor"); } const res = await db .updateTable("Piece") .set({ name, composer, lyricist, arranger, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` }) .where("pieceId", "=", pieceId) .returningAll() .execute(); if (res.length === 0) { return error("Not Found", undefined); } const attachments = await db .selectFrom("Attachment") .selectAll() .where("pieceId", "=", pieceId) .execute(); return { ...res[0], attachments: attachments.map(({ sha256, ...rest }) => ({ sha256: Sha256_Hex(Buffer.from(sha256).toString("hex")), ...rest, })), }; }, { 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: Model.PieceId, }), response: { 200: Model.Piece, 401: t.Literal("Session invalid or expired"), 403: t.Literal("Must be an Editor"), 404: t.Void(), }, }) .delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } if (!idToken.value.payload.roles.includes("Editor")) { return error("Forbidden", "Must be an Editor"); } const res = await db .deleteFrom("Piece") .where("pieceId", "=", pieceId) .returningAll() .execute(); if (res.length === 0) { return error("Not Found", undefined); } set.status = "No Content"; }, { params: t.Object({ pieceId: Model.PieceId, }), response: { 204: t.Void(), 401: t.Literal("Session invalid or expired"), 404: t.Void(), }, }) // --- MARK: ATTACHMENT CRUD ------------------------------------------- .post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } if (!idToken.value.payload.roles.includes("Editor")) { return error("Forbidden", "Must be an Editor"); } const attachmentId = AttachmentId(Bun.randomUUIDv7()); const dataArray = new Uint8Array(await data.arrayBuffer()); const sha256 = Sha256_Bin(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: idToken.value.payload.oid, createdAt: sql`datetime()` }) .returningAll() .executeTakeFirstOrThrow(); return { ...res, sha256: Sha256_Hex(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: Model.PieceId, }), response: { 200: Model.Attachment, 401: t.Literal("Session invalid or expired"), 403: t.Literal("Must be an Editor"), }, }) /* 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 }, session: { idToken }, set }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } 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", undefined); } 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: Model.PieceId, attachmentId: Model.AttachmentId, }), response: { 200: t.File(), 401: t.Literal("Session invalid or expired"), 404: t.Void(), }, }) .put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } if (!idToken.value.payload.roles.includes("Editor")) { return error("Forbidden", "Must be an Editor"); } const res = await db .updateTable("Attachment") .set({ filename, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` }) .where((eb) => eb.and([ eb("pieceId", "=", pieceId), eb("attachmentId", "=", attachmentId), ])) .returningAll() .execute(); if (res.length === 0) { return error("Not Found", undefined); } return { ...res[0], sha256: Sha256_Hex(Buffer.from(res[0].sha256).toString("hex")), }; }, { body: t.Object({ filename: t.String({ minLength: 1 }), }), params: t.Object({ pieceId: Model.PieceId, attachmentId: Model.AttachmentId, }), response: { 200: Model.Attachment, 401: t.Literal("Session invalid or expired"), 403: t.Literal("Must be an Editor"), 404: t.Void(), }, }) .delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } if (!idToken.value.payload.roles.includes("Editor")) { return error("Forbidden", "Must be an Editor"); } 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", undefined); } set.status = "No Content"; }, { params: t.Object({ pieceId: Model.PieceId, attachmentId: Model.AttachmentId, }), response: { 204: t.Void(), 401: t.Literal("Session invalid or expired"), 403: t.Literal("Must be an Editor"), 404: t.Void(), }, }) // --- MARK: REPERTOIRE CRUD ------------------------------------------- .post("/repertoire", async ({ db, body: { name, entries }, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } if (!idToken.value.payload.roles.includes("Editor")) { return error("Forbidden", "Must be an Editor"); } const repertoireId = RepertoireId(Bun.randomUUIDv7()); const repertoire = await db .insertInto("Repertoire") .values({ repertoireId, name, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` }) .returningAll() .executeTakeFirstOrThrow(); const dbEntries: Db.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: Model.PieceId, })), }), response: { 401: t.Literal("Session invalid or expired"), 403: t.Literal("Must be an Editor"), }, }) .get("/repertoire", async ({ db, query, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } 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: Model.Repertoire_Query, response: { 200: t.Array(Model.RepertoireId), 401: t.Literal("Session invalid or expired"), }, }) .get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } const repertoire = await db .selectFrom("Repertoire") .selectAll() .where("repertoireId", "=", repertoireId) .executeTakeFirst(); if (repertoire === undefined) { return error("Not Found", undefined); } const entries = await db .selectFrom("RepertoireEntry") .select(["pieceId"]) .where("repertoireId", "=", repertoireId) .orderBy("order") .execute(); return { ...repertoire, entries: entries.map(({ pieceId }) => pieceId), }; }, { params: t.Object({ repertoireId: Model.RepertoireId, }), response: { 200: Model.Repertoire, 401: t.Literal("Session invalid or expired"), 404: t.Void(), }, }) .put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } if (!idToken.value.payload.roles.includes("Editor")) { return error("Forbidden", "Must be an Editor"); } const res = await db .updateTable("Repertoire") .set({ name, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` }) .where("repertoireId", "=", repertoireId) .returningAll() .execute(); if (res.length === 0) { return error("Not Found", undefined); } await db .deleteFrom("RepertoireEntry") .where("repertoireId", "=", repertoireId) .execute(); for (let i = 0; i < entries.length; ++i) { const entry = entries[i]; await db .insertInto("RepertoireEntry") .values({ pieceId: entry, repertoireId, order: i }) .returning(["pieceId"]) .executeTakeFirstOrThrow(); } return { ...res[0], entries, }; }, { body: t.Object({ name: t.String({ minLength: 1 }), entries: t.Array(Model.PieceId), }), params: t.Object({ repertoireId: Model.RepertoireId, }), response: { 200: Model.Repertoire, 401: t.Literal("Session invalid or expired"), 403: t.Literal("Must be an Editor"), 404: t.Void(), }, }) .delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, session: { idToken } }) => { if (Option.isNone(idToken)) { return error("Unauthorized", "Session invalid or expired"); } if (!idToken.value.payload.roles.includes("Editor")) { return error("Forbidden", "Must be an Editor"); } 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: Model.RepertoireId, }), response: { 204: t.Void(), 401: t.Literal("Session invalid or expired"), 403: t.Literal("Must be an Editor"), 404: t.Void(), }, // 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;