Docker support, backend SPA mode

This commit is contained in:
2024-12-22 22:46:55 +01:00
parent 555a24dbe5
commit 228713b3cc
13 changed files with 467 additions and 362 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
build
db.sqlite3
dist
node_modules
tsconfig.tsbuildinfo
.dockerignore
Dockerfile

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
build
db.sqlite3 db.sqlite3
dist dist
node_modules node_modules
storage
tsconfig.tsbuildinfo tsconfig.tsbuildinfo

22
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -5,6 +5,9 @@
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
"backend:dev": "bun run --watch packages/backend/src/app.ts", "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" "frontend:dev": "pnpm --filter frontend exec vite --open"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@elysiajs/cors": "catalog:", "@elysiajs/cors": "catalog:",
"@elysiajs/static": "catalog:",
"@elysiajs/swagger": "catalog:", "@elysiajs/swagger": "catalog:",
"common": "workspace:^", "common": "workspace:^",
"elysia": "catalog:", "elysia": "catalog:",

View File

@@ -1,4 +1,5 @@
import cors from "@elysiajs/cors"; import cors from "@elysiajs/cors";
import { staticPlugin } from "@elysiajs/static";
import { swagger } from "@elysiajs/swagger"; import { swagger } from "@elysiajs/swagger";
import { AttachmentId, PieceId, RequestId, SessionId, Sha256, UserId } from "common"; import { AttachmentId, PieceId, RequestId, SessionId, Sha256, UserId } from "common";
import * as Function from "common/Function"; import * as Function from "common/Function";
@@ -14,9 +15,9 @@ const app = new Elysia()
.use(swagger()) .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 }) => { .resolve(async ({ db, cookie }) => {
await db await db
@@ -31,7 +32,7 @@ const app = new Elysia()
const res = await db const res = await db
.updateTable("Session") .updateTable("Session")
.set({ expiresAt: sql`datetime('now', '+7 days') `}) .set({ expiresAt: sql`datetime('now', '+7 days') ` })
.where("sessionId", "=", SessionId(sessionId)) .where("sessionId", "=", SessionId(sessionId))
.returning(["userId"]) .returning(["userId"])
.executeTakeFirst(); .executeTakeFirst();
@@ -71,396 +72,409 @@ const app = new Elysia()
console.log(`${timestamp} ${method} ${request.url} ${ip}`); 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) { // --- MARK: AUTHENTICATION --------------------------------------------
return error("Unauthorized");
}
return { .get("/me", async ({ user }) => {
userId: user.userId,
username: user.username,
admin: user.admin !== 0,
};
})
.post("/login", async ({ db, body: { username, password }, cookie }) => { if (user === null) {
return error("Unauthorized");
}
const user = await db return {
.selectFrom("User") userId: user.userId,
.selectAll() username: user.username,
.where("username", "=", username) admin: user.admin !== 0,
.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<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, 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<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 }) => { .post("/login", async ({ db, body: { username, password }, cookie }) => {
if (user === null) { const user = await db
return error("Unauthorized"); .selectFrom("User")
} .selectAll()
.where("username", "=", username)
.executeTakeFirst();
const res = await db if (user === undefined) {
.deleteFrom("Piece") return error("Unauthorized", "Invalid username or password");
.where("pieceId", "=", pieceId) }
.returningAll()
.execute();
if (res.length === 0) { const valid = await Bun.password.verify(password, user.password);
return error("Not Found"); if (!valid) {
} return error("Unauthorized", "Invalid username or password");
}
set.status = "No Content"; const sessionId = generateSessionId();
}, { await db
params: t.Object({ .insertInto("Session")
pieceId: tbranded<PieceId>(), .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) { .post("/logout", async ({ db, cookie, set }) => {
return error("Unauthorized");
}
const attachmentId = AttachmentId(Bun.randomUUIDv7()); set.status = "No Content";
const dataArray = new Uint8Array(await data.arrayBuffer());
const sha256 = Sha256(new Uint8Array(Bun.SHA256.byteLength)); const sessionCookie = cookie.sessionId;
Bun.SHA256.hash(dataArray, sha256); sessionCookie.remove();
await db const sessionId = sessionCookie.value;
.insertInto("File") if (sessionId === undefined) {
.values({ sha256, data: dataArray }) return;
.onConflict((cb) => cb.column("sha256").doNothing()) }
.execute();
const res = await db await db
.insertInto("Attachment") .deleteFrom("Session")
.values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: user.userId, createdAt: sql`datetime()` }) .where("sessionId", "=", SessionId(sessionId))
.returningAll() .execute();
.executeTakeFirstOrThrow(); })
return { // --- MARK: USER MANAGEMENT -------------------------------------------
...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 .get("/user/:userId", async ({ db, params: { userId }, user }) => {
* 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");
}
if (user === null) { const res = await db
return error("Unauthorized"); .selectFrom("User")
} .select(["userId", "username", "admin"])
.where("userId", "=", userId)
.executeTakeFirst();
const res = await db if (res === undefined) {
.selectFrom("File") return error("Not Found");
.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 {
return error("Not Found"); userId: res.userId,
} username: res.username,
admin: res.admin !== 0,
};
}, {
params: t.Object({
userId: tbranded<UserId>(),
}),
})
set.headers["content-disposition"] = `attachment; filename="${res.filename}"`; // --- MARK: PIECE CRUD ------------------------------------------------
return Bun.file(res.data, { 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 }) => { .post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized");
} }
const res = await db const pieceId = PieceId(Bun.randomUUIDv7());
.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) { const res = await db
return error("Not Found"); .insertInto("Piece")
} .values({ pieceId, name, composer, lyricist, arranger, createdBy: user.userId, createdAt: sql`datetime()` })
.returningAll()
.executeTakeFirstOrThrow();
return res[0]; return res;
}, { }, {
body: t.Object({ body: t.Object({
filename: t.String({ minLength: 1 }), name: t.String({ minLength: 1 }),
}), composer: t.Nullable(t.String({ minLength: 1 })),
params: t.Object({ lyricist: t.Nullable(t.String({ minLength: 1 })),
pieceId: tbranded<PieceId>(), arranger: t.Nullable(t.String({ minLength: 1 })),
attachmentId: tbranded<AttachmentId>(), }),
}), })
})
.delete("piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, user }) => { .get("/piece", async ({ db, query, user }) => {
if (user === null) { if (user === null) {
return error("Unauthorized"); return error("Unauthorized");
} }
const res = await db let q = db
.deleteFrom("Attachment") .selectFrom("Piece")
.where((eb) => eb.and([ .select("pieceId")
eb("pieceId", "=", pieceId), .orderBy(["name", "composer", "arranger"])
eb("attachmentId", "=", attachmentId), .offset(query.offset ?? 0)
])) .limit(query.limit ?? 100);
.returningAll()
.execute();
if (res.length === 0) { if (query.name !== undefined) {
return error("Not Found"); q = q.where("name", "like", "%" + query.name + "%");
} }
set.status = "No Content"; if (query.author !== undefined) {
}, { q = q.where((eb) => eb.or([
params: t.Object({ eb("composer", "like", "%" + query.author + "%"),
pieceId: tbranded<PieceId>(), eb("arranger", "like", "%" + query.author + "%"),
attachmentId: tbranded<AttachmentId>(), 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<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>(),
}),
})
)
.get("*", () => Bun.file("packages/frontend/build/index.html"))
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -22,6 +22,12 @@ export const ACCEPTED_EXTENSIONS = (() => {
return ret.join(","); 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[]]) { function register(mediaType: string, ...extensions: [string, ...string[]]) {
const [primaryExtension, ...secondaryExtensions] = extensions; const [primaryExtension, ...secondaryExtensions] = extensions;

View File

@@ -1,9 +1,24 @@
import { Treaty, treaty } from "@elysiajs/eden"; import { Treaty, treaty } from "@elysiajs/eden";
import type { App } from "backend/app"; import type { App } from "backend/app";
import { ACCEPTED_MEDIA_TYPES } from "common/MediaType";
import { Effect } from "effect"; import { Effect } from "effect";
export type ResponseEffect<R extends Record<number, unknown>> = Effect.Effect<R[200], Exclude<keyof R, 200> extends never ? never : { [Status in keyof R]: { status: Status, value: R[Status] } }[Exclude<keyof R, 200>]>; export type ResponseEffect<R extends Record<number, unknown>> = Effect.Effect<R[200], Exclude<keyof R, 200> extends never ? never : { [Status in keyof R]: { status: Status, value: R[Status] } }[Exclude<keyof R, 200>]>;
export const client = treaty<App>("localhost:3000", { fetch: { credentials: "include" } }); export const client = treaty<App>(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 = <R extends Record<number, unknown>>({ error, data }: Treaty.TreatyResponse<R>): ResponseEffect<R> => error !== null ? Effect.fail(error) as any : Effect.succeed(data); export const mapResponse = <R extends Record<number, unknown>>({ error, data }: Treaty.TreatyResponse<R>): ResponseEffect<R> => error !== null ? Effect.fail(error) as any : Effect.succeed(data);

View File

@@ -201,7 +201,7 @@ function AttachmentRow(props: AttachmentRow.Props) {
const url = URL.createObjectURL(data); const url = URL.createObjectURL(data);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = data.name; a.download = props.attachment.filename; // TODO Use `data.name` after Content-Disposition parser is implemented
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [props.attachment.attachmentId, props.attachment.pieceId]); }, [props.attachment.attachmentId, props.attachment.pieceId]);

View File

@@ -1,3 +1,5 @@
import tailwindcssAnimate from "tailwindcss-animate";
/** @type {import("tailwindcss").Config} */ /** @type {import("tailwindcss").Config} */
export default { export default {
darkMode: ["class"], darkMode: ["class"],
@@ -19,5 +21,5 @@ export default {
colors: {}, colors: {},
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [tailwindcssAnimate],
}; };

View File

@@ -10,4 +10,7 @@ export default defineConfig({
"common": path.resolve(__dirname, "../common/src"), "common": path.resolve(__dirname, "../common/src"),
}, },
}, },
build: {
outDir: "build",
},
}); });

