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;