diff --git a/bun.lockb b/bun.lockb index c577741..14358ce 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 570fce8..7fb02e4 100644 --- a/package.json +++ b/package.json @@ -1,45 +1,52 @@ { - "name": "markone", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" - }, - "dependencies": { - "@tailwindcss/vite": "^4.1.5", - "@tanstack/react-router": "^1.119.0", - "clsx": "^2.1.1", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "tailwindcss": "^4.1.5", - "zustand": "^5.0.4" - }, - "devDependencies": { - "@eslint/js": "^9.18.0", - "@tanstack/react-router-devtools": "^1.119.1", - "@tanstack/router-plugin": "^1.119.0", - "@types/node": "^22.15.3", - "@types/react": "^19.0.8", - "@types/react-dom": "^19.0.3", - "@vite-pwa/assets-generator": "^0.2.6", - "@vitejs/plugin-react": "^4.2.1", - "eslint": "^9.18.0", - "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.18", - "globals": "^15.14.0", - "typescript": "~5.7.2", - "typescript-eslint": "^8.20.0", - "vite": "^6.0.11", - "vite-plugin-pwa": "^0.21.1", - "workbox-core": "^7.3.0", - "workbox-window": "^7.3.0" - }, - "resolutions": { - "sharp": "0.32.6", - "sharp-ico": "0.1.5" - } + "name": "markone", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:server": "bun run src/server-main.ts", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.5", + "@tanstack/react-router": "^1.119.0", + "arktype": "^2.1.20", + "clsx": "^2.1.1", + "dayjs": "^1.11.13", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwindcss": "^4.1.5", + "uid-safe": "^2.1.5", + "ulid": "^3.0.0", + "zustand": "^5.0.4" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@tanstack/react-router-devtools": "^1.119.1", + "@tanstack/router-plugin": "^1.119.0", + "@types/bun": "^1.2.12", + "@types/node": "^22.15.3", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@types/uid-safe": "^2.1.5", + "@vite-pwa/assets-generator": "^0.2.6", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.18", + "globals": "^15.14.0", + "typescript": "~5.7.2", + "typescript-eslint": "^8.20.0", + "vite": "^6.0.11", + "vite-plugin-pwa": "^0.21.1", + "workbox-core": "^7.3.0", + "workbox-window": "^7.3.0" + }, + "resolutions": { + "sharp": "0.32.6", + "sharp-ico": "0.1.5" + } } diff --git a/src/app/$username/bookmarks.tsx b/src/app/$username/bookmarks.tsx index c5c9306..fae72f5 100644 --- a/src/app/$username/bookmarks.tsx +++ b/src/app/$username/bookmarks.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { create } from "zustand"; import clsx from "clsx"; import { Button } from "~/components/button"; -import type { LinkBookmark } from "~/bookmark"; +import type { LinkBookmark } from "~/bookmark/bookmark"; const testBookmarks: LinkBookmark[] = [ { diff --git a/src/auth/auth.ts b/src/auth/auth.ts new file mode 100644 index 0000000..ed7665f --- /dev/null +++ b/src/auth/auth.ts @@ -0,0 +1,160 @@ +import { HttpError, httpHandler } from "~/server-util"; +import dayjs from "dayjs"; +import { ulid } from "ulid"; +import { + createSessionForUser, + verifySession, + extendSession, + saveSession, + forgetAllSessions, +} from "./session"; +import { db } from "~/database"; +import { + createUser, + findUserById, + findUserByUsername, + type User, +} from "~/user/user"; +import { type } from "arktype"; + +const SignUpRequest = type({ + username: "string", + password: "string", +}); + +const LoginRequest = type({ + username: "string", + password: "string", +}); + +const createAuthTokenQuery = db.query( + "INSERT INTO auth_tokens(id, token, user_id, expires_at_unix_ms) VALUES ($id, $token, $userId, $expiresAt)", +); + +const deleteAuthTokenQuery = db.query("DELETE FROM auth_tokens WHERE id = $id"); + +const deleteAllAuthTokensQuery = db.query( + "DELETE FROM auth_tokens WHERE user_id = $userId", +); + +function authenticated( + handler: (request: Bun.BunRequest, user: User) => Promise, +) { + return httpHandler((request) => { + const session = verifySession(request.cookies); + if (!session) { + throw new HttpError(401); + } + + const user = findUserById(session.userId); + if (!user) { + throw new HttpError(401); + } + + const authTokenCookie = request.cookies.get("auth-token"); + if (authTokenCookie) { + const tokenId = authTokenCookie.split(":")[0]; + deleteAuthTokenQuery.run({ id: tokenId }); + rememberLoginForUser(user, request.cookies); + } + + const extendedSession = extendSession(session); + saveSession(extendedSession, request.cookies); + + return handler(request, user); + }); +} + +function rememberLoginForUser(user: User, cookies: Bun.CookieMap) { + const tokenId = ulid(); + + const authToken = Buffer.alloc(32); + crypto.getRandomValues(authToken); + + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(authToken); + const hashedToken = hasher.digest("base64url"); + + const expiryDate = dayjs().add(1, "month"); + + createAuthTokenQuery.run({ + id: tokenId, + token: hashedToken, + userId: user.id, + expiresAt: expiryDate.valueOf(), + }); + + cookies.set( + "auth-token", + `${tokenId}:${authToken.toBase64({ alphabet: "base64url" })}`, + { maxAge: 30 * 24 * 60 * 60 * 1000, httpOnly: true }, + ); +} + +async function signUp(request: Bun.BunRequest<"/api/sign-up">) { + const body = await request.json().catch(() => { + throw new HttpError(500); + }); + + const signUpRequest = SignUpRequest(body); + if (signUpRequest instanceof type.errors) { + throw new HttpError(400, signUpRequest.summary); + } + + const { username, password } = signUpRequest; + const hashedPassword = await Bun.password.hash(password, "argon2id"); + const user = createUser(username, hashedPassword); + + await createSessionForUser(user, request.cookies); + rememberLoginForUser(user, request.cookies); + + return Response.json(user, { status: 200 }); +} + +async function login(request: Bun.BunRequest<"/api/login">) { + const body = await request.json().catch(() => { + throw new HttpError(500); + }); + + const loginRequest = LoginRequest(body); + if (loginRequest instanceof type.errors) { + throw new HttpError(400, loginRequest.summary); + } + + const foundUser = findUserByUsername(loginRequest.username, { + password: true, + }); + if (!foundUser) { + throw new HttpError(400); + } + + const ok = await Bun.password + .verify(loginRequest.password, foundUser.password, "argon2id") + .catch(() => { + throw new HttpError(401); + }); + if (!ok) { + throw new HttpError(401); + } + + const user: User = { + id: foundUser.id, + username: foundUser.username, + }; + + await createSessionForUser(user, request.cookies); + rememberLoginForUser(user, request.cookies); + + return Response.json(user, { status: 200 }); +} + +async function logout( + request: Bun.BunRequest<"/api/logout">, + user: User, +): Promise { + forgetAllSessions(user); + deleteAllAuthTokensQuery.run({ userId: user.id }); + return new Response(undefined, { status: 200 }); +} + +export { authenticated, signUp, login, logout }; diff --git a/src/auth/session.ts b/src/auth/session.ts new file mode 100644 index 0000000..7319f51 --- /dev/null +++ b/src/auth/session.ts @@ -0,0 +1,134 @@ +import uid from "uid-safe"; +import dayjs from "dayjs"; +import { db } from "~/database"; +import type { User } from "~/user/user"; + +interface Session { + id: string; + signedId: string; + userId: string; + durationMs: number; + expiresAt: number; +} + +const SESSION_ID_BYTE_LENGTH = 24; +const SESSION_ID_COOKIE_NAME = "session-id"; +const SESSION_DURATION_MS = 30 * 60 * 1000; + +const findSessionQuery = db.query( + "SELECT user_id, expires_at_unix FROM sessions WHERE session_id = $sessionId", +); + +const deleteSessionQuery = db.query( + "DELETE FROM sessions WHERE session_id = $sessionId", +); + +const forgetAllSessionsQuery = db.query( + "DELETE FROM sessions WHERE user_id = $userId", +); + +const saveSessionQuery = db.query( + "INSERT INTO sessions(session_id, user_id, expires_at_unix_ms) VALUES ($sessionId, $userId, $expiresAt)", +); + +const extendSessionQuery = db.query( + "UPDATE sessions SET expires_at_unix_ms = $newExpiryDate WHERE session_id = $session_id", +); + +async function newSessionId(): Promise { + return await uid(SESSION_ID_BYTE_LENGTH); +} + +function signSessionId(sessionId: string): string { + const hasher = new Bun.CryptoHasher("sha256", Bun.env.SESSION_SECRET); + hasher.update(sessionId); + return `${sessionId}.${hasher.digest("base64url")}`; +} + +async function createSessionForUser(user: User, cookies: Bun.CookieMap) { + const sessionId = await newSessionId(); + const signedSessionId = signSessionId(sessionId); + + const expiryDate = dayjs().add(30, "minutes").valueOf(); + + saveSessionQuery.run({ + sessionId, + userId: user.id, + expiresAt: expiryDate, + }); + + cookies.set(SESSION_ID_COOKIE_NAME, signedSessionId, { + maxAge: SESSION_DURATION_MS, + httpOnly: true, + }); +} + +async function saveSession(session: Session, cookies: Bun.CookieMap) { + cookies.set(SESSION_ID_COOKIE_NAME, session.signedId, { + maxAge: SESSION_DURATION_MS, + httpOnly: true, + }); +} + +function verifySession(cookie: Bun.CookieMap): Session | null { + const signedSessionId = cookie.get(SESSION_ID_COOKIE_NAME); + if (!signedSessionId) { + return null; + } + + const value = signedSessionId.slice(0, signedSessionId.lastIndexOf(".")); + const expected = signSessionId(value); + + const a = Buffer.from(signedSessionId); + const b = Buffer.from(expected); + + const isEqual = a.length === b.length && crypto.timingSafeEqual(a, b); + if (!isEqual) { + return null; + } + + const row = findSessionQuery.get({ sessionId: value }); + if (!row) { + return null; + } + const foundSession = row as { user_id: string; expires_at_unix_ms: number }; + + const now = dayjs().valueOf(); + if (now > foundSession.expires_at_unix_ms) { + deleteSessionQuery.run({ sessionId: value }); + return null; + } + + return { + id: value, + signedId: signedSessionId, + userId: foundSession.user_id, + expiresAt: foundSession.expires_at_unix_ms, + durationMs: SESSION_DURATION_MS, + }; +} + +function extendSession(session: Session): Session { + const newExpiryDate = dayjs().add(30, "minutes").valueOf(); + extendSessionQuery.run({ + sessionId: session.id, + newExpiryDate, + }); + return { + ...session, + expiresAt: newExpiryDate, + }; +} + +function forgetAllSessions(user: User) { + forgetAllSessionsQuery.run({ userId: user.id }); +} + +export { + newSessionId, + createSessionForUser, + verifySession, + saveSession, + extendSession, + forgetAllSessions, +}; diff --git a/src/bookmark.ts b/src/bookmark/bookmark.ts similarity index 100% rename from src/bookmark.ts rename to src/bookmark/bookmark.ts diff --git a/src/bookmark/handlers.ts b/src/bookmark/handlers.ts new file mode 100644 index 0000000..c926dbe --- /dev/null +++ b/src/bookmark/handlers.ts @@ -0,0 +1,35 @@ +import type { User } from "~/user/user"; +import { type } from "arktype"; +import { HttpError } from "~/server"; +import { db } from "~/database"; + +const BOOKMARK_PAGINATION_LIMIT = 100; + +const ListUserBookmarksParams = type({ + limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT], + skip: ["number", "=", 5], +}); + +const listBookmarksQuery = db.query( + "SELECT id, kind, title, url FROM bookmarks WHERE user_id = $userId LIMIT $limit OFFSET $skip", +); + +async function listUserBookmarks( + request: Bun.BunRequest<"/api/bookmarks">, + user: User, +) { + const queryParams = ListUserBookmarksParams(request.params); + if (queryParams instanceof type.errors) { + throw new HttpError(400, queryParams.summary); + } + + const results = listBookmarksQuery.all({ + userId: user.id, + limit: queryParams.limit, + skip: queryParams.skip, + }); + + return Response.json(results, { status: 200 }); +} + +export { listUserBookmarks }; diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..0704e81 --- /dev/null +++ b/src/database.ts @@ -0,0 +1,62 @@ +import { Database } from "bun:sqlite"; + +const DB_VERSION = 0; + +const db = new Database("data.sqlite"); + +const dbVersionQuery = db.query("SELECT version FROM migration"); + +const migrations = [ + ` +CREATE TABLE IF NOT EXISTS migration( + version INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS users( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password TEXT NOT NULL, +); + +CREATE TABLE IF NOT EXISTS bookmarks( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + kind TEXT NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions( + session_id TEXT NOT NULL, + user_id TEXT NOT NULL, + expires_at_unix_ms INTEGER NOT NULL, + PRIMARY KEY (session_id, user_id) +); + +CREATE TABLE IF NOT EXISTS auth_tokens( + id TEXT PRIMARY KEY, + token TEXT NOT NULL, + user_id TEXT NOT NULL, + expires_at_unix_ms INTEGER NOT NULL +); +`, +]; + +function migrateDatabase() { + let row = dbVersionQuery.get(); + let currentVersion: number; + if (row) { + currentVersion = (row as { version: number }).version; + console.log( + `Migrating database from version ${currentVersion} to version ${DB_VERSION}...`, + ); + } else { + currentVersion = -1; + console.log("Initializing database..."); + } + for (let version = currentVersion + 1; version <= DB_VERSION; ++version) { + db.run(migrations[version]); + } +} + +export { db, migrateDatabase }; diff --git a/src/server-main.ts b/src/server-main.ts new file mode 100644 index 0000000..d8b0d39 --- /dev/null +++ b/src/server-main.ts @@ -0,0 +1,27 @@ +import { authenticated, signUp, login, logout } from "./auth/auth"; +import { listUserBookmarks } from "./bookmark/handlers"; +import { migrateDatabase } from "./database"; +import { httpHandler } from "./server-util"; + +function main() { + migrateDatabase(); + + Bun.serve({ + routes: { + "/api/login": { + POST: httpHandler(login), + }, + "/api/sign-up": { + POST: httpHandler(signUp), + }, + "/api/logout": { + POST: authenticated(logout), + }, + "/api/bookmarks": { + GET: authenticated(listUserBookmarks), + }, + }, + }); +} + +main(); diff --git a/src/server-util.ts b/src/server-util.ts new file mode 100644 index 0000000..7a4ba95 --- /dev/null +++ b/src/server-util.ts @@ -0,0 +1,30 @@ +class HttpError { + constructor( + public readonly status: number, + public readonly message?: string, + ) {} +} + +function httpHandler( + handler: (request: Bun.BunRequest) => Promise, +): (request: Bun.BunRequest) => Promise { + return async (request) => { + try { + const response = await handler(request); + return response; + } catch (error) { + if (error instanceof HttpError) { + if (error.message) { + return Response.json( + { message: error.message }, + { status: error.status }, + ); + } + return new Response(undefined, { status: error.status }); + } + return new Response(undefined, { status: 500 }); + } + }; +} + +export { HttpError, httpHandler }; diff --git a/src/user/user.ts b/src/user/user.ts new file mode 100644 index 0000000..1220085 --- /dev/null +++ b/src/user/user.ts @@ -0,0 +1,67 @@ +import { ulid } from "ulid"; +import { db } from "~/database"; + +interface User { + id: string; + username: string; +} + +interface UserWithPassword extends User { + password: string; +} + +const findUserByIdQuery = db.query( + "SELECT id, username FROM users WHERE id = $userId", +); + +const findUserByUsernameQuery = db.query( + "SELECT id, username FROM users WHERE username = $username", +); +const findUserByUsernameWithPwQuery = db.query( + "SELECT id, username, password FROM users WHERE username = $username", +); + +const createUserQuery = db.query( + "INSERT INTO users(id, username, password) VALUES ($id, $username, $password)", +); + +function findUserByUsername( + username: string, + opts: { password: true }, +): UserWithPassword | null; +function findUserByUsername( + username: string, + { password }: { password?: boolean }, +): User | UserWithPassword | null { + const row = ( + password ? findUserByUsernameWithPwQuery : findUserByUsernameQuery + ).get({ username }); + if (!row) { + return null; + } + return row as User; +} + +function findUserById(userId: string): User | null { + const row = findUserByIdQuery.get({ userId }); + if (!row) { + return null; + } + return row as User; +} + +function createUser(username: string, password: string): User { + const userId = ulid(); + createUserQuery.run({ + id: userId, + username, + password, + }); + return { + id: userId, + username, + }; +} + +export type { User }; +export { createUser, findUserByUsername, findUserById }; diff --git a/tsconfig.app.json b/tsconfig.app.json index 05ec3c8..1e36d59 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -6,6 +6,7 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, + "experimentalDecorators": true, /* Bundler mode */ "moduleResolution": "bundler",