功能:完善用户与项目管理框架

This commit is contained in:
Coldsmile_7
2026-06-13 00:50:11 +08:00
parent db6d6de74a
commit 49c86bc14a
25 changed files with 3571 additions and 192 deletions
+179 -3
View File
@@ -1,9 +1,10 @@
import { db } from "./db.mjs";
import { json, readBody } from "./http.mjs";
import { clearSessionCookie, createSession, deleteSession, getSession, parseCookies, sessionCookie } from "./session.mjs";
import { verifyPassword } from "./security.mjs";
import { hashPassword, verifyPassword } from "./security.mjs";
import { hasPermission, publicPermissions } from "./permissions.mjs";
function publicUser(user) {
export function publicUser(user) {
if (!user) return null;
return {
@@ -12,6 +13,12 @@ function publicUser(user) {
name: user.name,
avatarUrl: user.avatar_url,
role: user.role,
status: user.status || "active",
emailVerified: Boolean(user.email_verified),
createdAt: user.created_at,
updatedAt: user.updated_at,
lastLoginAt: user.last_login_at,
permissions: publicPermissions(user),
};
}
@@ -33,12 +40,49 @@ export function requireCmsUser(request, response) {
return session.user;
}
export function requirePermission(request, response, permission) {
const user = requireCmsUser(request, response);
if (!user) return undefined;
if (!hasPermission(user, permission)) {
json(response, 403, { error: "Permission denied" });
return undefined;
}
return user;
}
function settingEnabled(key, fallback = false) {
const row = db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key);
return (row?.value ?? String(fallback)) === "true";
}
function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
async function handleMe(request, response) {
const { session } = getRequestSession(request);
json(response, 200, { user: session?.user ?? null });
}
async function handleOptions(request, response) {
json(response, 200, {
options: {
allowEmailLogin: settingEnabled("auth.allow_email_login", true),
allowGitHubLogin: settingEnabled("auth.allow_github_login", true),
allowRegistration: settingEnabled("auth.allow_registration", false),
},
});
}
async function handleLogin(request, response) {
if (!settingEnabled("auth.allow_email_login", true)) {
json(response, 403, { error: "Email login is disabled" });
return;
}
const body = JSON.parse(await readBody(request) || "{}");
const email = String(body.email || "").trim().toLowerCase();
const password = String(body.password || "");
@@ -55,7 +99,15 @@ async function handleLogin(request, response) {
return;
}
const sessionUser = publicUser(user);
if ((user.status || "active") !== "active") {
json(response, 403, { error: "This account is disabled" });
return;
}
db.prepare("UPDATE users SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(user.id);
const nextUser = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id);
const sessionUser = publicUser(nextUser);
const sessionId = createSession({
user: sessionUser,
});
@@ -63,12 +115,116 @@ async function handleLogin(request, response) {
json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(sessionId) });
}
async function handleRegister(request, response) {
if (!settingEnabled("auth.allow_registration", false)) {
json(response, 403, { error: "Registration is disabled" });
return;
}
const body = JSON.parse((await readBody(request)) || "{}");
const email = String(body.email || "").trim().toLowerCase();
const name = String(body.name || "").trim();
const password = String(body.password || "");
if (!validateEmail(email)) {
json(response, 400, { error: "A valid email is required" });
return;
}
if (!name) {
json(response, 400, { error: "Name is required" });
return;
}
if (password.length < 8) {
json(response, 400, { error: "Password must be at least 8 characters" });
return;
}
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
if (existing) {
json(response, 409, { error: "Email already exists" });
return;
}
const result = db
.prepare("INSERT INTO users (email, password_hash, name, role, email_verified) VALUES (?, ?, ?, 'user', 0)")
.run(email, hashPassword(password), name);
const user = publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid));
const sessionId = createSession({ user });
json(response, 201, { user }, { "Set-Cookie": sessionCookie(sessionId) });
}
async function handleLogout(request, response) {
const { sessionId } = getRequestSession(request);
deleteSession(sessionId);
json(response, 200, { ok: true }, { "Set-Cookie": clearSessionCookie() });
}
async function handleUpdateProfile(request, response) {
const { sessionId, session } = getRequestSession(request);
if (!session?.user?.id) {
json(response, 401, { error: "Not signed in" });
return;
}
const body = JSON.parse((await readBody(request)) || "{}");
const name = String(body.name || "").trim();
if (!name) {
json(response, 400, { error: "Name is required" });
return;
}
db.prepare("UPDATE users SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(name, session.user.id);
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(session.user.id);
const sessionUser = publicUser(user);
delete session.user;
const nextSession = {
...session,
user: sessionUser,
};
deleteSession(sessionId);
const nextSessionId = createSession(nextSession);
json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(nextSessionId) });
}
async function handleChangePassword(request, response) {
const { session } = getRequestSession(request);
if (!session?.user?.id) {
json(response, 401, { error: "Not signed in" });
return;
}
const body = JSON.parse((await readBody(request)) || "{}");
const currentPassword = String(body.currentPassword || "");
const newPassword = String(body.newPassword || "");
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(session.user.id);
if (newPassword.length < 8) {
json(response, 400, { error: "New password must be at least 8 characters" });
return;
}
if (user.password_hash && !verifyPassword(currentPassword, user.password_hash)) {
json(response, 403, { error: "Current password is incorrect" });
return;
}
db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(
hashPassword(newPassword),
session.user.id,
);
json(response, 200, { ok: true });
}
export async function handleAuthApi(request, response, url) {
try {
if (url.pathname === "/api/auth/me" && request.method === "GET") {
@@ -76,16 +232,36 @@ export async function handleAuthApi(request, response, url) {
return true;
}
if (url.pathname === "/api/auth/options" && request.method === "GET") {
await handleOptions(request, response);
return true;
}
if (url.pathname === "/api/auth/login" && request.method === "POST") {
await handleLogin(request, response);
return true;
}
if (url.pathname === "/api/auth/register" && request.method === "POST") {
await handleRegister(request, response);
return true;
}
if (url.pathname === "/api/auth/logout" && request.method === "POST") {
await handleLogout(request, response);
return true;
}
if (url.pathname === "/api/auth/profile" && request.method === "PATCH") {
await handleUpdateProfile(request, response);
return true;
}
if (url.pathname === "/api/auth/password" && request.method === "POST") {
await handleChangePassword(request, response);
return true;
}
return false;
} catch (error) {
json(response, 500, {
+10
View File
@@ -67,6 +67,16 @@ export function migrate() {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
const userColumns = db.prepare("PRAGMA table_info(users)").all().map((column) => column.name);
if (!userColumns.includes("status")) {
db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
}
if (!userColumns.includes("last_login_at")) {
db.exec("ALTER TABLE users ADD COLUMN last_login_at TEXT");
}
}
export function getSetting(key, fallback = "") {
+149 -75
View File
@@ -1,7 +1,10 @@
import { randomBytes } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawn } from "node:child_process";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { db, getSetting } from "./db.mjs";
import { json, readBody, redirect } from "./http.mjs";
import { getRequestSession } from "./auth.mjs";
@@ -37,7 +40,7 @@ function requestTextWithPowerShell(targetUrl, init = {}) {
).toString("base64");
const script = `
$ErrorActionPreference = 'Stop'
$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($args[0])) | ConvertFrom-Json
$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:VITEPRESS_CMS_PS_PAYLOAD)) | ConvertFrom-Json
$headers = @{}
if ($payload.headers) {
foreach ($property in $payload.headers.PSObject.Properties) {
@@ -81,7 +84,11 @@ try {
$result | ConvertTo-Json -Compress -Depth 5
`;
const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand, payload], {
const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand], {
env: {
...process.env,
VITEPRESS_CMS_PS_PAYLOAD: payload,
},
windowsHide: true,
});
const stdout = [];
@@ -281,6 +288,11 @@ function publicCmsUser(user) {
name: user.name,
avatarUrl: user.avatar_url,
role: user.role,
status: user.status || "active",
emailVerified: Boolean(user.email_verified),
createdAt: user.created_at,
updatedAt: user.updated_at,
lastLoginAt: user.last_login_at,
};
}
@@ -392,8 +404,14 @@ async function handleCallback(request, response, url) {
const user = userResponse.payload ?? {};
const { session: currentSession } = getRequestSession(request);
const cmsUser = findOrCreateGithubUser(user, tokenPayload.access_token, currentSession);
if ((cmsUser.status || "active") !== "active") {
throw new Error("This account is disabled");
}
db.prepare("UPDATE users SET last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(cmsUser.id);
const nextCmsUser = db.prepare("SELECT * FROM users WHERE id = ?").get(cmsUser.id);
const sessionId = createSession({
user: publicCmsUser(cmsUser),
user: publicCmsUser(nextCmsUser),
github: {
accessToken: tokenPayload.access_token,
login: user.login,
@@ -489,6 +507,10 @@ function normalizeSiteRoot(siteRoot = "") {
return siteRoot.trim().replace(/^\/+|\/+$/g, "");
}
function getVitePressSiteRoot(siteRoot = "") {
return normalizeSiteRoot(siteRoot) || "docs";
}
function toRepoPath(siteRoot, path) {
const normalizedRoot = normalizeSiteRoot(siteRoot);
return normalizedRoot ? `${normalizedRoot}/${path}` : path;
@@ -508,10 +530,11 @@ function encodeBase64Content(content) {
}
function packageJsonTemplate(siteRoot) {
const root = normalizeSiteRoot(siteRoot) || ".";
const root = getVitePressSiteRoot(siteRoot);
return `${JSON.stringify(
{
type: "module",
scripts: {
"docs:dev": `vitepress dev ${root}`,
"docs:build": `vitepress build ${root}`,
@@ -527,43 +550,100 @@ function packageJsonTemplate(siteRoot) {
`;
}
function siteConfigTemplate() {
return `import { defineConfig } from "vitepress";
function mergePackageJsonContent(existingSource, siteRoot) {
const root = getVitePressSiteRoot(siteRoot);
let packageJson;
export default defineConfig({
title: "VitePress Site",
description: "Created by VitePress-CMS",
lastUpdated: true,
themeConfig: {
nav: [
{ text: "首页", link: "/" },
{ text: "指南", link: "/guide/getting-started" },
],
sidebar: {
"/guide/": [
{
text: "指南",
items: [
{ text: "快速开始", link: "/guide/getting-started" },
],
},
],
},
search: {
provider: "local",
},
footer: {
message: "Powered by VitePress-CMS",
copyright: "Copyright 2026",
},
},
});
`;
try {
packageJson = JSON.parse(existingSource);
} catch {
throw new Error("package.json is not valid JSON");
}
packageJson.type = "module";
packageJson.scripts = {
...packageJson.scripts,
"docs:dev": `vitepress dev ${root}`,
"docs:build": `vitepress build ${root}`,
"docs:preview": `vitepress preview ${root}`,
};
packageJson.devDependencies = {
...packageJson.devDependencies,
vitepress: packageJson.devDependencies?.vitepress || "^1.6.4",
};
return `${JSON.stringify(packageJson, null, 2)}\n`;
}
async function loadVitePressScaffold() {
try {
const module = await import("vitepress");
return module.scaffold;
} catch {
const module = await import("../test-vitepress/node_modules/vitepress/dist/node/index.js");
return module.scaffold;
}
}
async function createOfficialVitePressFiles(siteRoot) {
const tempRoot = mkdtempSync(join(tmpdir(), "vitepress-cms-init-"));
const cwd = process.cwd();
try {
const scaffold = await loadVitePressScaffold();
writeFileSync(join(tempRoot, "package.json"), packageJsonTemplate(siteRoot), "utf-8");
process.chdir(tempRoot);
scaffold({
root: siteRoot,
title: "VitePress Site",
description: "Created by VitePress-CMS",
theme: "default theme",
useTs: true,
injectNpmScripts: true,
});
const configPath = [
toRepoPath(siteRoot, ".vitepress/config.ts"),
toRepoPath(siteRoot, ".vitepress/config.mts"),
toRepoPath(siteRoot, ".vitepress/config.mjs"),
toRepoPath(siteRoot, ".vitepress/config.js"),
].find((path) => {
try {
readFileSync(join(tempRoot, path), "utf-8");
return true;
} catch {
return false;
}
});
if (!configPath) {
throw new Error("VitePress official scaffold did not generate a config file");
}
const filePaths = [
"package.json",
toRepoPath(siteRoot, "index.md"),
toRepoPath(siteRoot, "api-examples.md"),
toRepoPath(siteRoot, "markdown-examples.md"),
configPath,
];
return filePaths.map((path) => ({
path,
content:
path === "package.json"
? mergePackageJsonContent(readFileSync(join(tempRoot, path), "utf-8"), siteRoot)
: readFileSync(join(tempRoot, path), "utf-8"),
}));
} finally {
process.chdir(cwd);
rmSync(tempRoot, { recursive: true, force: true });
}
}
function deployWorkflowTemplate(siteRoot, branch) {
const root = normalizeSiteRoot(siteRoot);
const outputDir = root ? `${root}/.vitepress/dist` : ".vitepress/dist";
const root = getVitePressSiteRoot(siteRoot);
const outputDir = `${root}/.vitepress/dist`;
return `name: Deploy VitePress site to Pages
@@ -579,7 +659,7 @@ permissions:
concurrency:
group: pages
cancel-in-progress: false
cancel-in-progress: true
jobs:
build:
@@ -603,44 +683,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
`;
}
function initTemplateFiles(siteRoot, branch) {
async function initTemplateFiles(siteRoot, branch) {
const root = getVitePressSiteRoot(siteRoot);
const files = await createOfficialVitePressFiles(root);
return [
{
path: "package.json",
content: packageJsonTemplate(siteRoot),
},
{
path: toRepoPath(siteRoot, ".vitepress/config.ts"),
content: siteConfigTemplate(),
},
{
path: toRepoPath(siteRoot, "index.md"),
content: `---
title: 首页
description: VitePress-CMS 初始化页面
---
# VitePress Site
这个站点由 VitePress-CMS 初始化。
`,
},
{
path: toRepoPath(siteRoot, "guide/getting-started.md"),
content: `---
title: 快速开始
description: 使用 VitePress-CMS 管理内容
---
# 快速开始
这里是你的第一篇指南文档。
`,
},
...files,
{
path: ".github/workflows/deploy.yml",
content: deployWorkflowTemplate(siteRoot, branch),
@@ -759,7 +811,7 @@ function getSiteQuery(url) {
const owner = url.searchParams.get("owner");
const repo = url.searchParams.get("repo");
const branch = url.searchParams.get("branch") || "main";
const siteRoot = url.searchParams.get("siteRoot") || "";
const siteRoot = getVitePressSiteRoot(url.searchParams.get("siteRoot") || "");
if (!owner || !repo) {
throw new Error("owner and repo are required");
@@ -789,6 +841,28 @@ async function createRepoPathFile(session, site, file) {
try {
const existing = await getRepoPathContent(session, site, file.path);
if (file.path === "package.json") {
const encodedPath = file.path.split("/").map(encodeURIComponent).join("/");
const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: "docs: update package metadata from VitePress-CMS",
content: encodeBase64Content(mergePackageJsonContent(decodeBase64Content(existing.content), site.siteRoot)),
branch: site.branch,
sha: existing.sha,
}),
});
return {
path: file.path,
status: "updated",
sha: result.content.sha,
};
}
if (file.path.startsWith(".github/workflows/")) {
const encodedPath = file.path.split("/").map(encodeURIComponent).join("/");
const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
@@ -891,11 +965,11 @@ async function handleInitSite(request, response) {
owner: String(site.owner),
repo: String(site.repo),
branch: String(site.branch || "main"),
siteRoot: normalizeSiteRoot(String(site.siteRoot || "")),
siteRoot: getVitePressSiteRoot(String(site.siteRoot || "")),
};
const files = [];
for (const file of initTemplateFiles(normalizedSite.siteRoot, normalizedSite.branch)) {
for (const file of await initTemplateFiles(normalizedSite.siteRoot, normalizedSite.branch)) {
files.push(await createRepoPathFile(session, normalizedSite, file));
}
+10
View File
@@ -6,6 +6,8 @@ import { bootstrap } from "./bootstrap.mjs";
import { handleAuthApi } from "./auth.mjs";
import { handleGithubApi } from "./github.mjs";
import { handleSettingsApi } from "./settings.mjs";
import { handleUsersApi } from "./users.mjs";
import { handleProjectsApi } from "./projects.mjs";
const isProduction = process.env.NODE_ENV === "production";
const port = Number(process.env.PORT || 5173);
@@ -64,6 +66,14 @@ const server = createServer(async (request, response) => {
return;
}
if (await handleUsersApi(request, response, url)) {
return;
}
if (await handleProjectsApi(request, response, url)) {
return;
}
if (await handleGithubApi(request, response, url)) {
return;
}
+31
View File
@@ -0,0 +1,31 @@
export const permissions = {
settingsManage: "settings.manage",
usersManage: "users.manage",
projectsManage: "projects.manage",
contentEdit: "content.edit",
contentPublish: "content.publish",
contentView: "content.view",
};
const rolePermissions = {
system_admin: Object.values(permissions),
admin: [
permissions.usersManage,
permissions.projectsManage,
permissions.contentEdit,
permissions.contentPublish,
permissions.contentView,
],
editor: [permissions.contentEdit, permissions.contentPublish, permissions.contentView],
viewer: [permissions.contentView],
user: [permissions.contentView],
};
export function hasPermission(user, permission) {
if (!user) return false;
return Boolean(rolePermissions[user.role]?.includes(permission));
}
export function publicPermissions(user) {
return rolePermissions[user?.role] ?? [];
}
+343
View File
@@ -0,0 +1,343 @@
import { db } from "./db.mjs";
import { requireCmsUser } from "./auth.mjs";
import { json, readBody } from "./http.mjs";
const projectRoles = ["owner", "admin", "editor", "viewer"];
const projectManagers = ["owner", "admin"];
function publicProject(row) {
return {
id: row.id,
name: row.name,
githubOwner: row.github_owner,
githubRepo: row.github_repo,
githubBranch: row.github_branch,
siteRoot: row.site_root,
createdAt: row.created_at,
updatedAt: row.updated_at,
ownerUserId: row.owner_user_id,
};
}
function publicMember(row) {
return {
id: row.id,
projectId: row.project_id,
userId: row.user_id,
role: row.role,
name: row.name,
email: row.email,
avatarUrl: row.avatar_url,
status: row.status || "active",
createdAt: row.created_at,
};
}
function getProject(projectId) {
return db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId);
}
function hasGlobalProjectAccess(user) {
return user.role === "system_admin" || user.role === "admin";
}
function getProjectMember(projectId, userId) {
return db.prepare("SELECT * FROM project_members WHERE project_id = ? AND user_id = ?").get(projectId, userId);
}
function canViewProject(user, projectId) {
return hasGlobalProjectAccess(user) || Boolean(getProjectMember(projectId, user.id));
}
function canManageProject(user, projectId) {
if (hasGlobalProjectAccess(user)) return true;
const member = getProjectMember(projectId, user.id);
return Boolean(member && projectManagers.includes(member.role));
}
function requireProjectView(request, response, projectId) {
const user = requireCmsUser(request, response);
if (!user) return undefined;
if (!canViewProject(user, projectId)) {
json(response, 403, { error: "Permission denied" });
return undefined;
}
return user;
}
function requireProjectManage(request, response, projectId) {
const user = requireCmsUser(request, response);
if (!user) return undefined;
if (!canManageProject(user, projectId)) {
json(response, 403, { error: "Only project owners and admins can manage this project" });
return undefined;
}
return user;
}
function listMembers(projectId) {
return db
.prepare(`
SELECT project_members.*, users.name, users.email, users.avatar_url, users.status
FROM project_members
INNER JOIN users ON users.id = project_members.user_id
WHERE project_members.project_id = ?
ORDER BY
CASE project_members.role
WHEN 'owner' THEN 1
WHEN 'admin' THEN 2
WHEN 'editor' THEN 3
ELSE 4
END,
project_members.created_at ASC
`)
.all(projectId)
.map(publicMember);
}
function listProjects(user) {
if (user.role === "system_admin" || user.role === "admin") {
return db.prepare("SELECT * FROM projects ORDER BY updated_at DESC, id DESC").all().map(publicProject);
}
return db
.prepare(`
SELECT projects.*
FROM projects
INNER JOIN project_members ON project_members.project_id = projects.id
WHERE project_members.user_id = ?
ORDER BY projects.updated_at DESC, projects.id DESC
`)
.all(user.id)
.map(publicProject);
}
async function handleListProjects(request, response) {
const user = requireCmsUser(request, response);
if (!user) return;
json(response, 200, { projects: listProjects(user) });
}
async function handleCreateProject(request, response) {
const user = requireCmsUser(request, response);
if (!user) return;
const body = JSON.parse((await readBody(request)) || "{}");
const name = String(body.name || "").trim();
if (!name) {
json(response, 400, { error: "Project name is required" });
return;
}
const result = db
.prepare(`
INSERT INTO projects (owner_user_id, name, github_owner, github_repo, github_branch, site_root)
VALUES (?, ?, ?, ?, ?, ?)
`)
.run(
user.id,
name,
String(body.githubOwner || "").trim() || null,
String(body.githubRepo || "").trim() || null,
String(body.githubBranch || "main").trim() || "main",
String(body.siteRoot || "docs").trim().replace(/^\/+|\/+$/g, "") || "docs",
);
db.prepare("INSERT OR IGNORE INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')").run(
result.lastInsertRowid,
user.id,
);
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(result.lastInsertRowid);
json(response, 201, { project: publicProject(project) });
}
async function handleUpdateProject(request, response, projectId) {
const project = db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId);
if (!project) {
json(response, 404, { error: "Project not found" });
return;
}
const user = requireProjectManage(request, response, projectId);
if (!user) return;
const body = JSON.parse((await readBody(request)) || "{}");
db.prepare(`
UPDATE projects
SET name = ?,
github_owner = ?,
github_repo = ?,
github_branch = ?,
site_root = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
String(body.name || project.name).trim(),
String(body.githubOwner ?? project.github_owner ?? "").trim() || null,
String(body.githubRepo ?? project.github_repo ?? "").trim() || null,
String(body.githubBranch || project.github_branch || "main").trim() || "main",
String(body.siteRoot || project.site_root || "docs").trim().replace(/^\/+|\/+$/g, "") || "docs",
projectId,
);
json(response, 200, { project: publicProject(db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId)) });
}
async function handleListMembers(request, response, projectId) {
if (!getProject(projectId)) {
json(response, 404, { error: "Project not found" });
return;
}
const user = requireProjectView(request, response, projectId);
if (!user) return;
json(response, 200, { members: listMembers(projectId) });
}
async function handleAddMember(request, response, projectId) {
if (!getProject(projectId)) {
json(response, 404, { error: "Project not found" });
return;
}
const user = requireProjectManage(request, response, projectId);
if (!user) return;
const body = JSON.parse((await readBody(request)) || "{}");
const email = String(body.email || "").trim().toLowerCase();
const role = String(body.role || "editor");
if (!projectRoles.includes(role)) {
json(response, 400, { error: "Invalid member role" });
return;
}
const target = db.prepare("SELECT * FROM users WHERE email = ?").get(email);
if (!target) {
json(response, 404, { error: "User not found" });
return;
}
db.prepare(`
INSERT INTO project_members (project_id, user_id, role)
VALUES (?, ?, ?)
ON CONFLICT(project_id, user_id) DO UPDATE SET role = excluded.role
`).run(projectId, target.id, role);
json(response, 200, { members: listMembers(projectId) });
}
async function handleUpdateMember(request, response, projectId, memberId) {
const member = db.prepare("SELECT * FROM project_members WHERE project_id = ? AND id = ?").get(projectId, memberId);
if (!member) {
json(response, 404, { error: "Member not found" });
return;
}
const user = requireProjectManage(request, response, projectId);
if (!user) return;
const body = JSON.parse((await readBody(request)) || "{}");
const role = String(body.role || member.role);
if (!projectRoles.includes(role)) {
json(response, 400, { error: "Invalid member role" });
return;
}
if (member.role === "owner" && role !== "owner") {
const ownerCount = db
.prepare("SELECT COUNT(*) AS count FROM project_members WHERE project_id = ? AND role = 'owner'")
.get(projectId).count;
if (ownerCount <= 1) {
json(response, 400, { error: "At least one project owner is required" });
return;
}
}
db.prepare("UPDATE project_members SET role = ? WHERE id = ?").run(role, memberId);
json(response, 200, { members: listMembers(projectId) });
}
async function handleRemoveMember(request, response, projectId, memberId) {
const member = db.prepare("SELECT * FROM project_members WHERE project_id = ? AND id = ?").get(projectId, memberId);
if (!member) {
json(response, 404, { error: "Member not found" });
return;
}
const user = requireProjectManage(request, response, projectId);
if (!user) return;
if (member.role === "owner") {
const ownerCount = db
.prepare("SELECT COUNT(*) AS count FROM project_members WHERE project_id = ? AND role = 'owner'")
.get(projectId).count;
if (ownerCount <= 1) {
json(response, 400, { error: "At least one project owner is required" });
return;
}
}
db.prepare("DELETE FROM project_members WHERE id = ?").run(memberId);
json(response, 200, { members: listMembers(projectId) });
}
export async function handleProjectsApi(request, response, url) {
try {
if (url.pathname === "/api/projects" && request.method === "GET") {
await handleListProjects(request, response);
return true;
}
if (url.pathname === "/api/projects" && request.method === "POST") {
await handleCreateProject(request, response);
return true;
}
const match = url.pathname.match(/^\/api\/projects\/(\d+)$/);
if (match && request.method === "PATCH") {
await handleUpdateProject(request, response, Number(match[1]));
return true;
}
const membersMatch = url.pathname.match(/^\/api\/projects\/(\d+)\/members$/);
if (membersMatch && request.method === "GET") {
await handleListMembers(request, response, Number(membersMatch[1]));
return true;
}
if (membersMatch && request.method === "POST") {
await handleAddMember(request, response, Number(membersMatch[1]));
return true;
}
const memberMatch = url.pathname.match(/^\/api\/projects\/(\d+)\/members\/(\d+)$/);
if (memberMatch && request.method === "PATCH") {
await handleUpdateMember(request, response, Number(memberMatch[1]), Number(memberMatch[2]));
return true;
}
if (memberMatch && request.method === "DELETE") {
await handleRemoveMember(request, response, Number(memberMatch[1]), Number(memberMatch[2]));
return true;
}
return false;
} catch (error) {
json(response, 500, {
error: error instanceof Error ? error.message : "Unknown projects API error",
});
return true;
}
}
+77 -11
View File
@@ -1,18 +1,64 @@
import net from "node:net";
import tls from "node:tls";
import { db, setSetting } from "./db.mjs";
import { requireCmsUser } from "./auth.mjs";
import { requirePermission } from "./auth.mjs";
import { json, readBody } from "./http.mjs";
import { permissions } from "./permissions.mjs";
function requireAdmin(request, response) {
const user = requireCmsUser(request, response);
function getSettingValue(key, fallback = "") {
return db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key)?.value ?? fallback;
}
if (!user) return undefined;
function testSmtpConnection({ host, port }) {
return new Promise((resolve, reject) => {
const socketFactory = Number(port) === 465 ? tls.connect : net.connect;
const socket = socketFactory({
host,
port: Number(port),
servername: host,
timeout: 10000,
});
let buffer = "";
if (user.role !== "system_admin") {
json(response, 403, { error: "System admin permission is required" });
return undefined;
}
const finish = (result) => {
socket.removeAllListeners();
socket.end();
resolve(result);
};
return user;
socket.on("connect", () => {
if (Number(port) === 465) {
socket.write("EHLO vitepress-cms.local\r\n");
}
});
socket.on("secureConnect", () => {
socket.write("EHLO vitepress-cms.local\r\n");
});
socket.on("data", (chunk) => {
buffer += chunk.toString("utf-8");
if (buffer.startsWith("220")) {
socket.write("EHLO vitepress-cms.local\r\n");
}
if (buffer.includes("\n250 ") || buffer.includes("\n250-") || buffer.startsWith("250")) {
socket.write("QUIT\r\n");
finish({
ok: true,
greeting: buffer.split(/\r?\n/).filter(Boolean).slice(0, 3).join(" / "),
});
}
});
socket.on("timeout", () => {
socket.destroy();
reject(new Error("SMTP connection timed out"));
});
socket.on("error", reject);
});
}
function publicSetting(row) {
@@ -25,7 +71,7 @@ function publicSetting(row) {
}
async function handleGetSettings(request, response) {
if (!requireAdmin(request, response)) return;
if (!requirePermission(request, response, permissions.settingsManage)) return;
const rows = db
.prepare("SELECT key, value, encrypted, updated_at FROM system_settings ORDER BY key")
@@ -35,7 +81,7 @@ async function handleGetSettings(request, response) {
}
async function handleSaveSettings(request, response) {
if (!requireAdmin(request, response)) return;
if (!requirePermission(request, response, permissions.settingsManage)) return;
const body = JSON.parse(await readBody(request) || "{}");
const settings = Array.isArray(body.settings) ? body.settings : [];
@@ -52,6 +98,21 @@ async function handleSaveSettings(request, response) {
json(response, 200, { ok: true });
}
async function handleTestSmtp(request, response) {
if (!requirePermission(request, response, permissions.settingsManage)) return;
const host = getSettingValue("smtp.host");
const port = getSettingValue("smtp.port", "587");
if (!host) {
json(response, 400, { error: "SMTP host is required" });
return;
}
const result = await testSmtpConnection({ host, port });
json(response, 200, result);
}
export async function handleSettingsApi(request, response, url) {
try {
if (url.pathname === "/api/admin/settings" && request.method === "GET") {
@@ -64,6 +125,11 @@ export async function handleSettingsApi(request, response, url) {
return true;
}
if (url.pathname === "/api/admin/settings/test-smtp" && request.method === "POST") {
await handleTestSmtp(request, response);
return true;
}
return false;
} catch (error) {
json(response, 500, {
+143
View File
@@ -0,0 +1,143 @@
import { db } from "./db.mjs";
import { requirePermission, publicUser } from "./auth.mjs";
import { json, readBody } from "./http.mjs";
import { permissions } from "./permissions.mjs";
import { hashPassword } from "./security.mjs";
const roles = new Set(["system_admin", "admin", "editor", "viewer", "user"]);
const statuses = new Set(["active", "disabled"]);
function listUsers() {
return db
.prepare(`
SELECT id, email, name, avatar_url, role, status, email_verified, created_at, updated_at, last_login_at
FROM users
ORDER BY created_at DESC, id DESC
`)
.all()
.map(publicUser);
}
function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
async function handleListUsers(request, response) {
if (!requirePermission(request, response, permissions.usersManage)) return;
json(response, 200, { users: listUsers() });
}
async function handleCreateUser(request, response) {
if (!requirePermission(request, response, permissions.usersManage)) return;
const body = JSON.parse((await readBody(request)) || "{}");
const email = String(body.email || "").trim().toLowerCase();
const name = String(body.name || "").trim();
const password = String(body.password || "");
const role = String(body.role || "user");
if (!validateEmail(email)) {
json(response, 400, { error: "A valid email is required" });
return;
}
if (!name) {
json(response, 400, { error: "Name is required" });
return;
}
if (password.length < 8) {
json(response, 400, { error: "Password must be at least 8 characters" });
return;
}
if (!roles.has(role)) {
json(response, 400, { error: "Invalid role" });
return;
}
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
if (existing) {
json(response, 409, { error: "Email already exists" });
return;
}
const result = db
.prepare("INSERT INTO users (email, password_hash, name, role, email_verified) VALUES (?, ?, ?, ?, 1)")
.run(email, hashPassword(password), name, role);
json(response, 201, { user: publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid)) });
}
async function handleUpdateUser(request, response, userId) {
const actor = requirePermission(request, response, permissions.usersManage);
if (!actor) return;
const target = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
if (!target) {
json(response, 404, { error: "User not found" });
return;
}
const body = JSON.parse((await readBody(request)) || "{}");
const nextRole = body.role === undefined ? target.role : String(body.role);
const nextStatus = body.status === undefined ? target.status || "active" : String(body.status);
if (!roles.has(nextRole)) {
json(response, 400, { error: "Invalid role" });
return;
}
if (!statuses.has(nextStatus)) {
json(response, 400, { error: "Invalid status" });
return;
}
if (Number(actor.id) === Number(userId) && nextStatus !== "active") {
json(response, 400, { error: "You cannot disable your own account" });
return;
}
if (target.role === "system_admin" && nextRole !== "system_admin") {
const adminCount = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'system_admin'").get().count;
if (adminCount <= 1) {
json(response, 400, { error: "At least one system admin is required" });
return;
}
}
db.prepare("UPDATE users SET role = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(
nextRole,
nextStatus,
userId,
);
json(response, 200, { user: publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(userId)) });
}
export async function handleUsersApi(request, response, url) {
try {
if (url.pathname === "/api/admin/users" && request.method === "GET") {
await handleListUsers(request, response);
return true;
}
if (url.pathname === "/api/admin/users" && request.method === "POST") {
await handleCreateUser(request, response);
return true;
}
const match = url.pathname.match(/^\/api\/admin\/users\/(\d+)$/);
if (match && request.method === "PATCH") {
await handleUpdateUser(request, response, Number(match[1]));
return true;
}
return false;
} catch (error) {
json(response, 500, {
error: error instanceof Error ? error.message : "Unknown users API error",
});
return true;
}
}