implement bookmark delete

This commit is contained in:
2025-05-07 15:47:08 +01:00
parent 30cc4d3fb5
commit e87a6586b6
26 changed files with 763 additions and 149 deletions

View File

@@ -1,4 +1,4 @@
import { type User, DEMO_USER } from "@markone/core/user"
import { DEMO_USER, type User } from "@markone/core/user"
import { type } from "arktype"
import dayjs from "dayjs"
import { ulid } from "ulid"
@@ -6,6 +6,7 @@ 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 { hashPassword, verifyPassword } from "./password.ts"
import { createSessionForUser, extendSession, forgetAllSessions, saveSession, verifySession } from "./session.ts"
const SignUpRequest = type({
@@ -18,20 +19,13 @@ const LoginRequest = type({
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) {
console.log("session not found!")
throw new HttpError(401)
}
@@ -42,6 +36,8 @@ function authenticated<Route extends string>(
const authTokenCookie = request.cookies.get("auth-token")
if (authTokenCookie) {
const deleteAuthTokenQuery = db.query("DELETE FROM auth_tokens WHERE id = $id")
// 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 })
@@ -69,6 +65,10 @@ function rememberLoginForUser(user: User, cookies: Bun.CookieMap) {
const expiryDate = dayjs().add(1, "month")
const createAuthTokenQuery = db.query(
"INSERT INTO auth_tokens(id, token, user_id, expires_at_unix_ms) VALUES ($id, $token, $userId, $expiresAt)",
)
createAuthTokenQuery.run({
id: tokenId,
token: hashedToken,
@@ -78,6 +78,7 @@ function rememberLoginForUser(user: User, cookies: Bun.CookieMap) {
cookies.set("auth-token", `${tokenId}:${authToken.toBase64({ alphabet: "base64url" })}`, {
maxAge: 30 * 24 * 60 * 60 * 1000,
path: "/api",
httpOnly: true,
})
}
@@ -93,7 +94,7 @@ async function signUp(request: Bun.BunRequest<"/api/sign-up">) {
}
const { username, password } = signUpRequest
const hashedPassword = await Bun.password.hash(password, "argon2id")
const hashedPassword = await hashPassword(password)
const user = createUser(username, hashedPassword)
await createSessionForUser(user, request.cookies)
@@ -116,10 +117,10 @@ async function login(request: Bun.BunRequest<"/api/login">) {
password: true,
})
if (!foundUser) {
throw new HttpError(400)
throw new HttpError(401)
}
const ok = await Bun.password.verify(loginRequest.password, foundUser.password, "argon2id").catch(() => {
const ok = await verifyPassword(loginRequest.password, foundUser.password).catch(() => {
throw new HttpError(401)
})
if (!ok) {
@@ -131,8 +132,8 @@ async function login(request: Bun.BunRequest<"/api/login">) {
username: foundUser.username,
}
if (user.id === DEMO_USER.id) {
await createSessionForUser(user, request.cookies)
await createSessionForUser(user, request.cookies)
if (user.id !== DEMO_USER.id) {
rememberLoginForUser(user, request.cookies)
}
@@ -140,6 +141,8 @@ async function login(request: Bun.BunRequest<"/api/login">) {
}
async function logout(request: Bun.BunRequest<"/api/logout">, user: User): Promise<Response> {
const deleteAllAuthTokensQuery = db.query("DELETE FROM auth_tokens WHERE user_id = $userId")
forgetAllSessions(user)
deleteAllAuthTokensQuery.run({ userId: user.id })
return new Response(undefined, { status: 200 })

View File

@@ -0,0 +1,11 @@
const HASH_ALGORITHM = "argon2id"
async function hashPassword(input: string): Promise<string> {
return await Bun.password.hash(input, HASH_ALGORITHM)
}
async function verifyPassword(input: string, hash: string) {
return await Bun.password.verify(input, hash, HASH_ALGORITHM)
}
export { hashPassword, verifyPassword }

View File

@@ -15,21 +15,8 @@ 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() {
const deleteExpiredSessionsQuery = db.query("DELETE FROM sessions WHERE expires_at_unix_ms < $time")
setInterval(() => {
deleteExpiredSessionsQuery.run({ time: dayjs().valueOf() })
}, 5000)
@@ -48,9 +35,11 @@ function signSessionId(sessionId: string): string {
async function createSessionForUser(user: User, cookies: Bun.CookieMap) {
const sessionId = await newSessionId()
const signedSessionId = signSessionId(sessionId)
const expiryDate = dayjs().add(30, "minutes").valueOf()
const saveSessionQuery = db.query(
"INSERT INTO sessions (session_id, user_id, expires_at_unix_ms) VALUES ($sessionId, $userId, $expiresAt)",
)
saveSessionQuery.run({
sessionId,
userId: user.id,
@@ -59,13 +48,17 @@ async function createSessionForUser(user: User, cookies: Bun.CookieMap) {
cookies.set(SESSION_ID_COOKIE_NAME, signedSessionId, {
maxAge: user.id === DEMO_USER.id ? undefined : SESSION_DURATION_MS,
path: "/api",
httpOnly: true,
})
console.log("session created for user", user.id)
}
async function saveSession(session: Session, cookies: Bun.CookieMap) {
cookies.set(SESSION_ID_COOKIE_NAME, session.signedId, {
maxAge: SESSION_DURATION_MS,
path: "/api",
httpOnly: true,
})
}
@@ -73,6 +66,7 @@ async function saveSession(session: Session, cookies: Bun.CookieMap) {
function verifySession(cookie: Bun.CookieMap): Session | null {
const signedSessionId = cookie.get(SESSION_ID_COOKIE_NAME)
if (!signedSessionId) {
console.log("no cookie")
return null
}
@@ -84,18 +78,23 @@ function verifySession(cookie: Bun.CookieMap): Session | null {
const isEqual = a.length === b.length && crypto.timingSafeEqual(a, b)
if (!isEqual) {
console.log("not equal")
return null
}
const findSessionQuery = db.query("SELECT user_id, expires_at_unix_ms FROM sessions WHERE session_id = $sessionId")
const row = findSessionQuery.get({ sessionId: value })
if (!row) {
console.log("no 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) {
const deleteSessionQuery = db.query("DELETE FROM sessions WHERE session_id = $sessionId")
deleteSessionQuery.run({ sessionId: value })
console.log("session expired!")
return null
}
@@ -110,6 +109,9 @@ function verifySession(cookie: Bun.CookieMap): Session | null {
function extendSession(session: Session): Session {
const newExpiryDate = dayjs().add(30, "minutes").valueOf()
const extendSessionQuery = db.query(
"UPDATE sessions SET expires_at_unix_ms = $newExpiryDate WHERE session_id = $session_id",
)
extendSessionQuery.run({
sessionId: session.id,
newExpiryDate,
@@ -121,6 +123,7 @@ function extendSession(session: Session): Session {
}
function forgetAllSessions(user: User) {
const forgetAllSessionsQuery = db.query("DELETE FROM sessions WHERE user_id = $userId")
forgetAllSessionsQuery.run({ userId: user.id })
}

View File

@@ -0,0 +1,24 @@
import type { User } from "@markone/core/user"
import { db } from "~/database.ts"
import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts"
function insertDemoBookmarks(user: User) {
const query = db.query(`
INSERT OR IGNORE INTO bookmarks (id, user_id, kind, title, url)
VALUES ($id, $userId, $kind, $title, $url)
`)
const insert = db.transaction((bookmarks) => {
for (const bookmark of bookmarks) {
query.run({
id: bookmark.id,
userId: user.id,
kind: bookmark.kind,
title: bookmark.title,
url: bookmark.url,
})
}
})
insert(DEMO_BOOKMARKS)
}
export { insertDemoBookmarks }

View File

@@ -0,0 +1,66 @@
import type { Bookmark } from "@markone/core/bookmark"
const DEMO_BOOKMARKS: Bookmark[] = [
{
kind: "link",
id: "01JTKBKVMWRRDR20PY9X4HGPVY",
title: "ULID Specification",
url: "https://github.com/ulid/spec",
},
{
kind: "link",
id: "01JTKBKVMW8392A9361PFGZEGM",
title: "Another Example Site",
url: "https://www.example.net",
},
{
kind: "link",
id: "01JTKBKVMW3YKY4CMANEK7502N",
title: "Documentation Hub",
url: "https://docs.example.com",
},
{
kind: "link",
id: "01JTKBKVMWHH7QG0CFQ6Q40E2G",
title: "API Reference",
url: "https://api.example.com/docs",
},
{
kind: "link",
id: "01JTKBKVMWVR4T3KXXB3PHZD60",
title: "Blog Posts",
url: "https://blog.example.com",
},
{
kind: "link",
id: "01JTKBKVMWAP0GGKWBN7Z3CYAM",
title: "Support Forum",
url: "https://forum.example.com",
},
{
kind: "link",
id: "01JTKBKVMW1YJB63F2YCMZNGFE",
title: "Tutorials",
url: "https://tutorials.example.com",
},
{
kind: "link",
id: "01JTKBKVMW93QYS2EK983RQS40",
title: "Resource Library",
url: "https://resources.example.com",
},
{
kind: "link",
id: "01JTKBKVMW6NG61138T8SCRFYT",
title: "Community Page",
url: "https://community.example.com",
},
{
kind: "link",
id: "01JTKBKVMWDBKSXYHGFXC0HEZB",
title: "Project Repository",
url: "https://github.com/example/project",
},
]
export { DEMO_BOOKMARKS }

View File

@@ -1,3 +1,4 @@
import { DEMO_USER } from "@markone/core/user"
import { type } from "arktype"
import { db } from "~/database.ts"
import { HttpError } from "~/error.ts"
@@ -10,16 +11,16 @@ const ListUserBookmarksParams = type({
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 listBookmarksQuery = db.query(
"SELECT id, kind, title, url FROM bookmarks WHERE user_id = $userId ORDER BY id LIMIT $limit OFFSET $skip",
)
const results = listBookmarksQuery.all({
userId: user.id,
limit: queryParams.limit,
@@ -29,4 +30,16 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user
return Response.json(results, { status: 200 })
}
export { listUserBookmarks }
async function deleteUserBookmark(request: Bun.BunRequest<"/api/bookmark/:id">, user: User) {
console.log("askldjlskajdkl")
if (user.id !== DEMO_USER.id) {
const deleteBookmarkQuery = db.query("DELETE FROM bookmarks WHERE user_id = $userId AND id = $id")
const tx = db.transaction(() => {
deleteBookmarkQuery.run({ userId: user.id, id: request.params.id })
})
tx()
}
return Response.json(undefined, { status: 204 })
}
export { listUserBookmarks, deleteUserBookmark }

View File

@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite"
const SCHEMA_VERSION = 0
const db = new Database("data.sqlite")
const db = new Database("data.sqlite", { strict: true })
const createMetadataTableQuery = db.query(`
CREATE TABLE IF NOT EXISTS metadata(
@@ -12,15 +12,12 @@ const createMetadataTableQuery = db.query(`
);
`)
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,
password TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bookmarks(
@@ -47,17 +44,22 @@ CREATE TABLE IF NOT EXISTS auth_tokens(
`,
]
const executeMigrations = db.transaction((migration) => {
db.run(migration)
const executeMigrations = db.transaction((migrations) => {
for (const migration of migrations) {
db.run(migration)
}
})
function migrateDatabase() {
createMetadataTableQuery.run()
const schemaVersionQuery = db.query("SELECT value FROM metadata WHERE key = 'schema_version'")
const setSchemaVersionQuery = db.query("UPDATE metadata SET value = $schemaVersion WHERE key = 'schema_version'")
const row = schemaVersionQuery.get()
let currentVersion: number
if (row) {
currentVersion = (row as { version: number }).version
currentVersion = (row as { value: number }).value
console.log(`Migrating database from version ${currentVersion} to version ${SCHEMA_VERSION}...`)
} else {
currentVersion = -1

View File

@@ -1,22 +1,46 @@
import { HttpError } from "./error.ts"
type HttpMethod = "GET" | "POST" | "DELETE" | "PUT" | "OPTIONS" | "PATCH"
const ALLOWED_ORIGINS = ["http://localhost:5173"]
function httpHandler<Route extends string>(
handler: (request: Bun.BunRequest<Route>) => Promise<Response>,
): (request: Bun.BunRequest<Route>) => Promise<Response> {
return async (request) => {
let response: Response
try {
const response = await handler(request)
return response
response = await handler(request)
} catch (error) {
if (error instanceof HttpError) {
if (error.message) {
return Response.json({ message: error.message }, { status: error.status })
response = Response.json({ message: error.message }, { status: error.status })
} else {
response = new Response(undefined, { status: error.status })
}
return new Response(undefined, { status: error.status })
} else {
console.error(error)
response = new Response(undefined, { status: 500 })
}
return new Response(undefined, { status: 500 })
}
for (const origin of ALLOWED_ORIGINS) {
response.headers.set("Access-Control-Allow-Origin", origin)
}
response.headers.set("Access-Control-Allow-Credentials", "true")
return response
}
}
export { httpHandler }
function preflightHandler<Route extends string>({ allowedMethods }: { allowedMethods: HttpMethod[] }) {
return async (request: Bun.BunRequest<Route>) =>
new Response(undefined, {
status: 200,
headers: {
"Access-Control-Allow-Origin": ALLOWED_ORIGINS,
"Access-Control-Allow-Methods": allowedMethods.join(", "),
"Access-Control-Allow-Credentials": "true",
},
})
}
export { httpHandler, preflightHandler }

View File

@@ -1,13 +1,15 @@
import { authenticated, login, logout, signUp } from "./auth/auth.ts"
import { startBackgroundSessionCleanup } from "./auth/session.ts"
import { listUserBookmarks } from "./bookmark/handlers.ts"
import { insertDemoBookmarks } from "./bookmark/bookmark.ts"
import { listUserBookmarks, deleteUserBookmark } from "./bookmark/handlers.ts"
import { migrateDatabase } from "./database.ts"
import { httpHandler } from "./http-handler.ts"
import { httpHandler, preflightHandler } from "./http-handler.ts"
import { createDemoUser } from "./user/user.ts"
function main() {
async function main() {
migrateDatabase()
createDemoUser()
const user = await createDemoUser()
insertDemoBookmarks(user)
startBackgroundSessionCleanup()
Bun.serve({
@@ -24,7 +26,15 @@ function main() {
"/api/bookmarks": {
GET: authenticated(listUserBookmarks),
},
"/api/bookmark/:id": {
DELETE: authenticated(deleteUserBookmark),
OPTIONS: preflightHandler({
allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"],
}),
},
},
port: 8080,
})
}

View File

@@ -1,20 +1,17 @@
import { type User, DEMO_USER } from "@markone/core/user"
import { DEMO_USER, type User } from "@markone/core/user"
import { ulid } from "ulid"
import { db } from "~/database.ts"
import { DEMO_BOOKMARKS } from "~/bookmark/demo-bookmarks.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 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 row = (password ? findUserByUsernameWithPwQuery : findUserByUsernameQuery).get({ username })
if (!row) {
return null
@@ -23,6 +20,7 @@ function findUserByUsername(username: string, { password }: { password?: boolean
}
function findUserById(userId: string): User | null {
const findUserByIdQuery = db.query("SELECT id, username FROM users WHERE id = $userId")
const row = findUserByIdQuery.get({ userId })
if (!row) {
return null
@@ -31,6 +29,7 @@ function findUserById(userId: string): User | null {
}
function createUser(username: string, password: string): User {
const createUserQuery = db.query("INSERT INTO users(id, username, password) VALUES ($id, $username, $password)")
const userId = ulid()
createUserQuery.run({
id: userId,
@@ -43,19 +42,17 @@ function createUser(username: string, password: string): User {
}
}
async function createDemoUser() {
const row = findUserByUsernameQuery.get({ username: DEMO_USER.username })
if (row) {
return
}
async function createDemoUser(): Promise<User> {
const createUserQuery = db.query(
"INSERT OR REPLACE INTO users(id, username, password) VALUES ($id, $username, $password)",
)
const hashedPassword = await Bun.password.hash(DEMO_USER.unhashedPassword, "argon2id")
createUserQuery.run({
id: DEMO_USER.id,
username: DEMO_USER.username,
password: hashedPassword,
})
return DEMO_USER
}
export type { User }