Docker support, backend SPA mode
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
build
|
||||||
|
db.sqlite3
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
|
build
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
storage
|
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
|||||||
22
Dockerfile
Normal file
22
Dockerfile
Normal 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"]
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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:",
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
30
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user