548 lines
24 KiB
JavaScript
548 lines
24 KiB
JavaScript
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`
|
|
};
|
|
}
|