Backend: DB schema, login API

This commit is contained in:
2024-08-03 15:29:29 +02:00
parent 777038e0b4
commit 903168a565
6 changed files with 288 additions and 33 deletions

View File

@@ -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 });
}
}
}

View File

@@ -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 = <A, I, R>(schema: S.Schema<A, I, R>) => {
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<Buffer>((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)),
);

View File

@@ -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<RequestInterface>({
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<unknown, SyntaxError>,
}),
});
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();
});

View File

@@ -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<User, NoSuchElementException>;
readonly createSession: (userId: UserId) => Effect.Effect<SessionId>;
}
export class Database extends Context.Tag("Database")<Database, DatabaseInterface>() { }
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<DatabaseInterface>({
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;
}),
});
}),
));

View File

@@ -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<string>;
readonly json: Effect.Effect<unknown, SyntaxError>;
}
export class Request extends Context.Tag("Request")<Request, RequestInterface>() { }