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

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