Port to elysia, tailwind (no effect)

This commit is contained in:
2024-11-17 23:08:23 +01:00
parent e8aaa328e6
commit 63de1a3b02
53 changed files with 1942 additions and 2363 deletions

View File

@@ -1,21 +0,0 @@
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,292 +1,228 @@
import { Schema as S } from "@effect/schema";
import { SessionId } from "common";
import { CreatePiece, GetPieces, UpdatePiece, DeletePiece, Login, Logout, Me } from "common/api";
import { AccessLog, SessionData } from "common/db";
import { DateTime, Duration, Either as E, Effect, Match, Option as O, pipe } from "effect";
import { constant } from "effect/Function";
import { Api } from "make-api";
import { brotliCompress } from "node:zlib";
import { RequestError } from "./RequestError";
import { Database } from "./services/database";
import { Request } from "./services/request";
import cors from "@elysiajs/cors";
import { PieceId, RequestId, SessionId } from "common";
import * as Function from "common/Function";
import { Elysia, error, t } from "elysia";
import { sql } from "kysely";
import { generateSessionId, initDatabase } from "./database";
const match = (api: Api.Api.Any) => Effect.gen(function* () {
const tbranded = <T>() => t.Transform(t.String())
.Decode(Function.unsafeCoerce<string, T>)
.Encode(Function.unsafeCoerce<T, string>);
const req = yield* Request;
const app = new Elysia()
.use(cors({ origin: "localhost:5173" }))
.decorate("db", await initDatabase())
.resolve(async ({ db, cookie }) => {
await db
.deleteFrom("Session")
.where(sql`datetime()`, ">=", "expiresAt")
.execute();
return req.method === api.method
&& req.path.length === api.props.route.length
&& api.props.route.every((token, i) => pipe(
Match.value(token),
Match.tags({
Literal: ({ literal }) => req.path[i] === literal,
Param: ({ schema }) => E.isRight(S.decodeUnknownEither(schema)(req.path[i])),
}),
Match.exhaustive,
));
});
const COOKIE_REGEX = /^([!#$%'*+.^_`|~a-z0-9-]+)\s*=\s*([!#$%&'()*+./:<=>?@[\]^_`{|}~a-z0-9-]+)/i;
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 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;
}
const codings = acceptEncoding
.split(/\s*,\s*/)
.map((token) => token.match(WEIGHTED_TOKEN_REGEX))
.filter((match) => match !== null)
.map((match) => ({
coding: match[1],
...(match[2] ? { quality: Number.parseFloat(match[2]) } : undefined),
}));
return codings.some(({ coding, quality = 1 }) => coding === "br" && quality > 0);
});
const getCookie = (name: string): Effect.Effect<O.Option<string>, never, Request> => Effect.gen(function* () {
const req = yield* Request;
const cookieString = req.headers["cookie"];
if (!cookieString) {
return O.none();
}
const cookieValue = cookieString
.split(/\s*;\s*/)
.map((cookie) => cookie.match(COOKIE_REGEX))
.filter((match): match is RegExpMatchArray => match !== null && match[1] === name)
.map((match) => match[2])[0];
return O.fromNullable(cookieValue);
});
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),
});
yield* db.createAccessLog(accessLog);
console.log(`${DateTime.toDateUtc(accessLog.timestamp).toISOString()} ${accessLog.requestId} ${accessLog.method} ${accessLog.pathname}?${new URLSearchParams(accessLog.query).toString()} ${accessLog.ip}`);
if (yield* match(CreatePiece)) {
return new Response(null, { status: 501 });
} else if (yield* match(GetPieces)) {
return new Response(null, { status: 501 });
} else if (yield* match(UpdatePiece)) {
return new Response(null, { status: 501 });
} else if (yield* match(DeletePiece)) {
return new Response(null, { status: 501 });
} else if (yield* match(Login)) {
const body = yield* requestJson(Login.props.request.schema);
const user = yield* pipe(
db.getUserByUsername(body.username),
Effect.catchTag("NoSuchElementException", () => new RequestError({ status: 401, body: "Invalid username or password" })),
);
const valid = yield* Effect.promise(() => Bun.password.verify(body.password, user.password));
if (!valid) {
return yield* new RequestError({ status: 401, body: "Invalid username or password" });
}
const sessionData = SessionData.make({ userId: user.userId });
const sessionId = yield* db.createSession(sessionData);
const responseData = Login.props.response[200].schema.make({
userId: user.userId,
username: user.username,
admin: user.admin,
});
const responseJson = yield* pipe(
responseData,
S.encode(Login.props.response[200].schema),
Effect.map(JSON.stringify),
);
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; SameSite=None; 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,
}
});
}
} else if (yield* match(Logout)) {
const sessionId = yield* pipe(
getCookie("sessionId"),
Effect.flatten,
Effect.flatMap(S.decodeUnknown(SessionId)),
Effect.catchAll(constant(Effect.succeed(null))),
);
if (sessionId !== null) {
db.deleteSession(sessionId);
}
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
return new Response(null, {
status: 204,
headers: {
"Set-Cookie": setCookie,
},
});
} else if (yield* match(Me)) {
const sessionId = yield* pipe(
getCookie("sessionId"),
Effect.flatten,
Effect.flatMap(S.decodeUnknown(SessionId)),
Effect.mapError(() => new RequestError({ status: 401, body: "Session invalid or expired" })),
);
const sessionData = yield* pipe(
db.getAndRefreshSessionData(sessionId),
Effect.optionFromOptional,
Effect.map(O.getOrNull),
);
if (sessionData === null) {
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
return new Response(null, {
status: 401,
headers: {
"Set-Cookie": setCookie,
},
});
}
const user = yield* pipe(
db.getUserById(sessionData.userId),
Effect.optionFromOptional,
Effect.map(O.getOrNull),
);
if (user === null) {
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
return new Response(null, {
status: 401,
headers: {
"Set-Cookie": setCookie,
},
});
}
const responseData = Me.props.response[200].schema.make({
userId: user.userId,
username: user.username,
admin: user.admin,
});
const responseJson = yield* pipe(
responseData,
S.encode(Me.props.response[200].schema),
Effect.map(JSON.stringify),
);
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; SameSite=None; 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,
}
});
}
const sessionId = cookie.sessionId.value;
if (sessionId === undefined) {
return { user: null };
}
return new Response(null, { status: 404 });
}),
Effect.catchAllDefect(() => new RequestError({ status: 500 })),
Effect.catchTag("RequestError", (error) => Effect.succeed(error.response)),
);
const res = await db
.updateTable("Session")
.set({ expiresAt: sql`datetime('now', '+7 days') `})
.where("sessionId", "=", SessionId(sessionId))
.returning(["userId"])
.executeTakeFirst();
if (res === undefined) {
return { user: null };
}
const user = await db
.selectFrom("User")
.selectAll()
.where("userId", "=", res.userId)
.executeTakeFirst();
if (user === undefined) {
return { user: null };
}
return { user };
})
.onTransform(async ({ db, request, server }) => {
const requestId = RequestId(Bun.randomUUIDv7("hex"));
const timestamp = new Date().toISOString();
const { method } = request;
const url = new URL(request.url);
const { pathname } = url;
const query = JSON.stringify(Object.fromEntries(url.searchParams.entries()));
const ip = server?.requestIP(request)?.address ?? null;
await db
.insertInto("AccessLog")
.values({ requestId, timestamp, method, pathname, query, ip })
.execute();
console.log(`${timestamp} ${method} ${request.url} ${ip}`);
})
.get("/me", async ({ user }) => {
if (user === null) {
return error("Unauthorized");
}
return {
userId: user.userId,
username: user.username,
admin: user.admin !== 0,
};
})
.post("/login", async ({ db, body: { username, password }, cookie }) => {
const user = await db
.selectFrom("User")
.selectAll()
.where("username", "=", username)
.executeTakeFirst();
if (user === undefined) {
return error("Unauthorized", "Invalid username or password");
}
const valid = await Bun.password.verify(password, user.password);
if (!valid) {
return error("Unauthorized", "Invalid username or password");
}
const sessionId = generateSessionId();
await db
.insertInto("Session")
.values({ sessionId, userId: user.userId, expiresAt: sql`datetime('now', '+7 days')` })
.execute();
const expiresAt = new Date().getTime() + 604800000;
cookie.sessionId.set({
value: sessionId,
expires: new Date(expiresAt),
httpOnly: true,
sameSite: "none",
secure: true,
});
return {
userId: user.userId,
username: user.username,
admin: user.admin !== 0,
};
}, {
body: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 1 }),
}),
})
.post("/logout", async ({ db, cookie, set }) => {
set.status = "No Content";
const sessionCookie = cookie.sessionId;
sessionCookie.remove();
const sessionId = sessionCookie.value;
if (sessionId === undefined) {
return;
}
await db
.deleteFrom("Session")
.where("sessionId", "=", SessionId(sessionId))
.execute();
})
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
if (user === null) {
return error(401);
}
const pieceId = PieceId(Bun.randomUUIDv7());
const res = await db
.insertInto("Piece")
.values({ pieceId, name, composer, lyricist, arranger, createdBy: user.userId, createdAt: sql`datetime()` })
.returningAll()
.executeTakeFirstOrThrow();
return res;
}, {
body: t.Object({
name: t.String({ minLength: 1 }),
composer: t.Nullable(t.String({ minLength: 1 })),
lyricist: t.Nullable(t.String({ minLength: 1 })),
arranger: t.Nullable(t.String({ minLength: 1 })),
}),
})
.get("/piece", async ({ db, user }) => {
if (user === null) {
return error(401);
}
const res = await db
.selectFrom("Piece")
.selectAll()
.orderBy(["name", "composer", "arranger"])
.execute();
return res;
})
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => {
if (user === null) {
return error(401);
}
const res = await db
.updateTable("Piece")
.set({ name, composer, lyricist, arranger, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
.where("pieceId", "=", pieceId)
.returningAll()
.execute();
if (res.length === 0) {
return error("Not Found");
}
return res;
}, {
body: t.Object({
name: t.String({ minLength: 1 }),
composer: t.Nullable(t.String({ minLength: 1 })),
lyricist: t.Nullable(t.String({ minLength: 1 })),
arranger: t.Nullable(t.String({ minLength: 1 })),
}),
params: t.Object({
pieceId: tbranded<PieceId>(),
})
})
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => {
if (user === null) {
return error(401);
}
const res = await db
.deleteFrom("Piece")
.where("pieceId", "=", pieceId)
.returningAll()
.execute();
if (res.length === 0) {
return error("Not Found");
}
set.status = "No Content";
}, {
params: t.Object({
pieceId: tbranded<PieceId>(),
}),
})
.listen(process.env.PORT || 3000);
export type App = typeof app;

