Files
music-repo/packages/backend/src/app.ts
2024-12-25 22:32:33 +01:00

695 lines
17 KiB
TypeScript

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>() => t.Transform(t.String())
.Decode(Function.unsafeCoerce<string, T>)
.Encode(Function.unsafeCoerce<T, string>);
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<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, 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>(),
}),
})
// --- 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<PieceId>(),
})),
}),
})
.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<RepertoireId>(),
}),
})
.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<PieceId>(),
})),
}),
params: t.Object({
repertoireId: tbranded<RepertoireId>(),
}),
})
.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<RepertoireId>(),
}),
// 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;