diff --git a/packages/backend/src/RequestError.ts b/packages/backend/src/RequestError.ts new file mode 100644 index 0000000..661b72e --- /dev/null +++ b/packages/backend/src/RequestError.ts @@ -0,0 +1,21 @@ +import { Data } from "effect"; + +export class RequestError extends Data.TaggedError("RequestError")<{ + readonly status: number, + readonly body?: string, +}> { + get response(): Response { + if (this.body) { + const body = new TextEncoder().encode(this.body); + return new Response(body, { + headers: { + "Content-Length": body.byteLength.toString(), + "Content-Type": "text/plain; charset=utf-8", + }, + status: this.status, + }); + } else { + return new Response(null, { status: this.status }); + } + } +} diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 10e509e..6bd9c8f 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,6 +1,10 @@ -import { Effect } from "effect"; +import { Schema as S } from "@effect/schema"; +import { DateTime, Duration, Effect, Option as O, pipe } from "effect"; +import { RequestError } from "./RequestError"; +import { Database } from "./services/database"; import { Request } from "./services/request"; -import { Storage } from "./services/storage"; +import { brotliCompress } from "node:zlib"; +import { AccessLog } from "common"; const match = (method: string, ...pattern: readonly string[]) => Effect.gen(function* () { @@ -11,19 +15,132 @@ const match = (method: string, ...pattern: readonly string[]) => Effect.gen(func && pattern.every((x, i) => x === "*" || x === req.path[i]); }); -export const app = Effect.gen(function* () { +const WEIGHTED_TOKEN_REGEX = /^([!#$%'*+.^_`|~a-z0-9-])\s*(?:;\s*q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/i; +const acceptsBrotli = Effect.gen(function* () { const req = yield* Request; - const storage = yield* Storage; - if (yield* match("GET", "ping")) { - return new Response("pong", { - headers: { - "Content-Length": "4", - "Content-Type": "text/plain;charset=utf-8", - }, - }); + const acceptEncoding = req.headers["accept-encoding"]; + + /* NOTE According to RFC9110, section 12.5.3: + * + * If no Accept-Encoding header field is in the request, any content coding + * is considered acceptable by the user agent. + * + * This means that we could encode the response as brotli in the absence of + * Accept-Encoding header, but we'd rather be nice and assume the user agent + * does not support any encoding. + */ + if (!acceptEncoding) { + return false; } - return new Response(null, { status: 404 }); + const codings = acceptEncoding + .split(/\s*,\s*/) + .map((token) => token.match(WEIGHTED_TOKEN_REGEX)) + .filter((match) => match !== null) + .map((match) => Object.freeze({ + coding: match[1], + ...(match[2] ? { quality: Number.parseFloat(match[2]) } : undefined), + })); + + return codings.some(({ coding, quality = 1 }) => coding === "br" && quality > 0); }); + +const requestJson = (schema: S.Schema) => { + const decoder = S.decodeUnknown(schema); + return pipe( + Request, + Effect.tap((req) => req.headers["content-type"] === "application/json" + ? Effect.void + : new RequestError({ status: 415 }) + ), + Effect.flatMap((req) => req.json), + Effect.flatMap(decoder), + Effect.mapError((error) => new RequestError({ status: 400, body: error.message })) + ); +}; + +export const app = pipe( + Effect.gen(function* () { + + const db = yield* Database; + const req = yield* Request; + + const timestamp = yield* DateTime.now; + + const accessLog = AccessLog.make({ + timestamp, + requestId: req.requestId, + method: req.method, + pathname: req.pathname, + query: req.query, + ip: O.getOrNull(req.ip), + }); + + console.log(JSON.stringify(accessLog)); + + if (yield* match("POST", "login")) { + + const body = yield* requestJson(S.Struct({ + username: S.NonEmptyString, + password: S.NonEmptyString, + })); + + const user = yield* pipe( + db.getUserByUsername(body.username), + Effect.catchTag("NoSuchElementException", () => new RequestError({ status: 404, body: "Invalid username or password" })), + ); + + const valid = yield* Effect.promise(() => Bun.password.verify(body.password, user.password)); + if (!valid) { + return yield* new RequestError({ status: 404, body: "Invalid username or password" }); + } + + const sessionId = yield* db.createSession(user.userId); + + const responseData = { + userId: user.userId, + username: user.username, + admin: user.admin, + }; + const responseJson = JSON.stringify(responseData); + const responseArray = new TextEncoder().encode(responseJson); + + const expiresAt = DateTime.addDuration(yield* DateTime.now, Duration.days(7)); + const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; Secure`; + + if (yield* acceptsBrotli) { + const responseBrotli = yield* Effect.async((callback) => { + brotliCompress(responseArray, (error, result) => { + if (error !== null) { + callback(Effect.die(error)); + } else { + callback(Effect.succeed(result)); + } + }); + }); + return new Response(responseBrotli, { + headers: { + "Content-Encoding": "br", + "Content-Length": responseArray.byteLength.toString(), + "Content-Type": "application/json", + "Set-Cookie": setCookie, + }, + }); + } else { + return new Response(responseArray, { + headers: { + "Content-Length": responseArray.byteLength.toString(), + "Content-Type": "application/json", + "Set-Cookie": setCookie, + } + }); + } + } + + return new Response(null, { status: 404 }); + }), + Effect.catchAllDefect(() => new RequestError({ status: 500 })), + Effect.catchTag("RequestError", (error) => Effect.succeed(error.response)), +); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index d2835da..cf2fe24 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,11 +1,24 @@ -import { AccessLog, RequestId } from "common"; -import { DateTime, Effect, Exit, Option as O, pipe } from "effect"; +import { RequestId } from "common"; +import { Effect, Exit, Layer, Option as O, pipe, Scope, unsafeCoerce } from "effect"; import { ulid } from "ulid"; import { app } from "./app"; +import { DatabaseLive } from "./services/database"; import { Request, RequestInterface } from "./services/request"; import { StorageFilesystem } from "./services/storage"; -const storage = StorageFilesystem("storage"); +const scope = Effect.runSync(Scope.make()); + +const layers = pipe( + Layer.merge( + StorageFilesystem("storage"), + pipe( + DatabaseLive(), + Layer.provide(Layer.succeed(Scope.Scope, scope)), + ), + ), + Layer.memoize, + Effect.provideService(Scope.Scope, scope), +); const server = Bun.serve({ fetch: (request, server) => { @@ -21,32 +34,25 @@ const server = Bun.serve({ const query = Object.freeze(Object.fromEntries(url.searchParams.entries())); const requestId = RequestId.make(ulid()); - const timestamp = Effect.runSync(DateTime.now); - - const accessLog = AccessLog.make({ - timestamp, - requestId, - method: request.method, - pathname: url.pathname, - query, - ip: O.getOrNull(ip), - }); - - console.log(JSON.stringify(accessLog)); - const requestInterface = Object.freeze({ requestId, method: request.method, + pathname: url.pathname, path: Object.freeze(url.pathname.slice(1).split("/")), query, headers: Object.freeze(Object.fromEntries(request.headers.entries())), ip, + + json: Effect.tryPromise({ + try: () => request.json(), + catch: unsafeCoerce, + }), }); const fiber = pipe( - app, + layers, + Effect.flatMap((layers) => Effect.provide(app, layers)), Effect.provideService(Request, requestInterface), - Effect.provide(storage), Effect.runFork, ); @@ -63,5 +69,6 @@ const server = Bun.serve({ }); process.on("SIGINT", () => { + Effect.withFiberRuntime((fiber) => Scope.close(scope, Exit.interrupt(fiber.id()))); server.stop(); }); diff --git a/packages/backend/src/services/database.ts b/packages/backend/src/services/database.ts new file mode 100644 index 0000000..ca385b4 --- /dev/null +++ b/packages/backend/src/services/database.ts @@ -0,0 +1,96 @@ +import { Schema as S } from "@effect/schema"; +import { Database as SqliteDatabase } from "bun:sqlite"; +import { SessionId, User, UserId } from "common"; +import { Context, Effect, Layer, pipe } from "effect"; +import { NoSuchElementException } from "effect/Cause"; + +export function generateSessionId(byteLength: number = 12): SessionId { + const array = new Uint8Array(byteLength); + crypto.getRandomValues(array); + const string = Buffer.from(array).toString("base64url"); + return SessionId.make(string); +} + +export interface DatabaseInterface { + readonly getUserByUsername: (username: string) => Effect.Effect; + readonly createSession: (userId: UserId) => Effect.Effect; +} + +export class Database extends Context.Tag("Database")() { } + +export const DatabaseLive = (filePath: string = "db.sqlite3") => Layer.effect(Database, pipe( + Effect.acquireRelease( + Effect.sync(() => new SqliteDatabase(filePath, { create: true, readwrite: true, strict: true })), + (db) => Effect.sync(() => db.close()), + ), + Effect.tap((db) => Effect.sync(() => { + const systemInformation = ` + createdBy TEXT REFERENCES User (userId) ON DELETE SET NULL ON UPDATE CASCADE, + createdAt TEXT NOT NULL, + modifiedBy TEXT REFERENCES User (userId) ON DELETE SET NULL ON UPDATE CASCADE, + modifiedAt TEXT NUL NULL + `; + + db.exec("PRAGMA foreign_keys = ON"); + + db.exec(`CREATE TABLE IF NOT EXISTS AccessLog ( + timestamp TEXT NOT NULL, + requestId TEXT NOT NULL, + method TEXT NOT NULL, + pathname TEXT NOT NULL, + query TEXT NOT NULL, + ip TEXT + )`); + + db.exec(`CREATE TABLE IF NOT EXISTS User ( + userId TEXT NOT NULL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + admin BOOLEAN NOT NULL + )`); + + db.exec(`CREATE TABLE IF NOT EXISTS Piece ( + pieceId TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + composer TEXT, + lyricist TEXT, + arranger TEXT, + ${systemInformation} + )`); + + db.exec(`CREATE TABLE IF NOT EXISTS Session ( + sessionId TEXT NOT NULL PRIMARY KEY, + userId TEXT NOT NULL REFERENCES User (userId) ON DELETE CASCADE ON UPDATE CASCADE, + expiresAt TEXT NOT NULL, + )`); + + db.exec(`CREATE TABLE IF NOT EXISTS Attachment ( + attachmentId TEXT NOT NULL PRIMARY KEY, + pieceId TEXT NOT NULL REFERENCES Piece (pieceId) ON DELETE CASCADE ON UPDATE CASCADE, + sha256 BLOB NOT NULL, + filename TEXT NOT NULL, + mediaType TEXT NOT NULL, + ${systemInformation} + )`); + })), + Effect.map((db) => { + const getUserByUsername = db.prepare("SELECT userId, username, password, admin FROM User WHERE username = ?"); + const cleanupSessions = db.prepare("DELETE FROM Session WHERE expiresAt >= datetime()"); + const createSession = db.prepare("INSERT INTO Session (sessionId, userId, expiresAt) VALUES (?, ?, datetime('now', '+7 days'))"); + const updateSession = db.prepare("UPDATE Session SET expiresAt = datetime('now', '+7 days') WHERE sessionId = ?"); + + return Object.freeze({ + getUserByUsername: (username) => Effect.suspend(() => { + const res = getUserByUsername.get(username); + if (res === null) return Effect.fail(new NoSuchElementException()); + return Effect.succeed(S.decodeUnknownSync(User)(res)); + }), + + createSession: (userId) => Effect.sync(() => { + const sessionId = generateSessionId(); + createSession.run(sessionId, userId); + return sessionId; + }), + }); + }), +)); diff --git a/packages/backend/src/services/request.ts b/packages/backend/src/services/request.ts index 3513a88..38d84d5 100644 --- a/packages/backend/src/services/request.ts +++ b/packages/backend/src/services/request.ts @@ -1,13 +1,16 @@ import { RequestId } from "common"; -import { Context, Option as O } from "effect"; +import { Context, Effect, Option as O } from "effect"; export interface RequestInterface { readonly requestId: RequestId; readonly method: string; + readonly pathname: string; readonly path: readonly string[]; readonly query: { readonly [_: string]: string }; readonly headers: { readonly [_: string]: string }; readonly ip: O.Option; + + readonly json: Effect.Effect; } export class Request extends Context.Tag("Request")() { } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index d61ef18..4d10e84 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -4,11 +4,13 @@ import { Brand as B, pipe } from "effect"; export const AttachmentId = pipe(S.ULID, S.brand("AttachmentId")); export const PieceId = pipe(S.ULID, S.brand("PieceId")); export const RequestId = pipe(S.ULID, S.brand("RequestId")); +export const SessionId = pipe(S.NonEmptyString, S.brand("SessionId")); export const UserId = pipe(S.ULID, S.brand("UserId")); export type AttachmentId = typeof AttachmentId.Type; export type PieceId = typeof PieceId.Type; export type RequestId = typeof RequestId.Type; +export type SessionId = typeof SessionId.Type; export type UserId = typeof UserId.Type; export type Sha256 = B.Branded; @@ -31,14 +33,16 @@ export class BooleanFromNumber extends S.transform( ).annotations({ identifier: "BooleanFromNumber" }) { } export const SystemInformation = S.Struct({ - createdBy: UserId, + createdBy: S.Union(UserId, S.Null), createdAt: S.DateTimeUtc, - modifiedBy: UserId, + modifiedBy: S.Union(UserId, S.Null), modifiedAt: S.DateTimeUtc, }); export type SystemInformation = typeof SystemInformation.Type; +// --- TABLES ------------------------------------------------------------------ + export const AccessLog = S.Struct({ timestamp: S.DateTimeUtc, requestId: RequestId, @@ -73,6 +77,12 @@ export const Piece = pipe( S.extend(SystemInformation), ); +export const Session = S.Struct({ + sessionId: SessionId, + userId: UserId, + expiresAt: S.DateTimeUtc, +}); + export const User = S.Struct({ userId: UserId, username: S.NonEmptyString, @@ -83,4 +93,5 @@ export const User = S.Struct({ export type AccessLog = typeof AccessLog.Type; export type Attachment = typeof Attachment.Type; export type Piece = typeof Piece.Type; +export type Session = typeof Session.Type; export type User = typeof User.Type;