30
pnpm-lock.yaml generated
View File

@@ -12,6 +12,9 @@ catalogs:
'@elysiajs/eden': '@elysiajs/eden':
specifier: ^1.1.3 specifier: ^1.1.3
version: 1.1.3 version: 1.1.3
'@elysiajs/static':
specifier: ^1.1.1
version: 1.1.1
'@elysiajs/swagger': '@elysiajs/swagger':
specifier: ^1.1.6 specifier: ^1.1.6
version: 1.1.6 version: 1.1.6
@@ -107,6 +110,9 @@ importers:
'@elysiajs/cors': '@elysiajs/cors':
specifier: 'catalog:' specifier: 'catalog:'
version: 1.1.1(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2)) 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': '@elysiajs/swagger':
specifier: 'catalog:' specifier: 'catalog:'
version: 1.1.6(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2)) 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: peerDependencies:
elysia: '>= 1.1.0' 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': '@elysiajs/swagger@1.1.6':
resolution: {integrity: sha512-B1airTG3eh6eFgFxGS2UtsdZ7Xc2vrn3YKIFLFai9YeZVROSHmi3ZaXZvGAn3DnkXHT6I+qx960xnrqoNiopUw==} resolution: {integrity: sha512-B1airTG3eh6eFgFxGS2UtsdZ7Xc2vrn3YKIFLFai9YeZVROSHmi3ZaXZvGAn3DnkXHT6I+qx960xnrqoNiopUw==}
peerDependencies: peerDependencies:
@@ -1108,6 +1119,10 @@ packages:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
clsx@2.1.1: clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1618,6 +1633,10 @@ packages:
resolution: {integrity: sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==} resolution: {integrity: sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==}
engines: {node: '>=10'} engines: {node: '>=10'}
node-cache@5.1.2:
resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==}
engines: {node: '>= 8.0.0'}
node-gyp@9.4.1: node-gyp@9.4.1:
resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==}
engines: {node: ^12.13 || ^14.13 || >=16} engines: {node: ^12.13 || ^14.13 || >=16}
@@ -2287,6 +2306,11 @@ snapshots:
dependencies: dependencies:
elysia: 1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2) 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))': '@elysiajs/swagger@1.1.6(elysia@1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2))':
dependencies: dependencies:
'@scalar/types': 0.0.12 '@scalar/types': 0.0.12
@@ -3016,6 +3040,8 @@ snapshots:
clean-stack@2.2.0: clean-stack@2.2.0:
optional: true optional: true
clone@2.1.2: {}
clsx@2.1.1: {} clsx@2.1.1: {}
color-convert@2.0.1: color-convert@2.0.1:
@@ -3564,6 +3590,10 @@ snapshots:
semver: 7.6.3 semver: 7.6.3
optional: true optional: true
node-cache@5.1.2:
dependencies:
clone: 2.1.2
node-gyp@9.4.1: node-gyp@9.4.1:
dependencies: dependencies:
env-paths: 2.2.1 env-paths: 2.2.1

View File

@@ -4,6 +4,7 @@ packages:
catalog: catalog:
'@elysiajs/cors': '^1.1.1' '@elysiajs/cors': '^1.1.1'
'@elysiajs/eden': '^1.1.3' '@elysiajs/eden': '^1.1.3'
'@elysiajs/static': '^1.1.1'
'@elysiajs/swagger': '^1.1.6' '@elysiajs/swagger': '^1.1.6'
'@radix-ui/react-dialog': '^1.1.2' '@radix-ui/react-dialog': '^1.1.2'
'@radix-ui/react-dropdown-menu': '^2.1.2' '@radix-ui/react-dropdown-menu': '^2.1.2'