View File

@@ -0,0 +1,162 @@
import { AttachmentId, PieceId, RequestId, SessionId, Sha256, UserId } from "common";
import { ColumnType, CompiledQuery, CreateTableBuilder, Insertable, Kysely, Selectable, sql, Updateable } from "kysely";
import { BunSqliteDialect } from "kysely-bun-sqlite";
import { Database as BunSqliteDatabase } from "bun:sqlite";
export function generateSessionId(byteLength: number = 32): SessionId {
const array = new Uint8Array(byteLength);
crypto.getRandomValues(array);
const string = Buffer.from(array).toString("base64url");
return SessionId(string);
};
export interface Database {
AccessLog: AccessLogTable;
Attachment: AttachmentTable;
File: FileTable;
Piece: PieceTable;
Session: SessionTable;
User: UserTable;
}
export interface SystemInformation {
createdBy: ColumnType<UserId | null, UserId, never>;
createdAt: ColumnType<string, string, never>;
modifiedBy: ColumnType<UserId | null, null, UserId>;
modifiedAt: ColumnType<string | null, null, string>;
}
export interface AccessLogTable {
requestId: ColumnType<RequestId, RequestId, never>;
timestamp: ColumnType<string, string, never>;
method: ColumnType<string, string, never>;
pathname: ColumnType<string, string, never>;
query: ColumnType<string, string, never>;
ip: ColumnType<string | null, string | null, never>;
}
export interface Attachment {
pieceId: ColumnType<PieceId, PieceId, never>;
sha256: Sha256;
filename: string;
mediaType: string;
}
export interface AttachmentTable extends Attachment, SystemInformation {
attachmentId: ColumnType<AttachmentId, AttachmentId, never>;
}
export interface FileTable {
sha256: ColumnType<Sha256, Sha256, never>;
data: ColumnType<Uint8Array, Uint8Array, never>;
}
export interface Piece {
name: string;
composer: string | null;
lyricist: string | null;
arranger: string | null;
}
export interface PieceTable extends Piece, SystemInformation {
pieceId: ColumnType<PieceId, PieceId, never>;
}
export interface Session {
userId: UserId;
}
export interface SessionTable extends Session {
sessionId: ColumnType<SessionId, SessionId, never>;
expiresAt: string;
}
export interface UserTable {
userId: ColumnType<UserId, UserId, never>;
username: string;
password: string;
admin: number;
}
function systemInformation<TB extends string, C extends string>(schema: CreateTableBuilder<TB, C>) {
return schema
.addColumn("createdBy", "text", (c) => c.references("User.userId").onDelete("set null").onUpdate("cascade"))
.addColumn("createdAt", "text", (c) => c.notNull())
.addColumn("modifiedBy", "text", (c) => c.references("User.userId").onDelete("set null").onUpdate("cascade"))
.addColumn("modifiedAt", "text");
}
export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kysely<Database>> {
const database = new BunSqliteDatabase(filename, { create: true, readwrite: true });
const dialect = new BunSqliteDialect({ database });
const db = new Kysely<Database>({ dialect });
await db.executeQuery(CompiledQuery.raw("PRAGMA foreign_keys = ON"));
await db.schema
.createTable("AccessLog")
.ifNotExists()
.addColumn("requestId", "text", (c) => c.notNull().primaryKey())
.addColumn("timestamp", "text", (c) => c.notNull())
.addColumn("method", "text", (c) => c.notNull())
.addColumn("pathname", "text", (c) => c.notNull())
.addColumn("query", "text", (c) => c.notNull())
.addColumn("ip", "text")
.execute();
await db.schema
.createIndex("AccessLog_timestamp")
.ifNotExists()
.on("AccessLog")
.column("timestamp")
.execute();
await db.schema
.createTable("User")
.ifNotExists()
.addColumn("userId", "text", (c) => c.notNull().primaryKey())
.addColumn("username", "text", (c) => c.notNull().unique())
.addColumn("password", "text", (c) => c.notNull())
.addColumn("admin", "boolean", (c) => c.notNull())
.execute();
await db.schema
.createTable("Piece")
.ifNotExists()
.addColumn("pieceId", "text", (c) => c.notNull().primaryKey())
.addColumn("name", "text", (c) => c.notNull())
.addColumn("composer", "text")
.addColumn("lyricist", "text")
.addColumn("arranger", "text")
.$call(systemInformation)
.execute();
await db.schema
.createIndex("Piece_name_composer_arranger")
.ifNotExists()
.on("Piece")
.columns(["name", "composer", "arranger"])
.execute();
await db.schema
.createTable("Session")
.ifNotExists()
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
.addColumn("userId", "text", (c) => c.references("User.userId").onDelete("cascade").onUpdate("cascade"))
.addColumn("expiresAt", "text", (c) => c.notNull())
.execute();
await db.schema
.createTable("Attachment")
.ifNotExists()
.addColumn("attachmentId", "text", (c) => c.notNull().primaryKey())
.addColumn("pieceId", "text", (c) => c.notNull().references("Piece.pieceId").onDelete("cascade").onUpdate("cascade"))
.addColumn("sha256", "blob", (c) => c.notNull())
.addColumn("filename", "text", (c) => c.notNull())
.addColumn("mediaType", "text", (c) => c.notNull())
.$call(systemInformation)
.execute();
return db;
}

