diff --git a/package.json b/package.json index 4cfe832..bbabeff 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "license": "UNLICENSED", "private": true, "type": "module", + "scripts": { + "start:backend": "bun run packages/backend/src/index.ts", + "start:frontend": "pnpm --filter frontend exec vite --open" + }, "devDependencies": { "typescript": "catalog:" } diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 40f33ea..4b401ba 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,6 +1,7 @@ import { Schema as S } from "@effect/schema"; -import { Login } from "common/api"; -import { AccessLog } from "common/db"; +import { SessionId } from "common"; +import { Login, 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 { Api } from "make-api"; import { brotliCompress } from "node:zlib"; @@ -24,7 +25,8 @@ const match = (api: Api.Api.Any) => Effect.gen(function* () { )); }); -const WEIGHTED_TOKEN_REGEX = /^([!#$%'*+.^_`|~a-z0-9-])\s*(?:;\s*q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/i; +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; @@ -48,7 +50,7 @@ const acceptsBrotli = Effect.gen(function* () { .split(/\s*,\s*/) .map((token) => token.match(WEIGHTED_TOKEN_REGEX)) .filter((match) => match !== null) - .map((match) => Object.freeze({ + .map((match) => ({ coding: match[1], ...(match[2] ? { quality: Number.parseFloat(match[2]) } : undefined), })); @@ -56,6 +58,24 @@ const acceptsBrotli = Effect.gen(function* () { return codings.some(({ coding, quality = 1 }) => coding === "br" && quality > 0); }); +const getCookie = (name: string): Effect.Effect, 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 = (schema: S.Schema) => { const decoder = S.decodeUnknown(schema); return pipe( @@ -66,7 +86,7 @@ const requestJson = (schema: S.Schema) => { ), Effect.flatMap((req) => req.json), Effect.flatMap(decoder), - Effect.mapError((error) => new RequestError({ status: 400, body: error.message })) + Effect.mapError((error) => new RequestError({ status: 400, body: error.message })), ); }; @@ -87,7 +107,8 @@ export const app = pipe( ip: O.getOrNull(req.ip), }); - console.log(JSON.stringify(accessLog)); + 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(Login)) { @@ -103,7 +124,8 @@ export const app = pipe( return yield* new RequestError({ status: 401, body: "Invalid username or password" }); } - const sessionId = yield* db.createSession(user.userId); + const sessionData = SessionData.make({ userId: user.userId }); + const sessionId = yield* db.createSession(sessionData); const responseData = Login.props.response[200].schema.make({ userId: user.userId, @@ -119,7 +141,91 @@ export const app = pipe( 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`; + const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; SameSite=None; Secure`; + + if (yield* acceptsBrotli) { + const responseBrotli = yield* Effect.async((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(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((callback) => { diff --git a/packages/backend/src/services/database.ts b/packages/backend/src/services/database.ts index 3b395d5..84a4913 100644 --- a/packages/backend/src/services/database.ts +++ b/packages/backend/src/services/database.ts @@ -1,7 +1,7 @@ import { Schema as S } from "@effect/schema"; import { Database as SqliteDatabase } from "bun:sqlite"; import { SessionId, UserId } from "common"; -import { User } from "common/db"; +import { AccessLog, SessionData, User } from "common/db"; import { Context, Effect, Layer, pipe } from "effect"; import { NoSuchElementException } from "effect/Cause"; import { ulid } from "ulid"; @@ -14,8 +14,14 @@ export function generateSessionId(byteLength: number = 12): SessionId { } export interface DatabaseInterface { + readonly createAccessLog: (accessLog: AccessLog) => Effect.Effect; + readonly getUserByUsername: (username: string) => Effect.Effect; - readonly createSession: (userId: UserId) => Effect.Effect; + readonly getUserById: (userId: UserId) => Effect.Effect; + + readonly createSession: (sessionData: SessionData) => Effect.Effect; + readonly getAndRefreshSessionData: (sessionId: SessionId) => Effect.Effect; + readonly deleteSession: (sessionId: SessionId) => Effect.Effect; } export class Database extends Context.Tag("Database")() { } @@ -85,23 +91,64 @@ export const DatabaseLive = (filePath: string = "db.sqlite3") => Layer.effect(Da } })), 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 = ?"); + 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 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("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("UPDATE Session SET expiresAt = datetime('now', '+7 days') WHERE sessionId = ? RETURNING userId"); + const deleteSession = db.prepare("DELETE FROM Session WHERE sessionId = ?"); return Object.freeze({ + createAccessLog: (accessLog) => Effect.sync(() => { + const { timestamp, requestId, method, pathname, query, ip } = S.encodeSync(AccessLog)(accessLog); + createAccessLog.run(timestamp, requestId, method, pathname, query, ip); + }), + getUserByUsername: (username) => Effect.suspend(() => { const res = getUserByUsername.get(username); if (res === null) return Effect.fail(new NoSuchElementException()); - return Effect.succeed(S.decodeUnknownSync(User)(res)); + return Effect.succeed(S.decodeSync(User)(res)); }), - createSession: (userId) => Effect.sync(() => { + 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); + }), }); }), )); diff --git a/packages/common/src/api.ts b/packages/common/src/api.ts index 4cc6137..6eab794 100644 --- a/packages/common/src/api.ts +++ b/packages/common/src/api.ts @@ -17,3 +17,13 @@ export const Login = pipe( Api.responseBodyText(400, S.String), Api.responseBodyText(401, S.String), ); + +export const Me = pipe( + Api.make("GET", "me"), + Api.responseBodyJson(200, S.Struct({ + userId: UserId, + username: S.NonEmptyString, + admin: S.Boolean, + })), + Api.responseBodyText(401, S.String), +); diff --git a/packages/common/src/db.ts b/packages/common/src/db.ts index 5c04854..93d6fc1 100644 --- a/packages/common/src/db.ts +++ b/packages/common/src/db.ts @@ -2,6 +2,12 @@ import { Schema as S } from "@effect/schema"; import { AttachmentId, BooleanFromNumber, PieceId, RequestId, SessionId, Sha256, UserId } from "common"; import { Brand as B, pipe } from "effect"; +export const SessionData = S.Struct({ + userId: UserId, +}); + +export type SessionData = typeof SessionData.Type; + export const SystemInformation = S.Struct({ createdBy: S.Union(UserId, S.Null), createdAt: S.DateTimeUtc, @@ -47,11 +53,13 @@ export const Piece = pipe( S.extend(SystemInformation), ); -export const Session = S.Struct({ - sessionId: SessionId, - userId: UserId, - expiresAt: S.DateTimeUtc, -}); +export const Session = pipe( + S.Struct({ + sessionId: SessionId, + expiresAt: S.DateTimeUtc, + }), + S.extend(SessionData), +); export const User = S.Struct({ userId: UserId, diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 366018d..efbb802 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,7 +1,6 @@ -import { ErrorBoundary, lazy, LocationProvider, Route, Router } from "preact-iso"; - -const Home = lazy(() => import("./routes/Home")); -const Login = lazy(() => import("./routes/Login")); +import { ErrorBoundary, LocationProvider, Route, Router } from "preact-iso"; +import { Home } from "./routes/Home"; +import { Login } from "./routes/Login"; export const App = () => ( diff --git a/packages/frontend/src/routes/Home.css.ts b/packages/frontend/src/routes/Home.css.ts new file mode 100644 index 0000000..fb1f5b0 --- /dev/null +++ b/packages/frontend/src/routes/Home.css.ts @@ -0,0 +1,18 @@ +import { style } from "@vanilla-extract/css"; + +export const container = style({ + width: 800, + maxWidth: "100%", + + margin: "0 auto", +}); + +export const loading = style({ + padding: 8, + + textAlign: "center", +}); + +export const content = style({ + padding: 8, +}); diff --git a/packages/frontend/src/routes/Home.tsx b/packages/frontend/src/routes/Home.tsx index 461f67a..ce75e97 100644 --- a/packages/frontend/src/routes/Home.tsx +++ b/packages/frontend/src/routes/Home.tsx @@ -1 +1,59 @@ -export default () => null; +import { Schema as S } from "@effect/schema"; +import { Me } from "common/api"; +import { Effect, Fiber, Option as O, Option, pipe } from "effect"; +import { useLocation } from "preact-iso"; +import { useEffect } from "preact/hooks"; +import { useStore } from "../store"; +import * as style from "./Home.css"; + +export const Home = () => { + + const { route } = useLocation(); + + const user = Option.getOrNull(useStore(state => state.user)); + const setUser = useStore(state => state.setUser); + + useEffect(() => { + if (user !== null) return; + + const effect = Effect.gen(function* () { + const res = yield* Effect.promise((signal) => fetch("http://localhost:3000/me", { + method: "GET", + signal, + credentials: "include", + })); + + if (!res.ok) { + route("/login"); + return; + } + + const responseData = yield* pipe( + Effect.promise(() => res.json()), + Effect.flatMap(S.decodeUnknown(Me.props.response[200].schema)), + Effect.orDie, + ); + + setUser(O.some(responseData)); + }); + + const fiber = Effect.runFork(effect); + return () => Effect.runFork(Fiber.interrupt(fiber), { immediate: true }); + }, []); + + if (user === null) { + return ( +
+
Ładowanie…
+
+ ); + } + + return ( +
+
+ Użytkownik: {user.username} +
+
+ ); +}; diff --git a/packages/frontend/src/routes/Login.tsx b/packages/frontend/src/routes/Login.tsx index 6a22105..ef90b0f 100644 --- a/packages/frontend/src/routes/Login.tsx +++ b/packages/frontend/src/routes/Login.tsx @@ -1,11 +1,14 @@ import { Schema as S } from "@effect/schema"; -import { Login } from "common/api"; +import { Login as LoginApi } from "common/api"; +import { Effect, Fiber, Option as O, pipe } from "effect"; +import { useLocation } from "preact-iso"; import { useId, useMemo, useRef } from "preact/hooks"; import { useStore } from "../store"; import * as style from "./Login.css"; -import { Effect, Fiber, Option as O, pipe } from "effect"; -export default () => { +export const Login = () => { + + const { route } = useLocation(); const loginUsername = useStore(state => state.loginUsername); const loginPassword = useStore(state => state.loginPassword); @@ -20,11 +23,11 @@ export default () => { const requestFiber = useRef | null>(null); const requestEffect = useMemo(() => Effect.gen(function* () { - const requestData = Login.props.request.schema.make({ username: loginUsername, password: loginPassword }); + const requestData = LoginApi.props.request.schema.make({ username: loginUsername, password: loginPassword }); const requestJson = yield* pipe( requestData, - S.encode(Login.props.request.schema), + S.encode(LoginApi.props.request.schema), Effect.map(JSON.stringify), Effect.orDie, ); @@ -34,6 +37,7 @@ export default () => { body: requestJson, headers: { "Content-Type": "application/json" }, signal, + credentials: "include", })); if (!res.ok) { @@ -42,7 +46,7 @@ export default () => { const responseData = yield* pipe( Effect.promise(() => res.json()), - Effect.flatMap(S.decodeUnknown(Login.props.response[200].schema)), + Effect.flatMap(S.decodeUnknown(LoginApi.props.response[200].schema)), Effect.orDie, ); @@ -50,9 +54,7 @@ export default () => { setLoginPassword(""); setUser(O.some(responseData)); - const a = document.createElement("a"); - a.href = "/"; - a.click(); + route("/"); }), [loginUsername, loginPassword]); const onSubmit = (e: SubmitEvent) => {