2026-01-21 23:09:33 +08:00
|
|
|
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";
|
2026-01-23 23:55:08 +08:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-21 23:09:33 +08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-23 23:55:08 +08:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-21 23:09:33 +08:00
|
|
|
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]);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|