import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared"; import { httpError } from "../lib/httpErrors.js"; import { requireAdmin, isAdminEmail } from "../lib/admin.js"; import { bookmarkRowToDto, folderRowToDto, userRowToDto } from "../lib/rows.js"; import { encryptPassword, decryptPassword } from "../lib/crypto.js"; function normalizeOrigin(input) { try { const u = new URL(String(input || "").trim()); return u.origin; } catch { throw httpError(400, "siteOrigin invalid"); } } function toUserDtoWithAdminOverride(app, row) { const dto = userRowToDto(row); if (isAdminEmail(app, dto.email)) dto.role = "admin"; return dto; } export async function adminRoutes(app) { app.get( "/admin/users", { preHandler: [async (req) => requireAdmin(app, req)] }, async () => { const res = await app.pg.query( "select id, email, role, created_at, updated_at from users order by created_at desc limit 500" ); return res.rows.map((r) => toUserDtoWithAdminOverride(app, r)); } ); app.get( "/admin/users/:id/folders", { preHandler: [async (req) => requireAdmin(app, req)] }, async (req) => { const userId = req.params?.id; if (!userId) throw httpError(400, "user id required"); const orderBy = app.features?.folderSortOrder ? "parent_id nulls first, sort_order asc, name asc" : "parent_id nulls first, name asc"; const res = await app.pg.query( `select * from bookmark_folders where user_id=$1 order by ${orderBy} limit 1000`, [userId] ); return res.rows.map(folderRowToDto); } ); app.get( "/admin/users/:id/bookmarks", { preHandler: [async (req) => requireAdmin(app, req)] }, async (req) => { const userId = req.params?.id; if (!userId) throw httpError(400, "user id required"); const q = (req.query?.q || "").trim(); const params = [userId]; let where = "where user_id=$1 and deleted_at is null"; if (q) { params.push(`%${q}%`); where += ` and (title ilike $${params.length} or url ilike $${params.length})`; } const orderBy = app.features?.bookmarkSortOrder ? "folder_id nulls first, sort_order asc, updated_at desc" : "updated_at desc"; const res = await app.pg.query( `select * from bookmarks ${where} order by ${orderBy} limit 500`, params ); return res.rows.map(bookmarkRowToDto); } ); app.get( "/admin/users/:id/credentials", { preHandler: [async (req) => requireAdmin(app, req)] }, async (req) => { const userId = req.params?.id; if (!userId) throw httpError(400, "user id required"); const includePassword = String(req.query?.includePassword || "").toLowerCase() === "true"; const res = await app.pg.query( "select * from credentials where user_id=$1 order by updated_at desc", [userId] ); if (!includePassword) { return res.rows.map((r) => ({ id: r.id, userId: r.user_id, siteOrigin: r.site_origin, username: r.username, password: null, createdAt: r.created_at, updatedAt: r.updated_at })); } const key = process.env.CREDENTIAL_MASTER_KEY; return res.rows.map((r) => ({ id: r.id, userId: r.user_id, siteOrigin: r.site_origin, username: r.username, password: decryptPassword({ cipherText: r.password_enc, iv: r.password_iv, tag: r.password_tag }, key), createdAt: r.created_at, updatedAt: r.updated_at })); } ); app.patch( "/admin/users/:userId/credentials/:credentialId", { preHandler: [async (req) => requireAdmin(app, req)] }, async (req) => { const userId = req.params?.userId; const credentialId = req.params?.credentialId; if (!userId || !credentialId) throw httpError(400, "userId and credentialId required"); const body = req.body || {}; const sets = []; const params = []; let i = 1; if (Object.prototype.hasOwnProperty.call(body, "siteOrigin")) { const origin = normalizeOrigin(body.siteOrigin); sets.push(`site_origin=$${i++}`); params.push(origin); } if (Object.prototype.hasOwnProperty.call(body, "username")) { const name = String(body.username || "").trim(); if (!name) throw httpError(400, "username required"); sets.push(`username=$${i++}`); params.push(name); } if (Object.prototype.hasOwnProperty.call(body, "password")) { const pwd = String(body.password || ""); if (!pwd) throw httpError(400, "password required"); const key = process.env.CREDENTIAL_MASTER_KEY; const enc = encryptPassword(pwd, key); sets.push(`password_enc=$${i++}`); params.push(enc.cipherText); sets.push(`password_iv=$${i++}`); params.push(enc.iv); sets.push(`password_tag=$${i++}`); params.push(enc.tag); } if (sets.length === 0) throw httpError(400, "no fields to update"); params.push(credentialId, userId); const res = await app.pg.query( `update credentials set ${sets.join(", ")}, updated_at=now() where id=$${i++} and user_id=$${i} returning *`, params ); if (!res.rows[0]) throw httpError(404, "credential not found"); return { id: res.rows[0].id, userId: res.rows[0].user_id, siteOrigin: res.rows[0].site_origin, username: res.rows[0].username, password: null, createdAt: res.rows[0].created_at, updatedAt: res.rows[0].updated_at }; } ); app.delete( "/admin/users/:userId/credentials/:credentialId", { preHandler: [async (req) => requireAdmin(app, req)] }, async (req) => { const userId = req.params?.userId; const credentialId = req.params?.credentialId; if (!userId || !credentialId) throw httpError(400, "userId and credentialId required"); const res = await app.pg.query( "delete from credentials where id=$1 and user_id=$2 returning id", [credentialId, userId] ); if (!res.rows[0]) throw httpError(404, "credential not found"); return { ok: true }; } ); app.delete( "/admin/users/:userId/bookmarks/:bookmarkId", { preHandler: [async (req) => requireAdmin(app, req)] }, async (req) => { const userId = req.params?.userId; const bookmarkId = req.params?.bookmarkId; if (!userId || !bookmarkId) throw httpError(400, "userId and bookmarkId required"); const res = await app.pg.query( "update bookmarks set deleted_at=now(), updated_at=now() where id=$1 and user_id=$2 and deleted_at is null returning *", [bookmarkId, userId] ); if (!res.rows[0]) throw httpError(404, "bookmark not found"); return bookmarkRowToDto(res.rows[0]); } ); app.delete( "/admin/users/:userId/folders/:folderId", { preHandler: [async (req) => requireAdmin(app, req)] }, async (req) => { const userId = req.params?.userId; const folderId = req.params?.folderId; if (!userId || !folderId) throw httpError(400, "userId and folderId required"); const res = await app.pg.query( "delete from bookmark_folders where id=$1 and user_id=$2 returning *", [folderId, userId] ); if (!res.rows[0]) throw httpError(404, "folder not found"); return folderRowToDto(res.rows[0]); } ); app.post( "/admin/users/:userId/bookmarks/:bookmarkId/copy-to-me", { preHandler: [async (req) => requireAdmin(app, req)] }, async (req) => { const sourceUserId = req.params?.userId; const bookmarkId = req.params?.bookmarkId; const adminUserId = req.adminUser?.id; if (!sourceUserId || !bookmarkId) throw httpError(400, "userId and bookmarkId required"); if (!adminUserId) throw httpError(401, "unauthorized"); const srcRes = await app.pg.query( "select * from bookmarks where id=$1 and user_id=$2 and deleted_at is null", [bookmarkId, sourceUserId] ); const src = srcRes.rows[0]; if (!src) throw httpError(404, "bookmark not found"); const urlNormalized = normalizeUrl(src.url); const urlHash = computeUrlHash(urlNormalized); const existing = await app.pg.query( "select * from bookmarks where user_id=$1 and url_hash=$2 and deleted_at is null limit 1", [adminUserId, urlHash] ); if (existing.rows[0]) { const merged = await app.pg.query( `update bookmarks set title=$1, url=$2, url_normalized=$3, visibility='private', folder_id=null, source='manual', updated_at=now() where id=$4 returning *`, [src.title, src.url, urlNormalized, existing.rows[0].id] ); return bookmarkRowToDto(merged.rows[0]); } const res = await app.pg.query( `insert into bookmarks (user_id, folder_id, title, url, url_normalized, url_hash, visibility, source) values ($1, null, $2, $3, $4, $5, 'private', 'manual') returning *`, [adminUserId, src.title, src.url, urlNormalized, urlHash] ); return bookmarkRowToDto(res.rows[0]); } ); }