Backend: DB schema, login API
This commit is contained in:
21
packages/backend/src/RequestError.ts
Normal file
21
packages/backend/src/RequestError.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { 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* () {
|
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]);
|
&& 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 req = yield* Request;
|
||||||
const storage = yield* Storage;
|
|
||||||
|
|
||||||
if (yield* match("GET", "ping")) {
|
const acceptEncoding = req.headers["accept-encoding"];
|
||||||
return new Response("pong", {
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {
|
headers: {
|
||||||
"Content-Length": "4",
|
"Content-Encoding": "br",
|
||||||
"Content-Type": "text/plain;charset=utf-8",
|
"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 });
|
return new Response(null, { status: 404 });
|
||||||
});
|
}),
|
||||||
|
Effect.catchAllDefect(() => new RequestError({ status: 500 })),
|
||||||
|
Effect.catchTag("RequestError", (error) => Effect.succeed(error.response)),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { AccessLog, RequestId } from "common";
|
import { RequestId } from "common";
|
||||||
import { DateTime, Effect, Exit, Option as O, pipe } from "effect";
|
import { Effect, Exit, Layer, Option as O, pipe, Scope, unsafeCoerce } from "effect";
|
||||||
import { ulid } from "ulid";
|
import { ulid } from "ulid";
|
||||||
import { app } from "./app";
|
import { app } from "./app";
|
||||||
|
import { DatabaseLive } from "./services/database";
|
||||||
import { Request, RequestInterface } from "./services/request";
|
import { Request, RequestInterface } from "./services/request";
|
||||||
import { StorageFilesystem } from "./services/storage";
|
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({
|
const server = Bun.serve({
|
||||||
fetch: (request, server) => {
|
fetch: (request, server) => {
|
||||||
@@ -21,32 +34,25 @@ const server = Bun.serve({
|
|||||||
const query = Object.freeze(Object.fromEntries(url.searchParams.entries()));
|
const query = Object.freeze(Object.fromEntries(url.searchParams.entries()));
|
||||||
|
|
||||||
const requestId = RequestId.make(ulid());
|
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>({
|
const requestInterface = Object.freeze<RequestInterface>({
|
||||||
requestId,
|
requestId,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
|
pathname: url.pathname,
|
||||||
path: Object.freeze(url.pathname.slice(1).split("/")),
|
path: Object.freeze(url.pathname.slice(1).split("/")),
|
||||||
query,
|
query,
|
||||||
headers: Object.freeze(Object.fromEntries(request.headers.entries())),
|
headers: Object.freeze(Object.fromEntries(request.headers.entries())),
|
||||||
ip,
|
ip,
|
||||||
|
|
||||||
|
json: Effect.tryPromise({
|
||||||
|
try: () => request.json(),
|
||||||
|
catch: unsafeCoerce<unknown, SyntaxError>,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const fiber = pipe(
|
const fiber = pipe(
|
||||||
app,
|
layers,
|
||||||
|
Effect.flatMap((layers) => Effect.provide(app, layers)),
|
||||||
Effect.provideService(Request, requestInterface),
|
Effect.provideService(Request, requestInterface),
|
||||||
Effect.provide(storage),
|
|
||||||
Effect.runFork,
|
Effect.runFork,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,5 +69,6 @@ const server = Bun.serve({
|
|||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
|
Effect.withFiberRuntime((fiber) => Scope.close(scope, Exit.interrupt(fiber.id())));
|
||||||
server.stop();
|
server.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
96
packages/backend/src/services/database.ts
Normal file
96
packages/backend/src/services/database.ts
Normal 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;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
));
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { RequestId } from "common";
|
import { RequestId } from "common";
|
||||||
import { Context, Option as O } from "effect";
|
import { Context, Effect, Option as O } from "effect";
|
||||||
|
|
||||||
export interface RequestInterface {
|
export interface RequestInterface {
|
||||||
readonly requestId: RequestId;
|
readonly requestId: RequestId;
|
||||||
readonly method: string;
|
readonly method: string;
|
||||||
|
readonly pathname: string;
|
||||||
readonly path: readonly string[];
|
readonly path: readonly string[];
|
||||||
readonly query: { readonly [_: string]: string };
|
readonly query: { readonly [_: string]: string };
|
||||||
readonly headers: { readonly [_: string]: string };
|
readonly headers: { readonly [_: string]: string };
|
||||||
readonly ip: O.Option<string>;
|
readonly ip: O.Option<string>;
|
||||||
|
|
||||||
|
readonly json: Effect.Effect<unknown, SyntaxError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Request extends Context.Tag("Request")<Request, RequestInterface>() { }
|
export class Request extends Context.Tag("Request")<Request, RequestInterface>() { }
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { Brand as B, pipe } from "effect";
|
|||||||
export const AttachmentId = pipe(S.ULID, S.brand("AttachmentId"));
|
export const AttachmentId = pipe(S.ULID, S.brand("AttachmentId"));
|
||||||
export const PieceId = pipe(S.ULID, S.brand("PieceId"));
|
export const PieceId = pipe(S.ULID, S.brand("PieceId"));
|
||||||
export const RequestId = pipe(S.ULID, S.brand("RequestId"));
|
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 const UserId = pipe(S.ULID, S.brand("UserId"));
|
||||||
|
|
||||||
export type AttachmentId = typeof AttachmentId.Type;
|
export type AttachmentId = typeof AttachmentId.Type;
|
||||||
export type PieceId = typeof PieceId.Type;
|
export type PieceId = typeof PieceId.Type;
|
||||||
export type RequestId = typeof RequestId.Type;
|
export type RequestId = typeof RequestId.Type;
|
||||||
|
export type SessionId = typeof SessionId.Type;
|
||||||
export type UserId = typeof UserId.Type;
|
export type UserId = typeof UserId.Type;
|
||||||
|
|
||||||
export type Sha256 = B.Branded<Uint8Array, "Sha256">;
|
export type Sha256 = B.Branded<Uint8Array, "Sha256">;
|
||||||
@@ -31,14 +33,16 @@ export class BooleanFromNumber extends S.transform(
|
|||||||
).annotations({ identifier: "BooleanFromNumber" }) { }
|
).annotations({ identifier: "BooleanFromNumber" }) { }
|
||||||
|
|
||||||
export const SystemInformation = S.Struct({
|
export const SystemInformation = S.Struct({
|
||||||
createdBy: UserId,
|
createdBy: S.Union(UserId, S.Null),
|
||||||
createdAt: S.DateTimeUtc,
|
createdAt: S.DateTimeUtc,
|
||||||
modifiedBy: UserId,
|
modifiedBy: S.Union(UserId, S.Null),
|
||||||
modifiedAt: S.DateTimeUtc,
|
modifiedAt: S.DateTimeUtc,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SystemInformation = typeof SystemInformation.Type;
|
export type SystemInformation = typeof SystemInformation.Type;
|
||||||
|
|
||||||
|
// --- TABLES ------------------------------------------------------------------
|
||||||
|
|
||||||
export const AccessLog = S.Struct({
|
export const AccessLog = S.Struct({
|
||||||
timestamp: S.DateTimeUtc,
|
timestamp: S.DateTimeUtc,
|
||||||
requestId: RequestId,
|
requestId: RequestId,
|
||||||
@@ -73,6 +77,12 @@ export const Piece = pipe(
|
|||||||
S.extend(SystemInformation),
|
S.extend(SystemInformation),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const Session = S.Struct({
|
||||||
|
sessionId: SessionId,
|
||||||
|
userId: UserId,
|
||||||
|
expiresAt: S.DateTimeUtc,
|
||||||
|
});
|
||||||
|
|
||||||
export const User = S.Struct({
|
export const User = S.Struct({
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
username: S.NonEmptyString,
|
username: S.NonEmptyString,
|
||||||
@@ -83,4 +93,5 @@ export const User = S.Struct({
|
|||||||
export type AccessLog = typeof AccessLog.Type;
|
export type AccessLog = typeof AccessLog.Type;
|
||||||
export type Attachment = typeof Attachment.Type;
|
export type Attachment = typeof Attachment.Type;
|
||||||
export type Piece = typeof Piece.Type;
|
export type Piece = typeof Piece.Type;
|
||||||
|
export type Session = typeof Session.Type;
|
||||||
export type User = typeof User.Type;
|
export type User = typeof User.Type;
|
||||||
|
|||||||
Reference in New Issue
Block a user