Port to elysia, tailwind (no effect)
This commit is contained in:
@@ -1,18 +1,17 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/schema": "catalog:",
|
||||
"@elysiajs/cors": "catalog:",
|
||||
"common": "workspace:^",
|
||||
"effect": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
"make-api": "workspace:^",
|
||||
"ulid": "catalog:"
|
||||
"elysia": "catalog:",
|
||||
"kysely": "catalog:",
|
||||
"kysely-bun-sqlite": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
162
packages/backend/src/database.ts
Normal file
162
packages/backend/src/database.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
}),
|
||||
));
|
||||
@@ -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>() { }
|
||||
@@ -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;
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user