import { readFile } from "node:fs/promises"; import { createHash, randomBytes } from "node:crypto"; import { basename, dirname, relative, resolve } from "node:path"; import { dbAll, dbGet, dbRun } from "./db.mjs"; import { addAudit, hasPermission, httpError, requireEntitlement, requirePermission } from "./tenant.mjs"; const projectRoot = resolve(import.meta.dirname, ".."); const defaultExpiryDays = 7; const maxExpiryDays = 365; const maxDownloadsLimit = 10000; function now() { return new Date().toISOString(); } function parseJson(value, fallback) { try { return JSON.parse(value); } catch { return fallback; } } function makeId(prefix) { return `${prefix}-${Date.now()}-${randomBytes(7).toString("hex")}`; } function hashToken(token) { return createHash("sha256").update(String(token)).digest("hex"); } function tokenFingerprint(tokenHash) { return String(tokenHash || "").slice(0, 16); } function safeStoragePath(value) { const relativePath = String(value || "").trim(); if (!relativePath || relativePath.startsWith("/") || !relativePath.startsWith("storage/")) return null; const segments = relativePath.split(/[\\/]+/); if (segments.includes("..")) return null; const storageRoot = resolve(projectRoot, "storage"); const absolute = resolve(projectRoot, relativePath); const storageRelative = relative(storageRoot, absolute); if (!storageRelative || storageRelative.startsWith("..") || storageRelative.includes("..")) return null; return { relative: relativePath, absolute }; } function safeFileSegment(value, fallback = "delivery") { const normalized = String(value || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); return normalized || fallback; } function requestInfoOf(requestInfo = {}) { return { ipAddress: String(requestInfo.ipAddress || "").slice(0, 200), userAgent: String(requestInfo.userAgent || "").slice(0, 1000) }; } function requireDeliveryView(context) { if (!hasPermission(context, "delivery:view") && !hasPermission(context, "delivery:approve")) { throw httpError(403, "permission_denied", "当前角色没有交付门户查看权限", { permission: "delivery:view" }); } } function releaseForContext(context, releaseId) { if (!context.project) throw httpError(400, "project_required", "交付门户必须绑定项目"); const release = dbGet( `SELECT dr.*, d.version AS delivery_version, d.status AS delivery_status, d.manifest_path AS delivery_manifest_path, d.project_id AS delivery_project_id, p.name AS project_name FROM delivery_releases dr JOIN deliveries d ON d.id = dr.delivery_id JOIN projects p ON p.id = dr.project_id WHERE dr.id = ? AND dr.organization_id = ? AND dr.workspace_id = ? AND dr.project_id = ? AND d.organization_id = dr.organization_id AND d.workspace_id = dr.workspace_id AND d.project_id = dr.project_id`, [releaseId, context.organization.id, context.workspace.id, context.project.id] ); if (!release) throw httpError(404, "delivery_release_not_found", "发布记录不存在或不属于当前项目", { releaseId }); return release; } function linkForContext(context, linkId) { if (!context.project) throw httpError(400, "project_required", "交付门户必须绑定项目"); const row = dbGet( `SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at, dr.delivery_id, d.version AS delivery_version, p.name AS project_name, creator.display_name AS created_by_name, (SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'view' AND e.result = 'success') AS view_count, (SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'download' AND e.result = 'success') AS download_event_count, (SELECT MAX(e.created_at) FROM delivery_access_events e WHERE e.link_id = dal.id) AS last_access_at FROM delivery_access_links dal JOIN delivery_releases dr ON dr.id = dal.release_id JOIN deliveries d ON d.id = dr.delivery_id JOIN projects p ON p.id = dal.project_id LEFT JOIN users creator ON creator.id = dal.created_by WHERE dal.id = ? AND dal.organization_id = ? AND dal.workspace_id = ? AND dal.project_id = ?`, [linkId, context.organization.id, context.workspace.id, context.project.id] ); if (!row) throw httpError(404, "delivery_access_link_not_found", "交付访问链接不存在或不属于当前项目", { linkId }); return row; } function linkState(row, at = Date.now()) { if (row.revoked_at || row.status === "revoked") return "revoked"; if (row.status === "expired" || new Date(row.expires_at).getTime() <= at) return "expired"; return "active"; } function syncExpired(row) { if (linkState(row) !== "expired" || row.status === "expired" || row.status === "revoked") return row; const timestamp = now(); dbRun("UPDATE delivery_access_links SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'active'", [timestamp, row.id]); return { ...row, status: "expired", updated_at: timestamp }; } function linkPayload(input) { if (!input) return null; const row = syncExpired(input); const state = linkState(row); const maxDownloads = Number(row.max_downloads || 0); const downloadCount = Number(row.download_count || 0); const feedback = feedbackSummaryForLink(row.id); return { id: row.id, releaseId: row.release_id, deliveryId: row.delivery_id, deliveryVersion: row.delivery_version, projectName: row.project_name, recipientName: row.recipient_name, recipientEmail: row.recipient_email, status: state, expiresAt: row.expires_at, maxDownloads, downloadCount, downloadsRemaining: Math.max(0, maxDownloads - downloadCount), viewCount: Number(row.view_count || 0), downloadEventCount: Number(row.download_event_count || 0), tokenHint: row.token_hint, createdBy: row.created_by, createdByName: row.created_by_name, createdAt: row.created_at, lastViewedAt: row.last_viewed_at, lastDownloadedAt: row.last_downloaded_at, lastAccessAt: row.last_access_at, revokedAt: row.revoked_at, clientReviewStatus: feedback.status, clientReviewMessage: feedback.message, clientReviewerName: feedback.reviewerName, clientReviewerEmail: feedback.reviewerEmail, clientReviewedAt: feedback.submittedAt, clientReviewCount: feedback.count }; } function addAccessEvent(row, eventType, result, requestInfo, fileKind = "", metadata = {}) { const info = requestInfoOf(requestInfo); const tokenHash = row?.token_hash || String(metadata.tokenHash || ""); dbRun( `INSERT INTO delivery_access_events( id, link_id, release_id, organization_id, workspace_id, project_id, event_type, result, file_kind, token_fingerprint, ip_address, user_agent, metadata_json, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ makeId("delivery-access-event"), row?.id || null, row?.release_id || null, row?.organization_id || null, row?.workspace_id || null, row?.project_id || null, eventType, result, fileKind, tokenFingerprint(tokenHash), info.ipAddress, info.userAgent, JSON.stringify(metadata), now() ] ); } function normalizeExpiry(value) { const candidate = value ? new Date(String(value)) : new Date(Date.now() + defaultExpiryDays * 24 * 60 * 60 * 1000); if (Number.isNaN(candidate.getTime())) throw httpError(400, "delivery_access_expiry_invalid", "访问链接有效期不是有效日期"); if (candidate.getTime() <= Date.now()) throw httpError(400, "delivery_access_expiry_past", "访问链接有效期必须晚于当前时间"); if (candidate.getTime() > Date.now() + maxExpiryDays * 24 * 60 * 60 * 1000) { throw httpError(400, "delivery_access_expiry_too_long", `访问链接有效期不能超过 ${maxExpiryDays} 天`); } return candidate.toISOString(); } function normalizeMaxDownloads(value) { const number = Number(value ?? 10); if (!Number.isInteger(number) || number < 1 || number > maxDownloadsLimit) { throw httpError(400, "delivery_access_download_limit_invalid", `最大下载次数必须是 1-${maxDownloadsLimit} 的整数`); } return number; } function normalizeRecipient(value, maxLength, label) { const result = String(value || "").trim(); if (result.length > maxLength) throw httpError(400, "delivery_access_recipient_invalid", `${label}不能超过 ${maxLength} 个字符`); return result; } function normalizeFeedbackMessage(value, required = false) { const result = String(value || "").trim(); if (result.length > 4000) throw httpError(400, "delivery_feedback_message_invalid", "客户反馈不能超过 4000 个字符"); if (required && !result) throw httpError(400, "delivery_feedback_message_required", "提出修改意见时必须填写具体反馈"); return result; } function feedbackPayload(row) { if (!row) return null; return { id: row.id, linkId: row.link_id, releaseId: row.release_id, decision: row.decision, message: row.message || "", reviewerName: row.reviewer_name || "", reviewerEmail: row.reviewer_email || "", createdAt: row.created_at, updatedAt: row.updated_at }; } function latestFeedbackForLink(linkId) { return dbGet( "SELECT * FROM delivery_access_feedback WHERE link_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1", [linkId] ); } function feedbackSummaryForLink(linkId) { const latest = latestFeedbackForLink(linkId); const count = dbGet("SELECT COUNT(*) AS count FROM delivery_access_feedback WHERE link_id = ?", [linkId]); return { status: latest?.decision || "pending", message: latest?.message || "", reviewerName: latest?.reviewer_name || "", reviewerEmail: latest?.reviewer_email || "", submittedAt: latest?.created_at || null, count: Number(count?.count || 0) }; } export function listDeliveryAccessLinks(context, releaseId) { requireDeliveryView(context); const release = releaseForContext(context, releaseId); return { releaseId: release.id, release: { id: release.id, status: release.status, deliveryId: release.delivery_id, deliveryVersion: release.delivery_version, publishedAt: release.published_at, outputPath: release.status === "published" ? release.output_path : "" }, accessLinks: dbAll( `SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at, dr.delivery_id, d.version AS delivery_version, p.name AS project_name, creator.display_name AS created_by_name, (SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'view' AND e.result = 'success') AS view_count, (SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'download' AND e.result = 'success') AS download_event_count, (SELECT MAX(e.created_at) FROM delivery_access_events e WHERE e.link_id = dal.id) AS last_access_at FROM delivery_access_links dal JOIN delivery_releases dr ON dr.id = dal.release_id JOIN deliveries d ON d.id = dr.delivery_id JOIN projects p ON p.id = dal.project_id LEFT JOIN users creator ON creator.id = dal.created_by WHERE dal.release_id = ? AND dal.organization_id = ? AND dal.workspace_id = ? AND dal.project_id = ? ORDER BY dal.created_at DESC`, [release.id, context.organization.id, context.workspace.id, context.project.id] ).map(linkPayload) }; } export function createDeliveryAccessLink(context, releaseId, body = {}, options = {}) { requirePermission(context, "delivery:approve"); requireEntitlement(context, "feature.private_delivery_portal", 1); const release = releaseForContext(context, releaseId); if (release.status !== "published" || !release.output_path) { throw httpError(409, "delivery_access_release_not_published", "只有已发布且存在发布产物的版本可以创建客户访问链接", { releaseId, status: release.status }); } const releaseFile = safeStoragePath(release.output_path); if (!releaseFile || basename(releaseFile.relative) !== "release.json") { throw httpError(409, "delivery_access_release_file_invalid", "发布产物路径不满足交付门户安全约束"); } const recipientName = normalizeRecipient(body.recipientName, 120, "收件人名称"); const recipientEmail = normalizeRecipient(body.recipientEmail, 240, "收件人邮箱"); if (recipientEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipientEmail)) { throw httpError(400, "delivery_access_email_invalid", "收件人邮箱格式无效"); } const expiresAt = normalizeExpiry(body.expiresAt); const maxDownloads = normalizeMaxDownloads(body.maxDownloads); const metadata = body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) ? body.metadata : {}; const token = randomBytes(32).toString("base64url"); const tokenHash = hashToken(token); const timestamp = now(); const id = makeId("delivery-access"); dbRun( `INSERT INTO delivery_access_links( id, organization_id, workspace_id, project_id, release_id, token_hash, token_hint, recipient_name, recipient_email, status, expires_at, max_downloads, download_count, metadata_json, created_by, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?)`, [ id, context.organization.id, context.workspace.id, context.project.id, release.id, tokenHash, `${token.slice(0, 10)}...`, recipientName, recipientEmail, expiresAt, maxDownloads, JSON.stringify(metadata), context.user.id, timestamp, timestamp ] ); addAudit({ context, action: "delivery.access_link.created", targetType: "delivery_access_link", targetId: id, metadata: { releaseId, recipientName, recipientEmail, expiresAt, maxDownloads } }); const portalPath = `/portal/${encodeURIComponent(token)}`; const portalOrigin = String(options.portalOrigin || "").replace(/\/$/, ""); return { link: linkPayload(linkForContext(context, id)), token, tokenHint: `${token.slice(0, 10)}...`, portalPath, ...(portalOrigin ? { portalUrl: `${portalOrigin}${portalPath}` } : {}), accessLinks: listDeliveryAccessLinks(context, release.id).accessLinks }; } export function revokeDeliveryAccessLink(context, linkId) { requirePermission(context, "delivery:approve"); const current = linkForContext(context, linkId); const state = linkState(current); if (state === "active") { const timestamp = now(); dbRun("UPDATE delivery_access_links SET status = 'revoked', revoked_by = ?, revoked_at = ?, updated_at = ? WHERE id = ? AND status = 'active'", [context.user.id, timestamp, timestamp, linkId]); addAudit({ context, action: "delivery.access_link.revoked", targetType: "delivery_access_link", targetId: linkId, metadata: { releaseId: current.release_id } }); } const link = linkForContext(context, linkId); return { link: linkPayload(link), accessLinks: listDeliveryAccessLinks(context, current.release_id).accessLinks }; } export function listDeliveryAccessFeedback(context, releaseId) { requireDeliveryView(context); const release = releaseForContext(context, releaseId); return { releaseId: release.id, feedback: dbAll( `SELECT f.*, dal.recipient_name, dal.recipient_email, dal.token_hint, creator.display_name AS created_by_name FROM delivery_access_feedback f JOIN delivery_access_links dal ON dal.id = f.link_id LEFT JOIN users creator ON creator.id = dal.created_by WHERE f.release_id = ? AND f.organization_id = ? AND f.workspace_id = ? AND f.project_id = ? ORDER BY f.created_at DESC, f.rowid DESC`, [release.id, context.organization.id, context.workspace.id, context.project.id] ).map((row) => ({ ...feedbackPayload(row), recipientName: row.recipient_name, recipientEmail: row.recipient_email, tokenHint: row.token_hint, createdByName: row.created_by_name })) }; } function publicLinkForToken(token, requestInfo, eventType = "view", fileKind = "") { const normalizedToken = String(token || "").trim(); const tokenHash = hashToken(normalizedToken); const row = dbGet( `SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at, dr.delivery_id, d.version AS delivery_version, p.name AS project_name FROM delivery_access_links dal JOIN delivery_releases dr ON dr.id = dal.release_id JOIN deliveries d ON d.id = dr.delivery_id JOIN projects p ON p.id = dal.project_id WHERE dal.token_hash = ?`, [tokenHash] ); if (!row) { addAccessEvent({ token_hash: tokenHash }, eventType, "missing", requestInfo, fileKind, { reason: "token_not_found" }); throw httpError(404, "delivery_access_not_found", "交付访问链接不存在"); } const state = linkState(row); if (state === "revoked") { addAccessEvent(row, eventType, "denied", requestInfo, fileKind, { reason: "revoked" }); throw httpError(404, "delivery_access_not_found", "交付访问链接不存在"); } if (state === "expired") { syncExpired(row); addAccessEvent(row, eventType, "denied", requestInfo, fileKind, { reason: "expired" }); throw httpError(410, "delivery_access_expired", "交付访问链接已过期"); } if (row.release_status !== "published" || !row.output_path) { addAccessEvent(row, eventType, "missing", requestInfo, fileKind, { reason: "release_not_published" }); throw httpError(404, "delivery_access_not_found", "交付版本不可用"); } return row; } function publicPortalPayload(row, token) { const maxDownloads = Number(row.max_downloads || 0); const downloadCount = Number(row.download_count || 0); const latestFeedback = latestFeedbackForLink(row.id); const feedbackCount = dbGet("SELECT COUNT(*) AS count FROM delivery_access_feedback WHERE link_id = ?", [row.id]); return { portal: { status: "active", recipientName: row.recipient_name, expiresAt: row.expires_at, maxDownloads, downloadCount, downloadsRemaining: Math.max(0, maxDownloads - downloadCount), createdAt: row.created_at, lastAccessAt: row.last_viewed_at || null }, delivery: { version: row.delivery_version, projectName: row.project_name, publishedAt: row.published_at }, review: { status: latestFeedback?.decision || "pending", message: latestFeedback?.message || "", reviewerName: latestFeedback?.reviewer_name || "", submittedAt: latestFeedback?.created_at || null, count: Number(feedbackCount?.count || 0) }, files: [ { kind: "release", label: "发布元数据", fileName: "release.json", path: `/api/public/delivery/${encodeURIComponent(token)}/file?kind=release` }, { kind: "manifest", label: "交付清单", fileName: "delivery-manifest.json", path: `/api/public/delivery/${encodeURIComponent(token)}/file?kind=manifest` } ] }; } export function resolvePublicDeliveryPortal(token, requestInfo = {}) { const row = publicLinkForToken(token, requestInfo, "view"); const timestamp = now(); dbRun("UPDATE delivery_access_links SET last_viewed_at = ?, updated_at = ? WHERE id = ?", [timestamp, timestamp, row.id]); addAccessEvent(row, "view", "success", requestInfo, "", {}); return publicPortalPayload({ ...row, last_viewed_at: timestamp }, token); } export function submitPublicDeliveryFeedback(token, body = {}, requestInfo = {}) { const row = publicLinkForToken(token, requestInfo, "view", "feedback"); const decision = String(body.decision || "").trim(); if (!["approved", "changes_requested"].includes(decision)) { throw httpError(400, "delivery_feedback_decision_invalid", "客户反馈状态只能是 approved 或 changes_requested"); } const message = normalizeFeedbackMessage(body.message, decision === "changes_requested"); const reviewerName = normalizeRecipient(body.reviewerName || row.recipient_name, 120, "反馈人名称"); const reviewerEmail = normalizeRecipient(body.reviewerEmail || row.recipient_email, 240, "反馈人邮箱"); if (reviewerEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(reviewerEmail)) { throw httpError(400, "delivery_feedback_email_invalid", "反馈人邮箱格式无效"); } const info = requestInfoOf(requestInfo); const timestamp = now(); dbRun( `INSERT INTO delivery_access_feedback( id, link_id, release_id, organization_id, workspace_id, project_id, decision, message, reviewer_name, reviewer_email, ip_address, user_agent, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ makeId("delivery-feedback"), row.id, row.release_id, row.organization_id, row.workspace_id, row.project_id, decision, message, reviewerName, reviewerEmail, info.ipAddress, info.userAgent, timestamp, timestamp ] ); return publicPortalPayload(row, token); } function publicFileTarget(row, kind) { if (!['release', 'manifest'].includes(kind)) throw httpError(400, "delivery_access_file_kind_invalid", "只允许读取 release 或 manifest 文件"); const releaseTarget = safeStoragePath(row.output_path); if (!releaseTarget || basename(releaseTarget.relative) !== "release.json") return null; const relativePath = kind === "release" ? releaseTarget.relative : `${dirname(releaseTarget.relative)}/delivery-manifest.json`; return safeStoragePath(relativePath); } export async function readPublicDeliveryFile(token, kind, requestInfo = {}) { const row = publicLinkForToken(token, requestInfo, "download", kind); const target = publicFileTarget(row, kind); if (!target) { addAccessEvent(row, "download", "missing", requestInfo, kind, { reason: "unsafe_path" }); throw httpError(404, "delivery_access_file_not_found", "交付文件不可用"); } let content; try { content = await readFile(target.absolute); } catch { addAccessEvent(row, "download", "missing", requestInfo, kind, { reason: "file_missing" }); throw httpError(404, "delivery_access_file_not_found", "交付文件不存在"); } const timestamp = now(); const claimed = dbRun( `UPDATE delivery_access_links SET download_count = download_count + 1, last_downloaded_at = ?, updated_at = ? WHERE id = ? AND status = 'active' AND expires_at > ? AND download_count < max_downloads`, [timestamp, timestamp, row.id, timestamp] ); if (Number(claimed?.changes || 0) !== 1) { const latest = dbGet("SELECT * FROM delivery_access_links WHERE id = ?", [row.id]); const state = latest ? linkState(latest) : "missing"; if (state === "expired") { syncExpired(latest); addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "expired" }); throw httpError(410, "delivery_access_expired", "交付访问链接已过期"); } if (state === "revoked" || !latest) { addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "revoked" }); throw httpError(404, "delivery_access_not_found", "交付访问链接不存在"); } addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "download_limit" }); throw httpError(429, "delivery_access_download_limit", "该交付访问链接已达到最大下载次数"); } addAccessEvent({ ...row, token_hash: row.token_hash }, "download", "success", requestInfo, kind, { downloadCount: Number(row.download_count || 0) + 1 }); return { content, contentType: "application/json; charset=utf-8", fileName: kind === "release" ? `${safeFileSegment(row.delivery_version)}-release.json` : `${safeFileSegment(row.delivery_version)}-delivery-manifest.json` }; }