initial server and auth implementation
This commit is contained in:
93
package.json
93
package.json
@@ -1,45 +1,52 @@
|
|||||||
{
|
{
|
||||||
"name": "markone",
|
"name": "markone",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"dev:server": "bun run src/server-main.ts",
|
||||||
"lint": "eslint .",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"lint": "eslint .",
|
||||||
},
|
"preview": "vite preview"
|
||||||
"dependencies": {
|
},
|
||||||
"@tailwindcss/vite": "^4.1.5",
|
"dependencies": {
|
||||||
"@tanstack/react-router": "^1.119.0",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
"clsx": "^2.1.1",
|
"@tanstack/react-router": "^1.119.0",
|
||||||
"react": "^19.0.0",
|
"arktype": "^2.1.20",
|
||||||
"react-dom": "^19.0.0",
|
"clsx": "^2.1.1",
|
||||||
"tailwindcss": "^4.1.5",
|
"dayjs": "^1.11.13",
|
||||||
"zustand": "^5.0.4"
|
"react": "^19.0.0",
|
||||||
},
|
"react-dom": "^19.0.0",
|
||||||
"devDependencies": {
|
"tailwindcss": "^4.1.5",
|
||||||
"@eslint/js": "^9.18.0",
|
"uid-safe": "^2.1.5",
|
||||||
"@tanstack/react-router-devtools": "^1.119.1",
|
"ulid": "^3.0.0",
|
||||||
"@tanstack/router-plugin": "^1.119.0",
|
"zustand": "^5.0.4"
|
||||||
"@types/node": "^22.15.3",
|
},
|
||||||
"@types/react": "^19.0.8",
|
"devDependencies": {
|
||||||
"@types/react-dom": "^19.0.3",
|
"@eslint/js": "^9.18.0",
|
||||||
"@vite-pwa/assets-generator": "^0.2.6",
|
"@tanstack/react-router-devtools": "^1.119.1",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@tanstack/router-plugin": "^1.119.0",
|
||||||
"eslint": "^9.18.0",
|
"@types/bun": "^1.2.12",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"@types/node": "^22.15.3",
|
||||||
"eslint-plugin-react-refresh": "^0.4.18",
|
"@types/react": "^19.0.8",
|
||||||
"globals": "^15.14.0",
|
"@types/react-dom": "^19.0.3",
|
||||||
"typescript": "~5.7.2",
|
"@types/uid-safe": "^2.1.5",
|
||||||
"typescript-eslint": "^8.20.0",
|
"@vite-pwa/assets-generator": "^0.2.6",
|
||||||
"vite": "^6.0.11",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"eslint": "^9.18.0",
|
||||||
"workbox-core": "^7.3.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"workbox-window": "^7.3.0"
|
"eslint-plugin-react-refresh": "^0.4.18",
|
||||||
},
|
"globals": "^15.14.0",
|
||||||
"resolutions": {
|
"typescript": "~5.7.2",
|
||||||
"sharp": "0.32.6",
|
"typescript-eslint": "^8.20.0",
|
||||||
"sharp-ico": "0.1.5"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Button } from "~/components/button";
|
import { Button } from "~/components/button";
|
||||||
import type { LinkBookmark } from "~/bookmark";
|
import type { LinkBookmark } from "~/bookmark/bookmark";
|
||||||
|
|
||||||
const testBookmarks: LinkBookmark[] = [
|
const testBookmarks: LinkBookmark[] = [
|
||||||
{
|
{
|
||||||
|
160
src/auth/auth.ts
Normal file
160
src/auth/auth.ts
Normal file
@@ -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<Route extends string>(
|
||||||
|
handler: (request: Bun.BunRequest<Route>, user: User) => Promise<Response>,
|
||||||
|
) {
|
||||||
|
return httpHandler<Route>((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<Response> {
|
||||||
|
forgetAllSessions(user);
|
||||||
|
deleteAllAuthTokensQuery.run({ userId: user.id });
|
||||||
|
return new Response(undefined, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export { authenticated, signUp, login, logout };
|
134
src/auth/session.ts
Normal file
134
src/auth/session.ts
Normal file
@@ -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<string> {
|
||||||
|
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,
|
||||||
|
};
|
35
src/bookmark/handlers.ts
Normal file
35
src/bookmark/handlers.ts
Normal file
@@ -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 };
|
62
src/database.ts
Normal file
62
src/database.ts
Normal file
@@ -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 };
|
27
src/server-main.ts
Normal file
27
src/server-main.ts
Normal file
@@ -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();
|
30
src/server-util.ts
Normal file
30
src/server-util.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
class HttpError {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly message?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpHandler<Route extends string>(
|
||||||
|
handler: (request: Bun.BunRequest<Route>) => Promise<Response>,
|
||||||
|
): (request: Bun.BunRequest<Route>) => Promise<Response> {
|
||||||
|
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 };
|
67
src/user/user.ts
Normal file
67
src/user/user.ts
Normal file
@@ -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 };
|
@@ -6,6 +6,7 @@
|
|||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
Reference in New Issue
Block a user