Files
VitPress-CMS/server/projects.mjs
T
2026-06-13 00:51:52 +08:00

344 lines
10 KiB
JavaScript

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;
}
}