notes / server.mjs
ghuser1's picture
Update server.mjs
1d48a9e verified
Raw
History Blame Contribute Delete
8.11 kB
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
};
}