Port to elysia, tailwind (no effect)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user