View File

@@ -1,71 +0,0 @@
import { RequestId } from "common";
import { Effect, Exit, Layer, ManagedRuntime, 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 scope = Effect.runSync(Scope.make());
const runtime = pipe(
StorageFilesystem("storage"),
Layer.merge(DatabaseLive()),
Layer.provide(Layer.succeed(Scope.Scope, scope)),
ManagedRuntime.make,
);
const server = Bun.serve({
fetch: (request, server) => {
const url = new URL(request.url);
const ip = pipe(
server.requestIP(request),
O.fromNullable,
O.map((a) => a.address),
);
const query = Object.freeze(Object.fromEntries(url.searchParams.entries()));
const requestId = RequestId.make(ulid());
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,
Effect.provideService(Request, requestInterface),
runtime.runFork,
);
return new Promise((resolve, reject) => {
fiber.addObserver(Exit.match({
onSuccess: resolve,
onFailure: reject,
}));
});
},
websocket: {
message: () => { },
},
});
process.on("SIGINT", () => {
Effect.runFork(Effect.gen(function* () {
yield* runtime.disposeEffect;
yield* Scope.close(scope, Exit.void);
}));
server.stop();
});

View File

@@ -1,197 +0,0 @@
import { Schema as S } from "@effect/schema";
import { Database as SqliteDatabase } from "bun:sqlite";
import { PieceId, SessionId, UserId } from "common";
import { AccessLog, Piece, SessionData, User } from "common/db";
import { Context, Effect, Layer, pipe, Option as O } from "effect";
import { NoSuchElementException } from "effect/Cause";
import { ulid } from "ulid";
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 createAccessLog: (accessLog: AccessLog) => Effect.Effect<void>;
readonly createPiece: (name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.Effect<PieceId>;
readonly getPieces: Effect.Effect<readonly Piece[]>;
readonly updatePiece: (pieceId: PieceId, name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.Effect<void, NoSuchElementException>;
readonly deletePiece: (pieceId: PieceId) => Effect.Effect<void, NoSuchElementException>;
readonly getUserByUsername: (username: string) => Effect.Effect<User, NoSuchElementException>;
readonly getUserById: (userId: UserId) => Effect.Effect<User, NoSuchElementException>;
readonly createSession: (sessionData: SessionData) => Effect.Effect<SessionId>;
readonly getAndRefreshSessionData: (sessionId: SessionId) => Effect.Effect<SessionData, NoSuchElementException>;
readonly deleteSession: (sessionId: SessionId) => Effect.Effect<void>;
}
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
`;
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}
)`);
const userCount = db.query("SELECT COUNT(*) FROM User").values()[0][0] as number;
if (userCount <= 0) {
const userId = UserId.make(ulid());
const username = "admin";
const password = Bun.password.hashSync("admin");
const admin = true;
db.exec("INSERT INTO User (userId, username, password, admin) VALUES (?, ?, ?, ?)", userId, username, password, admin);
}
})),
Effect.map((db) => {
const createAccessLog = db.prepare<
never,
[timestamp: string, requestId: string, method: string, pathname: string, query: string, ip: string | null]
>("INSERT INTO AccessLog (timestamp, requestId, method, pathname, query, ip) VALUES (?, ?, ?, ?, ?, ?)");
const createPiece = db.prepare<
never,
[pieceId: PieceId, name: string, composer: string | null, lyricist: string | null, arranger: string | null, user: UserId]
>("INSERT INTO Piece (pieceId, name, composer, lyricist, arranger, createdBy, createdAt, modifiedBy, modifiedAt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime(), ?6, NULL)");
const getPieces = db.prepare<
typeof Piece.Encoded,
[]
>("SELECT pieceId, name, composer, lyricist, arranger, createdBy, createdAt, modifiedBy, modifiedAt FROM Piece ORDER BY name, composer, arranger");
const updatePiece = db.prepare<
never,
[pieceId: PieceId, name: string, composer: string | null, lyricist: string | null, arranger: string | null, user: UserId]
>("UPDATE Piece SET name = ?2, composer = ?3, lyricist = ?4, arranger = ?5, modifiedBy = ?6, modifiedAt = datetime() WHERE pieceId = ?1");
const pieceExists = db.prepare<object, [pieceId: PieceId]>("SELECT 1 FROM Piece WHERE PieceId = ?");
const deletePiece = db.prepare<never, [pieceId: PieceId]>("DELETE FROM Piece WHERE pieceId = ?");
const getUserByUsername = db.prepare<
{ userId: string, username: string, password: string, admin: number },
[username: string]
>("SELECT userId, username, password, admin FROM User WHERE username = ?");
const getUserById = db.prepare<
{ userId: string, username: string, password: string, admin: number },
[userId: UserId]
>("SELECT userId, username, password, admin FROM User WHERE userId = ?");
const cleanupSessions = db.prepare<never, []>("DELETE FROM Session WHERE datetime() >= expiresAt");
const createSession = db.prepare<
never,
[sessionId: SessionId, userId: UserId]
>("INSERT INTO Session (sessionId, userId, expiresAt) VALUES (?, ?, datetime('now', '+7 days'))");
const updateSession = db.prepare<typeof SessionData.Encoded, [sessionId: SessionId]>("UPDATE Session SET expiresAt = datetime('now', '+7 days') WHERE sessionId = ? RETURNING userId");
const deleteSession = db.prepare<never, [sessionId: SessionId]>("DELETE FROM Session WHERE sessionId = ?");
return Object.freeze<DatabaseInterface>({
createAccessLog: (accessLog) => Effect.sync(() => {
const { timestamp, requestId, method, pathname, query, ip } = S.encodeSync(AccessLog)(accessLog);
createAccessLog.run(timestamp, requestId, method, pathname, query, ip);
}),
createPiece: (name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.sync(() => {
const pieceId = PieceId.make(ulid());
createPiece.run(pieceId, name, O.getOrNull(composer), O.getOrNull(lyricist), O.getOrNull(arranger), user);
return pieceId;
}),
getPieces: Effect.sync(() => {
const res = getPieces.all();
const decoder = S.decodeSync(Piece);
return Object.freeze(res.map((encoded) => decoder(encoded)));
}),
updatePiece: (pieceId: PieceId, name: string, composer: O.Option<string>, lyricist: O.Option<string>, arranger: O.Option<string>, user: UserId) => Effect.suspend(() => {
updatePiece.run(pieceId, name, O.getOrNull(composer), O.getOrNull(lyricist), O.getOrNull(arranger), user);
const matched = pieceExists.get(pieceId) !== null;
return matched ? Effect.void : Effect.fail(new NoSuchElementException());
}),
deletePiece: (pieceId: PieceId) => Effect.suspend(() => {
const matched = deletePiece.run(pieceId).changes > 0;
return matched ? Effect.void : Effect.fail(new NoSuchElementException());
}),
getUserByUsername: (username) => Effect.suspend(() => {
const res = getUserByUsername.get(username);
if (res === null) return Effect.fail(new NoSuchElementException());
return Effect.succeed(S.decodeSync(User)(res));
}),
getUserById: (userId) => Effect.suspend(() => {
const res = getUserById.get(userId);
if (res === null) return Effect.fail(new NoSuchElementException());
return Effect.succeed(S.decodeSync(User)(res));
}),
createSession: ({ userId }) => Effect.sync(() => {
cleanupSessions.run();
const sessionId = generateSessionId();
createSession.run(sessionId, userId);
return sessionId;
}),
getAndRefreshSessionData: (sessionId) => Effect.suspend(() => {
cleanupSessions.run();
const res = updateSession.get(sessionId);
if (res === null) return Effect.fail(new NoSuchElementException());
return Effect.succeed(S.decodeUnknownSync(SessionData)(res));
}),
deleteSession: (sessionId) => Effect.sync(() => {
cleanupSessions.run();
deleteSession.run(sessionId);
}),
});
}),
));

