diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 7dcdd4e..c892584 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,7 +1,7 @@ import cors from "@elysiajs/cors"; -import { PieceId, RequestId, SessionId } from "common"; +import { AttachmentId, PieceId, RequestId, SessionId, Sha256 } from "common"; import * as Function from "common/Function"; -import { Elysia, error, t } from "elysia"; +import { Elysia, error, form, t } from "elysia"; import { sql } from "kysely"; import { generateSessionId, initDatabase } from "./database"; @@ -10,8 +10,11 @@ const tbranded = () => t.Transform(t.String()) .Encode(Function.unsafeCoerce); const app = new Elysia() + .use(cors({ origin: "localhost:5173" })) + .decorate("db", await initDatabase()) + .resolve(async ({ db, cookie }) => { await db .deleteFrom("Session") @@ -46,6 +49,7 @@ const app = new Elysia() return { user }; }) + .onTransform(async ({ db, request, server }) => { const requestId = RequestId(Bun.randomUUIDv7("hex")); @@ -63,6 +67,9 @@ const app = new Elysia() console.log(`${timestamp} ${method} ${request.url} ${ip}`); }) + + // --- AUTHENTICATION ------------------------------------------------------ + .get("/me", async ({ user }) => { if (user === null) { @@ -75,6 +82,7 @@ const app = new Elysia() admin: user.admin !== 0, }; }) + .post("/login", async ({ db, body: { username, password }, cookie }) => { const user = await db @@ -118,6 +126,7 @@ const app = new Elysia() password: t.String({ minLength: 1 }), }), }) + .post("/logout", async ({ db, cookie, set }) => { set.status = "No Content"; @@ -135,10 +144,13 @@ const app = new Elysia() .where("sessionId", "=", SessionId(sessionId)) .execute(); }) + + // --- PIECE CRUD ---------------------------------------------------------- + .post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => { if (user === null) { - return error(401); + return error("Unauthorized"); } const pieceId = PieceId(Bun.randomUUIDv7()); @@ -158,10 +170,11 @@ const app = new Elysia() arranger: t.Nullable(t.String({ minLength: 1 })), }), }) + .get("/piece", async ({ db, user }) => { if (user === null) { - return error(401); + return error("Unauthorized"); } const res = await db @@ -172,10 +185,11 @@ const app = new Elysia() return res; }) + .put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => { if (user === null) { - return error(401); + return error("Unauthorized"); } const res = await db @@ -189,7 +203,7 @@ const app = new Elysia() return error("Not Found"); } - return res; + return res[0]; }, { body: t.Object({ name: t.String({ minLength: 1 }), @@ -201,10 +215,11 @@ const app = new Elysia() pieceId: tbranded(), }) }) + .delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => { if (user === null) { - return error(401); + return error("Unauthorized"); } const res = await db @@ -223,6 +238,164 @@ const app = new Elysia() pieceId: tbranded(), }), }) + + // --- 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") + .ignore() + .values({ sha256, data: dataArray }) + .execute(); + + const res = await db + .insertInto("Attachment") + .values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: user.userId, createdAt: sql`datetime()` }) + .returningAll() + .executeTakeFirstOrThrow(); + + return res; + }, { + body: t.Object({ + filename: t.String({ minLength: 1 }), + mediaType: t.String({ minLength: 1 }), + data: t.File(), + }), + params: t.Object({ + pieceId: tbranded(), + }), + }) + + .get("piece/:pieceId/attachment", async ({ db, params: { pieceId }, user }) => { + + if (user === null) { + return error("Unauthorized"); + } + + const res = await db + .selectFrom("Attachment") + .selectAll() + .where("pieceId", "=", pieceId) + .orderBy("filename") + .execute(); + + return res; + }, { + 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 }) => { + + 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"); + } + + return form({ + filename: res.filename, + mediaType: res.mediaType, + data: 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(), + }), + }) + + // ------------------------------------------------------------------------- + .listen(process.env.PORT || 3000); export type App = typeof app; diff --git a/packages/backend/src/database.ts b/packages/backend/src/database.ts index 9d5ff02..e81aad4 100644 --- a/packages/backend/src/database.ts +++ b/packages/backend/src/database.ts @@ -165,6 +165,13 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise eb.fn.countAll().as("count")) diff --git a/packages/frontend/src/routes/Home.tsx b/packages/frontend/src/routes/Home.tsx index 00eabe3..d7e2f45 100644 --- a/packages/frontend/src/routes/Home.tsx +++ b/packages/frontend/src/routes/Home.tsx @@ -1,8 +1,9 @@ -import { useEffect } from "react"; +import { FormEventHandler, useEffect, useId, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { client } from "../client"; -import { useStore } from "../store"; +import { Store, useStore } from "../store"; import { Button } from "../styled/Button"; +import { Input } from "../styled/Input"; export function Home() { @@ -11,6 +12,20 @@ export function Home() { const user = useStore(state => state.user); const setUser = useStore(state => state.setUser); + const pieces = useStore(state => state.pieces); + const setPieces = useStore(state => state.setPieces); + + const loadPieces = async () => { + const { data, error } = await client.piece.get(); + + if (error !== null) { + console.error(error.value); + return; + } + + setPieces(data); + }; + const init = async () => { if (user !== null) return; @@ -22,6 +37,7 @@ export function Home() { } setUser(data); + await loadPieces(); }; useEffect(() => { @@ -59,6 +75,110 @@ export function Home() { +
+ + + + + + + + + + + {(Object.values(pieces) as Store.Piece[]).map((piece) => ( + + + + + + + ))} + +
TytułKompozytorSłowaOpracowanie
{piece.name}{piece.composer}{piece.lyricist}{piece.arranger}
+
+
+ +
); } + +function PieceForm() { + + const [name, setName] = useState(""); + const [composer, setComposer] = useState(""); + const [lyricist, setLyricist] = useState(""); + const [arranger, setArranger] = useState(""); + + const nameId = useId(); + const composerId = useId(); + const lyricistId = useId(); + const arrangerId = useId(); + + const addPiece = useStore(state => state.setPiece); + + const autoFocusRef = useRef(null); + + const onSubmit: FormEventHandler = async (e) => { + e.preventDefault(); + + const { data, error } = await client.piece.post({ + name, + composer: composer.length > 0 ? composer : null, + lyricist: lyricist.length > 0 ? lyricist : null, + arranger: arranger.length > 0 ? arranger : null, + }); + + if (error) { + console.error(error.value); + return; + } + + setName(""); + setComposer(""); + setLyricist(""); + setArranger(""); + + addPiece(data); + autoFocusRef.current?.focus(); + } + + return ( +
+ + setName(e.target.value)} + /> + + setComposer(e.target.value)} + /> + + setLyricist(e.target.value)} + /> + + setArranger(e.target.value)} + /> + +
+ ); +} diff --git a/packages/frontend/src/routes/Login.tsx b/packages/frontend/src/routes/Login.tsx index 616fd3d..234206f 100644 --- a/packages/frontend/src/routes/Login.tsx +++ b/packages/frontend/src/routes/Login.tsx @@ -51,7 +51,7 @@ export function Login() { value={loginUsername} autoFocus required - onInput={(e) => setLoginUsername(e.currentTarget.value)} + onChange={(e) => setLoginUsername(e.target.value)} /> setLoginPassword(e.currentTarget.value)} + onChange={(e) => setLoginPassword(e.target.value)} />