| import { serve } from "@hono/node-server"; |
| import { Hono } from "hono"; |
| import { deleteCookie, getCookie, setCookie } from "hono/cookie"; |
| import { mkdirSync } from "node:fs"; |
| import { readFile } from "node:fs/promises"; |
| import { extname, join, normalize } from "node:path"; |
| import { createSessionToken, hashPassword, hashSessionToken, verifyPassword } from "./auth.mjs"; |
| import { createRepository } from "./src/data/repository.mjs"; |
| import { maxNoteBodyLength } from "./src/data/state-utils.mjs"; |
|
|
| const port = Number(process.env.PORT || 4173); |
| const root = process.cwd(); |
| const dataDir = join(root, "data"); |
| const app = new Hono(); |
| mkdirSync(dataDir, { recursive: true }); |
| const repository = createRepository({ dbPath: join(dataDir, "notes.db") }); |
| const sessionCookie = "web_notes_session"; |
| const sessionTtlMs = 1000 * 60 * 60 * 24 * 30; |
|
|
| await repository.init(); |
|
|
| app.get("/api/health", (c) => c.json({ ok: true })); |
|
|
| app.get("/api/auth/status", async (c) => { |
| const user = await repository.getUser(); |
| return c.json({ |
| configured: Boolean(user), |
| authenticated: await isAuthenticated(c) |
| }); |
| }); |
|
|
| app.post("/api/auth/setup", async (c) => { |
| const existing = await repository.getUser(); |
| if (existing) return c.json({ ok: false, error: "already_configured" }, 409); |
|
|
| const { password } = await c.req.json(); |
| if (!isValidPassword(password)) { |
| return c.json({ ok: false, error: "password_too_short" }, 400); |
| } |
|
|
| await repository.createUser({ passwordHash: await hashPassword(password) }); |
| await createSession(c); |
| return c.json({ ok: true }); |
| }); |
|
|
| app.post("/api/auth/login", async (c) => { |
| const user = await repository.getUser(); |
| if (!user) return c.json({ ok: false, error: "not_configured" }, 400); |
|
|
| const { password } = await c.req.json(); |
| if (!await verifyPassword(password, user.passwordHash)) { |
| return c.json({ ok: false, error: "invalid_password" }, 401); |
| } |
|
|
| await createSession(c); |
| return c.json({ ok: true }); |
| }); |
|
|
| app.post("/api/auth/logout", async (c) => { |
| const token = getCookie(c, sessionCookie); |
| if (token) { |
| await repository.deleteSession(await hashSessionToken(token)); |
| } |
| deleteCookie(c, sessionCookie, { path: "/" }); |
| return c.json({ ok: true }); |
| }); |
|
|
| app.post("/api/auth/password", async (c) => { |
| const user = await repository.getUser(); |
| if (!user) return c.json({ ok: false, error: "not_configured" }, 400); |
| if (!await isAuthenticated(c)) return c.json({ ok: false, error: "unauthorized" }, 401); |
|
|
| const { currentPassword, nextPassword } = await c.req.json(); |
| if (!await verifyPassword(currentPassword, user.passwordHash)) { |
| return c.json({ ok: false, error: "invalid_password" }, 401); |
| } |
| if (!isValidPassword(nextPassword)) { |
| return c.json({ ok: false, error: "password_too_short" }, 400); |
| } |
|
|
| await repository.updateUserPassword({ passwordHash: await hashPassword(nextPassword) }); |
| await repository.deleteAllSessions(); |
| await createSession(c); |
| return c.json({ ok: true }); |
| }); |
|
|
| app.use("/api/state", async (c, next) => { |
| const user = await repository.getUser(); |
| if (!user) return c.json({ ok: false, error: "not_configured" }, 401); |
| if (!await isAuthenticated(c)) return c.json({ ok: false, error: "unauthorized" }, 401); |
| await next(); |
| }); |
|
|
| app.get("/api/state", async (c) => { |
| return c.json(await repository.readState()); |
| }); |
|
|
| app.put("/api/state", async (c) => { |
| const body = await c.req.json(); |
| const folders = Array.isArray(body.folders) ? body.folders : []; |
| const notes = Array.isArray(body.notes) ? body.notes : []; |
| return c.json(await repository.writeState({ folders, notes })); |
| }); |
|
|
| app.post("/api/notes", async (c) => { |
| const authError = await getAuthError(c); |
| if (authError) return authError; |
| const body = await c.req.json(); |
| const lengthError = validateBodyLength(body.body); |
| if (lengthError) return c.json(lengthError, 413); |
| return c.json(await repository.createNote(body), 201); |
| }); |
|
|
| app.patch("/api/notes/:id", async (c) => { |
| const authError = await getAuthError(c); |
| if (authError) return authError; |
| const body = await c.req.json(); |
| const lengthError = validateBodyLength(body.body); |
| if (lengthError) return c.json(lengthError, 413); |
|
|
| const result = await repository.updateNote(c.req.param("id"), body); |
| if (result.status === "missing") return c.json({ ok: false, error: "not_found" }, 404); |
| if (result.status === "conflict") return c.json({ ok: false, error: "conflict", note: result.note }, 409); |
| return c.json(result.note); |
| }); |
|
|
| app.delete("/api/notes/:id", async (c) => { |
| const authError = await getAuthError(c); |
| if (authError) return authError; |
| await repository.deleteNote(c.req.param("id")); |
| return c.json({ ok: true }); |
| }); |
|
|
| app.post("/api/folders", async (c) => { |
| const authError = await getAuthError(c); |
| if (authError) return authError; |
| const body = await c.req.json(); |
| return c.json(await repository.createFolder(body), 201); |
| }); |
|
|
| app.patch("/api/folders/:id", async (c) => { |
| const authError = await getAuthError(c); |
| if (authError) return authError; |
| const body = await c.req.json(); |
| const result = await repository.updateFolder(c.req.param("id"), body); |
| if (result.status === "missing") return c.json({ ok: false, error: "not_found" }, 404); |
| if (result.status === "conflict") return c.json({ ok: false, error: "conflict", folder: result.folder }, 409); |
| return c.json(result.folder); |
| }); |
|
|
| app.delete("/api/folders/:id", async (c) => { |
| const authError = await getAuthError(c); |
| if (authError) return authError; |
| const result = await repository.deleteFolder(c.req.param("id")); |
| if (result.status === "missing") return c.json({ ok: false, error: "not_found" }, 404); |
| return c.json(result); |
| }); |
|
|
| app.get("*", async (c) => { |
| const url = new URL(c.req.url); |
| const requestPath = url.pathname === "/" ? "/index.html" : url.pathname; |
| const safePath = normalize(requestPath).replace(/^(\.\.[/\\])+/, ""); |
| const filePath = join(root, safePath); |
|
|
| if (!filePath.startsWith(root)) { |
| return c.text("Not found", 404); |
| } |
|
|
| try { |
| const file = await readFile(filePath); |
| return new Response(file, { |
| headers: { |
| "content-type": mimeType(extname(filePath)) |
| } |
| }); |
| } catch { |
| const index = await readFile(join(root, "index.html")); |
| return new Response(index, { |
| headers: { |
| "content-type": "text/html; charset=utf-8" |
| } |
| }); |
| } |
| }); |
|
|
| serve({ fetch: app.fetch, port }, () => { |
| console.log(`备忘录 running at http://127.0.0.1:${port}`); |
| console.log(`Data repository runtime: ${repository.runtime}`); |
| }); |
|
|
| function mimeType(ext) { |
| return { |
| ".html": "text/html; charset=utf-8", |
| ".css": "text/css; charset=utf-8", |
| ".js": "text/javascript; charset=utf-8", |
| ".json": "application/json; charset=utf-8", |
| ".png": "image/png", |
| ".svg": "image/svg+xml; charset=utf-8" |
| }[ext] || "application/octet-stream"; |
| } |
|
|
| async function createSession(c) { |
| const token = createSessionToken(); |
| const expiresAt = Date.now() + sessionTtlMs; |
| await repository.createSession({ |
| tokenHash: await hashSessionToken(token), |
| expiresAt |
| }); |
| setCookie(c, sessionCookie, token, { |
| path: "/", |
| httpOnly: true, |
| sameSite: "Lax", |
| maxAge: Math.floor(sessionTtlMs / 1000), |
| secure: new URL(c.req.url).protocol === "https:" |
| }); |
| } |
|
|
| async function isAuthenticated(c) { |
| const token = getCookie(c, sessionCookie); |
| if (!token) return false; |
| return Boolean(await repository.getSession(await hashSessionToken(token))); |
| } |
|
|
| async function getAuthError(c) { |
| const user = await repository.getUser(); |
| if (!user) return c.json({ ok: false, error: "not_configured" }, 401); |
| if (!await isAuthenticated(c)) return c.json({ ok: false, error: "unauthorized" }, 401); |
| return null; |
| } |
|
|
| function isValidPassword(password) { |
| return typeof password === "string" && password.length >= 8; |
| } |
|
|
| function validateBodyLength(body) { |
| if (body === undefined) return null; |
| if (String(body).length <= maxNoteBodyLength) return null; |
| return { |
| ok: false, |
| error: "note_too_large", |
| maxLength: maxNoteBodyLength |
| }; |
| } |
|
|