switch to monorepo structure

This commit is contained in:
2025-05-06 11:00:35 +01:00
parent e1f927ad27
commit 07b7f1b51f
63 changed files with 2440 additions and 1011 deletions

View File

@@ -0,0 +1,148 @@
import { type User, DEMO_USER } from "@markone/core/user"
import { type } from "arktype"
import dayjs from "dayjs"
import { ulid } from "ulid"
import { db } from "~/database.ts"
import { HttpError } from "~/error.ts"
import { httpHandler } from "~/http-handler.ts"
import { createUser, findUserById, findUserByUsername } from "~/user/user.ts"
import { createSessionForUser, extendSession, forgetAllSessions, saveSession, verifySession } from "./session.ts"
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) {
// biome-ignore lint/style/noNonNullAssertion: the cookie has already been verified by verifySession previously, therefore the cookie must be in the correct format <token-id>:<token-signature>
const tokenId = authTokenCookie.split(":")[0]!
deleteAuthTokenQuery.run({ id: tokenId })
rememberLoginForUser(user, request.cookies)
}
if (user.id !== DEMO_USER.id) {
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,
}
if (user.id === DEMO_USER.id) {
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 }

View File

@@ -0,0 +1,135 @@
import dayjs from "dayjs"
import uid from "uid-safe"
import { db } from "~/database.ts"
import { type User, DEMO_USER } from "~/user/user.ts"
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 deleteExpiredSessionsQuery = db.query("DELETE FROM sessions WHERE expires_at_unix_ms < $time")
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",
)
function startBackgroundSessionCleanup() {
setInterval(() => {
deleteExpiredSessionsQuery.run({ time: dayjs().valueOf() })
}, 5000)
}
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: user.id === DEMO_USER.id ? undefined : 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 {
startBackgroundSessionCleanup,
newSessionId,
createSessionForUser,
verifySession,
saveSession,
extendSession,
forgetAllSessions,
}

View File

@@ -0,0 +1,32 @@
import { type } from "arktype"
import { db } from "~/database.ts"
import { HttpError } from "~/error.ts"
import type { User } from "~/user/user.ts"
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 }

View File

@@ -0,0 +1,76 @@
import { Database } from "bun:sqlite"
const SCHEMA_VERSION = 0
const db = new Database("data.sqlite")
const createMetadataTableQuery = db.query(`
CREATE TABLE IF NOT EXISTS metadata(
key TEXT NOT NULL PRIMARY KEY,
value,
UNIQUE(key)
);
`)
const schemaVersionQuery = db.query("SELECT version FROM metadata WHERE key = 'schema_version'")
const setSchemaVersionQuery = db.query("UPDATE metadata SET value = $schemaVersion WHERE key = 'schema_version'")
const migrations = [
`
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
);
`,
]
const executeMigrations = db.transaction((migration) => {
db.run(migration)
})
function migrateDatabase() {
createMetadataTableQuery.run()
const row = schemaVersionQuery.get()
let currentVersion: number
if (row) {
currentVersion = (row as { version: number }).version
console.log(`Migrating database from version ${currentVersion} to version ${SCHEMA_VERSION}...`)
} else {
currentVersion = -1
console.log("Initializing database...")
}
if (currentVersion < SCHEMA_VERSION) {
executeMigrations(migrations.slice(currentVersion + 1, SCHEMA_VERSION + 1))
setSchemaVersionQuery.run({ schemaVersion: SCHEMA_VERSION })
console.log("Database successfully migrated!")
} else {
console.error("Rolling back database to a previous version is unsupported. Are you trying to downgrade MARKONE?")
}
}
export { db, migrateDatabase }

View File

@@ -0,0 +1,8 @@
class HttpError {
constructor(
public readonly status: number,
public readonly message?: string,
) {}
}
export { HttpError }

View File

@@ -0,0 +1,22 @@
import { HttpError } from "./error.ts"
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 { httpHandler }

View File

@@ -0,0 +1,31 @@
import { authenticated, login, logout, signUp } from "./auth/auth.ts"
import { startBackgroundSessionCleanup } from "./auth/session.ts"
import { listUserBookmarks } from "./bookmark/handlers.ts"
import { migrateDatabase } from "./database.ts"
import { httpHandler } from "./http-handler.ts"
import { createDemoUser } from "./user/user.ts"
function main() {
migrateDatabase()
createDemoUser()
startBackgroundSessionCleanup()
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()

View File

@@ -0,0 +1,62 @@
import { type User, DEMO_USER } from "@markone/core/user"
import { ulid } from "ulid"
import { db } from "~/database.ts"
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,
}
}
async function createDemoUser() {
const row = findUserByUsernameQuery.get({ username: DEMO_USER.username })
if (row) {
return
}
const hashedPassword = await Bun.password.hash(DEMO_USER.unhashedPassword, "argon2id")
createUserQuery.run({
id: DEMO_USER.id,
username: DEMO_USER.username,
password: hashedPassword,
})
}
export type { User }
export { DEMO_USER, createDemoUser, createUser, findUserByUsername, findUserById }