Initial Mebbling hub implementation
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const secret = () => new TextEncoder().encode(process.env.SESSION_SECRET || "development-only-change-me");
|
||||
export type Session = { id: number; username: string; role: string };
|
||||
export async function createSession(user: Session) {
|
||||
const token = await new SignJWT(user).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("7d").sign(secret());
|
||||
(await cookies()).set("hub_session", token, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 604800 });
|
||||
}
|
||||
export async function getSession(): Promise<Session | null> {
|
||||
const token = (await cookies()).get("hub_session")?.value; if (!token) return null;
|
||||
try { return (await jwtVerify(token, secret())).payload as unknown as Session; } catch { return null; }
|
||||
}
|
||||
export async function requireUser() { const user = await getSession(); if (!user) throw new Error("Unauthorized"); return user; }
|
||||
export async function clearSession() { (await cookies()).delete("hub_session"); }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
|
||||
function key() {
|
||||
const value = process.env.TOKEN_ENCRYPTION_KEY;
|
||||
if (!value || !/^[0-9a-f]{64}$/i.test(value)) throw new Error("TOKEN_ENCRYPTION_KEY must be 64 hexadecimal characters");
|
||||
return Buffer.from(value, "hex");
|
||||
}
|
||||
export function encrypt(value: string) {
|
||||
const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", key(), iv);
|
||||
const body = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64url");
|
||||
}
|
||||
export function decrypt(value: string) {
|
||||
const raw = Buffer.from(value, "base64url"); const decipher = createDecipheriv("aes-256-gcm", key(), raw.subarray(0, 12));
|
||||
decipher.setAuthTag(raw.subarray(12, 28)); return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const path = process.env.HUB_BUILD === "1" ? ":memory:" : (process.env.DATABASE_PATH || "./data/hub.db");
|
||||
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
||||
export const db = new Database(path);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.pragma("busy_timeout = 5000");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, disabled INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sources (
|
||||
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL, base_url TEXT NOT NULL, token_encrypted TEXT NOT NULL, remote_user TEXT,
|
||||
webhook_supported INTEGER NOT NULL DEFAULT 0, sync_status TEXT NOT NULL DEFAULT 'pending', last_synced_at TEXT, last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, base_url)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY, source_id INTEGER REFERENCES sources(id) ON DELETE SET NULL,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_memo_name TEXT, content TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL DEFAULT 'PUBLIC', tags_json TEXT NOT NULL DEFAULT '[]', attachments_json TEXT NOT NULL DEFAULT '[]',
|
||||
origin TEXT NOT NULL DEFAULT 'memos', remote_created_at TEXT, remote_updated_at TEXT, sync_status TEXT NOT NULL DEFAULT 'synced',
|
||||
hidden INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(source_id, remote_memo_name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, hidden INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(post_id, user_id, emoji)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, reporter_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
reason TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, resolved INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sync_jobs (
|
||||
id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL, payload_json TEXT, status TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, run_after TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS source_members (
|
||||
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(source_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS posts_public_idx ON posts(visibility, hidden, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS sync_jobs_idx ON sync_jobs(status, run_after);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS source_remote_identity_unique ON sources(base_url, remote_user) WHERE remote_user IS NOT NULL;
|
||||
`);
|
||||
|
||||
db.exec("INSERT OR IGNORE INTO source_members(source_id,user_id,role) SELECT id,user_id,'owner' FROM sources");
|
||||
|
||||
const sourceColumns = db.prepare("PRAGMA table_info(sources)").all() as { name: string }[];
|
||||
if (!sourceColumns.some((column) => column.name === "webhook_secret_hash")) {
|
||||
db.exec("ALTER TABLE sources ADD COLUMN webhook_secret_hash TEXT");
|
||||
}
|
||||
if (!sourceColumns.some((column) => column.name === "last_webhook_at")) {
|
||||
db.exec("ALTER TABLE sources ADD COLUMN last_webhook_at TEXT");
|
||||
}
|
||||
|
||||
const admin = process.env.ADMIN_USERNAME;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
if (admin && adminPassword) {
|
||||
// Keep bootstrap safe when Next preloads multiple route modules concurrently.
|
||||
const bcrypt = require("bcryptjs");
|
||||
db.prepare("INSERT OR IGNORE INTO users(username, password_hash, role) VALUES (?, ?, 'admin')").run(admin, bcrypt.hashSync(adminPassword, 12));
|
||||
}
|
||||
|
||||
const seedUrl = process.env.SEED_MEMOS_URL?.replace(/\/$/, "");
|
||||
const seedToken = process.env.SEED_MEMOS_TOKEN;
|
||||
if (admin && seedUrl && seedToken) {
|
||||
const user = db.prepare("SELECT id FROM users WHERE username=?").get(admin) as { id: number } | undefined;
|
||||
const source = db.prepare("SELECT id FROM sources WHERE user_id=? AND base_url=?").get(user?.id, seedUrl) as { id: number } | undefined;
|
||||
if (user && !source) {
|
||||
const { encrypt } = require("./crypto") as typeof import("./crypto");
|
||||
db.prepare("INSERT OR IGNORE INTO sources(user_id,name,base_url,token_encrypted,sync_status) VALUES(?,?,?,?, 'queued')").run(user.id, process.env.SEED_MEMOS_NAME || "Initial Memos", seedUrl, encrypt(seedToken));
|
||||
const inserted = db.prepare("SELECT id FROM sources WHERE user_id=? AND base_url=?").get(user.id, seedUrl) as { id: number };
|
||||
db.prepare("INSERT INTO sync_jobs(source_id,kind) SELECT ?, 'pull' WHERE NOT EXISTS (SELECT 1 FROM sync_jobs WHERE source_id=? AND kind='pull' AND status IN ('queued','running'))").run(inserted.id, inserted.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function externalUrl(request: Request, pathname: string) {
|
||||
const current = new URL(request.url);
|
||||
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || current.host;
|
||||
const protocol = request.headers.get("x-forwarded-proto") || current.protocol.replace(":", "");
|
||||
return new URL(pathname, `${protocol}://${host}`);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type MemosMemo = { name: string; content: string; visibility: string; createTime?: string; updateTime?: string; tags?: string[]; attachments?: unknown[]; resources?: unknown[] };
|
||||
const base = (url: string) => url.replace(/\/+$/, "") + "/api/v1";
|
||||
async function request(url: string, token: string, init?: RequestInit) {
|
||||
const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) }, cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Memos API ${res.status}: ${await res.text()}`); return res;
|
||||
}
|
||||
export async function verifyMemos(baseUrl: string, token: string) { await request(`${base(baseUrl)}/memos?pageSize=1`, token); }
|
||||
export async function getMemosIdentity(baseUrl: string, token: string) {
|
||||
const user = await (await request(`${base(baseUrl)}/auth/status`, token, { method: "POST", body: "{}" })).json() as { name: string; username?: string };
|
||||
if (!user.name) throw new Error("Memos did not return an account identity");
|
||||
return user;
|
||||
}
|
||||
export async function listMemos(baseUrl: string, token: string) {
|
||||
const all: MemosMemo[] = []; let pageToken = "";
|
||||
do { const res = await request(`${base(baseUrl)}/memos?pageSize=100${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`, token); const data = await res.json(); all.push(...(data.memos || [])); pageToken = data.nextPageToken || ""; } while (pageToken);
|
||||
return all.filter((memo) => memo.visibility === "PUBLIC");
|
||||
}
|
||||
export async function createMemo(baseUrl: string, token: string, memo: Pick<MemosMemo, "content" | "visibility"> & { attachments?: unknown[]; resources?: unknown[] }) {
|
||||
return (await request(`${base(baseUrl)}/memos`, token, { method: "POST", body: JSON.stringify({ state: "NORMAL", ...memo }) })).json() as Promise<MemosMemo>;
|
||||
}
|
||||
export async function createAttachment(baseUrl: string, token: string, attachment: { filename: string; content: string; type: string }) {
|
||||
return (await request(`${base(baseUrl)}/attachments`, token, { method: "POST", body: JSON.stringify(attachment) })).json() as Promise<{ name: string; filename: string; type: string }>;
|
||||
}
|
||||
export async function setMemoAttachments(baseUrl: string, token: string, memoName: string, attachments: unknown[]) {
|
||||
const memoId = memoName.split("/").at(-1);
|
||||
if (!memoId) throw new Error("Invalid Memos memo name");
|
||||
await request(`${base(baseUrl)}/memos/${encodeURIComponent(memoId)}/attachments`, token, { method: "PATCH", body: JSON.stringify({ name: memoName, attachments }) });
|
||||
}
|
||||
export async function createResource(baseUrl: string, token: string, resource: { filename: string; content: string; type: string; size: string }) {
|
||||
return (await request(`${base(baseUrl)}/resources`, token, { method: "POST", body: JSON.stringify(resource) })).json() as Promise<{ name: string; filename: string; type: string; size: string }>;
|
||||
}
|
||||
export async function createRemoteFile(baseUrl: string, token: string, file: { filename: string; content: string; type: string; size: string }) {
|
||||
try { return { kind: "attachment" as const, value: await createAttachment(baseUrl, token, file) }; }
|
||||
catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.startsWith("Memos API 404")) throw error;
|
||||
return { kind: "resource" as const, value: await createResource(baseUrl, token, file) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const visits = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
export function withinRateLimit(key: string, limit = 30, windowMs = 60_000) {
|
||||
const now = Date.now(); const record = visits.get(key);
|
||||
if (!record || record.resetAt <= now) { visits.set(key, { count: 1, resetAt: now + windowMs }); return true; }
|
||||
if (record.count >= limit) return false;
|
||||
record.count += 1; return true;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export function createWebhookSecret() { return randomBytes(32).toString("base64url"); }
|
||||
export function webhookSecretHash(secret: string) { return createHash("sha256").update(secret).digest("hex"); }
|
||||
export function webhookSecretMatches(secret: string, expectedHash: string | null) {
|
||||
if (!expectedHash) return false;
|
||||
const actual = Buffer.from(webhookSecretHash(secret), "hex");
|
||||
const expected = Buffer.from(expectedHash, "hex");
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
Reference in New Issue
Block a user