View File

@@ -1,16 +0,0 @@
import { RequestId } from "common";
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>() { }

View File

@@ -1,107 +0,0 @@
import { Sha256 } from "common";
import { Context, Data, Effect, Layer } from "effect";
import { NoSuchElementException } from "effect/Cause";
import { constant, flow } from "effect/Function";
import path from "node:path";
export class StorageError extends Data.TaggedError("StorageError")<{ cause: unknown }> { }
export interface StorageInterface {
/**
* @param sha256 SHA-256 of blob to read
* @returns Blob, if exists
*/
readonly read: (sha256: Sha256) => Effect.Effect<Uint8Array, NoSuchElementException | StorageError>;
/**
* @param data Blob to write
* @param sha256 Precomputed SHA-256, trusted to be accurate if provided
* @returns Precomputed SHA-256, if provided, internally computed SHA-256
* otherwise
*/
readonly write: (data: Uint8Array, sha256?: Sha256) => Effect.Effect<Sha256, StorageError>;
}
export class Storage extends Context.Tag("Storage")<Storage, StorageInterface>() { }
/**
* Storage implementation that uses the filesystem.
*/
export const StorageFilesystem = (baseDir: string = ".") => {
const sha256toFilePath = (sha256: Sha256): string => {
const sha256hex = Buffer.from(sha256).toString("hex");
return path.join(
baseDir,
sha256hex.slice(0, 2),
sha256hex.slice(2, 4),
sha256hex.slice(4),
);
}
return Layer.succeed(Storage, Object.freeze<StorageInterface>({
read: (sha256) => {
const filePath = sha256toFilePath(sha256);
return Effect.gen(function* () {
const arrayBuffer = yield* Effect.tryPromise({
try: () => Bun.file(filePath).arrayBuffer(),
catch: (error) => new StorageError({ cause: error }),
});
const array = new Uint8Array(arrayBuffer);
return array;
});
},
write: (data, sha256) => Effect.gen(function* () {
if (sha256 === undefined) {
sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
Bun.SHA256.hash(data, sha256);
}
const filePath = sha256toFilePath(sha256);
yield* Effect.tryPromise({
try: () => Bun.write(filePath, data),
catch: (error) => new StorageError({ cause: error }),
});
return sha256;
}),
}));
};
const sha256ToBase64 = (sha256: Sha256): string => {
return Buffer.from(sha256).toString("base64");
}
/**
* Storage that keeps all data in memory.
*/
export const StorageMemory = Layer.sync(Storage, () => {
const map = new Map<string, Uint8Array>();
return Object.freeze<StorageInterface>({
read: flow(
sha256ToBase64,
_ => map.get(_),
Effect.fromNullable,
),
write: (data, sha256) => Effect.sync(() => {
if (sha256 === undefined) {
sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
Bun.SHA256.hash(data, sha256);
}
map.set(sha256ToBase64(sha256), data);
return sha256;
}),
});
});
/**
* Storage that discards any writes silently and always fails to read.
*/
export const StorageEmpty = Layer.succeed(Storage, Object.freeze<StorageInterface>({
read: constant(Effect.fail(new NoSuchElementException())),
write: (data, sha256) => Effect.sync(() => {
if (sha256 === undefined) {
sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
Bun.SHA256.hash(data, sha256);
}
return sha256;
}),
}));