diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f22ac65 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +build +db.sqlite3 +dist +node_modules +tsconfig.tsbuildinfo + +.dockerignore +Dockerfile diff --git a/.gitignore b/.gitignore index 738a01a..9d26a89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ +build db.sqlite3 dist node_modules -storage tsconfig.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1089998 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1.7-labs +FROM node:lts AS build +WORKDIR /app + +RUN apt-get update +RUN apt-get install --yes build-essential +RUN apt-get install --yes libxi-dev +RUN apt-get install --yes python-is-python3 + +COPY --parents package.json pnpm-lock.yaml pnpm-workspace.yaml packages/*/package.json . +RUN npm install --global pnpm +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm exec tsc --build +RUN pnpm --filter frontend exec vite build + +FROM oven/bun:1 +WORKDIR /usr/src/app + +COPY --from=build /app . +ENTRYPOINT ["bun", "run", "packages/backend/src/app.ts"] diff --git a/package.json b/package.json index f3865fc..96f407f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "license": "UNLICENSED", "scripts": { "backend:dev": "bun run --watch packages/backend/src/app.ts", + "docker:build": "docker build -t music-repo .", + "docker:run": "docker run --init --publish 3000:3000 --rm music-repo", + "frontend:build": "pnpm --filter frontend exec vite build", "frontend:dev": "pnpm --filter frontend exec vite --open" }, "devDependencies": { diff --git a/packages/backend/package.json b/packages/backend/package.json index c9d7489..73003ca 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@elysiajs/cors": "catalog:", + "@elysiajs/static": "catalog:", "@elysiajs/swagger": "catalog:", "common": "workspace:^", "elysia": "catalog:", diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 24e7537..f9647d7 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -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(), - }), - }) - - // --- 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(), - }), - }) - - .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(), + 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(), - }), - }) + 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(), - }), - }) + // --- 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(), + }), + }) - set.headers["content-disposition"] = `attachment; filename="${res.filename}"`; - return Bun.file(res.data, { type: res.mediaType }); - }, { - params: t.Object({ - pieceId: tbranded(), - attachmentId: tbranded(), - }), - }) + // --- 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(), - attachmentId: tbranded(), - }), - }) + 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(), - attachmentId: tbranded(), - }), - }) + 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(), + }), + }) + + .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(), + }), + }) + ) + + .get("*", () => Bun.file("packages/frontend/build/index.html")) // ------------------------------------------------------------------------- diff --git a/packages/common/src/MediaType.ts b/packages/common/src/MediaType.ts index cebd9f3..665d2b9 100644 --- a/packages/common/src/MediaType.ts +++ b/packages/common/src/MediaType.ts @@ -22,6 +22,12 @@ export const ACCEPTED_EXTENSIONS = (() => { return ret.join(","); })(); +export const ACCEPTED_MEDIA_TYPES = (() => { + const ret = Array.from(mediaTypeExtension.keys()); + ret.sort((a, b) => a.localeCompare(b)); + return Object.freeze(ret); +})(); + function register(mediaType: string, ...extensions: [string, ...string[]]) { const [primaryExtension, ...secondaryExtensions] = extensions; diff --git a/packages/frontend/src/client.ts b/packages/frontend/src/client.ts index 325e762..9d6b5d0 100644 --- a/packages/frontend/src/client.ts +++ b/packages/frontend/src/client.ts @@ -1,9 +1,24 @@ import { Treaty, treaty } from "@elysiajs/eden"; import type { App } from "backend/app"; +import { ACCEPTED_MEDIA_TYPES } from "common/MediaType"; import { Effect } from "effect"; export type ResponseEffect> = Effect.Effect extends never ? never : { [Status in keyof R]: { status: Status, value: R[Status] } }[Exclude]>; -export const client = treaty("localhost:3000", { fetch: { credentials: "include" } }); +export const client = treaty(process.env.NODE_ENV === "production" ? "" : "localhost:3000", { + fetch: { + credentials: "include", + }, + keepDomain: true, + onResponse: async (res) => { + const contentType = res.headers.get('Content-Type')?.split(';')[0]; + if (contentType !== undefined && ACCEPTED_MEDIA_TYPES.includes(contentType)) { + const blob = await res.blob(); + // TODO Decode filename from Content-Disposition header + const file = new File([blob], "", { type: contentType }); + return file; + } + }, +}).api; export const mapResponse = >({ error, data }: Treaty.TreatyResponse): ResponseEffect => error !== null ? Effect.fail(error) as any : Effect.succeed(data); diff --git a/packages/frontend/src/routes/Piece.tsx b/packages/frontend/src/routes/Piece.tsx index 9f6fd20..bfc32b2 100644 --- a/packages/frontend/src/routes/Piece.tsx +++ b/packages/frontend/src/routes/Piece.tsx @@ -201,7 +201,7 @@ function AttachmentRow(props: AttachmentRow.Props) { const url = URL.createObjectURL(data); const a = document.createElement("a"); a.href = url; - a.download = data.name; + a.download = props.attachment.filename; // TODO Use `data.name` after Content-Disposition parser is implemented a.click(); URL.revokeObjectURL(url); }, [props.attachment.attachmentId, props.attachment.pieceId]); diff --git a/packages/frontend/tailwind.config.js b/packages/frontend/tailwind.config.js index 22c119f..2b45fec 100644 --- a/packages/frontend/tailwind.config.js +++ b/packages/frontend/tailwind.config.js @@ -1,3 +1,5 @@ +import tailwindcssAnimate from "tailwindcss-animate"; + /** @type {import("tailwindcss").Config} */ export default { darkMode: ["class"], @@ -19,5 +21,5 @@ export default { colors: {}, }, }, - plugins: [require("tailwindcss-animate")], + plugins: [tailwindcssAnimate], }; diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 007addc..2a6c760 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -10,4 +10,7 @@ export default defineConfig({ "common": path.resolve(__dirname, "../common/src"), }, }, + build: { + outDir: "build", + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd83fa8..8ba6218 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@elysiajs/eden': specifier: ^1.1.3 version: 1.1.3 + '@elysiajs/static': + specifier: ^1.1.1 + version: 1.1.1 '@elysiajs/swagger': specifier: ^1.1.6 version: 1.1.6 @@ -107,6 +110,9 @@ importers: '@elysiajs/cors': specifier: 'catalog:' version: 1.1.1(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2)) + '@elysiajs/static': + specifier: 'catalog:' + version: 1.1.1(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2)) '@elysiajs/swagger': specifier: 'catalog:' version: 1.1.6(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2)) @@ -317,6 +323,11 @@ packages: peerDependencies: elysia: '>= 1.1.0' + '@elysiajs/static@1.1.1': + resolution: {integrity: sha512-H1KqsuNHhHKYKUkPoies0pPQBgbA4qsfre840FKraeF99jz++2P/igrOagp8cWqwFGrHP1V+nwGlGm9U6rZAEg==} + peerDependencies: + elysia: '>= 1.1.0' + '@elysiajs/swagger@1.1.6': resolution: {integrity: sha512-B1airTG3eh6eFgFxGS2UtsdZ7Xc2vrn3YKIFLFai9YeZVROSHmi3ZaXZvGAn3DnkXHT6I+qx960xnrqoNiopUw==} peerDependencies: @@ -1108,6 +1119,10 @@ packages: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1618,6 +1633,10 @@ packages: resolution: {integrity: sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==} engines: {node: '>=10'} + node-cache@5.1.2: + resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} + engines: {node: '>= 8.0.0'} + node-gyp@9.4.1: resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} engines: {node: ^12.13 || ^14.13 || >=16} @@ -2287,6 +2306,11 @@ snapshots: dependencies: elysia: 1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2) + '@elysiajs/static@1.1.1(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2))': + dependencies: + elysia: 1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2) + node-cache: 5.1.2 + '@elysiajs/swagger@1.1.6(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2))': dependencies: '@scalar/types': 0.0.12 @@ -3016,6 +3040,8 @@ snapshots: clean-stack@2.2.0: optional: true + clone@2.1.2: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -3564,6 +3590,10 @@ snapshots: semver: 7.6.3 optional: true + node-cache@5.1.2: + dependencies: + clone: 2.1.2 + node-gyp@9.4.1: dependencies: env-paths: 2.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5df8093..4f4f818 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: catalog: '@elysiajs/cors': '^1.1.1' '@elysiajs/eden': '^1.1.3' + '@elysiajs/static': '^1.1.1' '@elysiajs/swagger': '^1.1.6' '@radix-ui/react-dialog': '^1.1.2' '@radix-ui/react-dropdown-menu': '^2.1.2'