Files
MyIP/api/share-report.js
T
jason5ng32andClaude Opus 5.5 c29e48687b Refactor(layout): split backend-only code out of common/, group frontend utils
common/ had become the backend's library: 30 of its 41 modules were
imported by nothing in the frontend, so the name no longer said what
belonged there. It now holds only what the frontend or the build imports
too; backend-only code moved to server/, flat except for datasets/ (the
dataset engine and its readers). The *-db/ dataset directories stay in
common/: self-hosting docs and Docker volumes mount those paths.

frontend/utils/ keeps its app-wide modules at the root and gains ip/
(IP / ASN shaping and validation), report/ (the report pipeline) and
features/ (the pure layer of a single feature). Paths only — no logic
changes. The Dockerfile copies server/, and the AGENTS.md files say where
a new module goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:10:02 +08:00

131 lines
5.9 KiB
JavaScript

// Shareable diagnostic report storage — POST /api/report validates an
// untrusted report against the shared schema whitelist and stores it in
// Cloudflare Workers KV (REST API, TTL-expired); GET /api/report/:id fetches
// one for the read-only /r/:id page (and for AI agents given a share link).
//
// The whole feature is env-gated: without the three CLOUDFLARE_* variables
// below both endpoints answer 503 and /api/configs reports
// `reportSharing: false`, so the share-link UI never shows.
//
// Abuse layering (write side): schema whitelist (no free-form text can be
// stored), a size cap (REPORT_MAX_BYTES), 128-bit unguessable ids, KV TTL.
// Rate limiting is
// deliberately NOT done here — it's a deployment concern: edge rules cover
// the hosted site, and the env-gated global /api limiter in
// backend-server.js (SECURITY_RATE_LIMIT) covers self-hosts.
import crypto from 'node:crypto';
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import { validateReport, REPORT_TTL_DAYS, REPORT_MAX_BYTES } from '../common/report-schema.js';
import logger from '../server/logger.js';
// --- env / KV plumbing -------------------------------------------------------
// Read at request time (not module load) so tests and late-loaded dotenv both work.
const kvConfig = () => {
const apiKey = process.env.CLOUDFLARE_API_KEY || process.env.CLOUDFLARE_API;
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
const namespaceId = process.env.CLOUDFLARE_KV_NAMESPACE_ID;
if (!apiKey || !accountId || !namespaceId) return null;
return { apiKey, accountId, namespaceId };
};
// Exposed for /api/configs (booleanized there).
export const isReportSharingConfigured = () => kvConfig() !== null;
// A tampered ttlDays is not worth a 400 — anything outside the whitelist is
// forced down to the shortest retention (1 day). Exported for tests.
export const normalizeTtlDays = (ttlDays) =>
(REPORT_TTL_DAYS.includes(ttlDays) ? ttlDays : REPORT_TTL_DAYS[0]);
const kvValueUrl = ({ accountId, namespaceId }, key, queryString = '') =>
`https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${key}${queryString}`;
// Surface Cloudflare's own error message (truncated) in thrown errors —
// a bare status code turns "multipart required" into a guessing game.
const kvErrorDetail = (response) =>
response.text().then((text) => text.slice(0, 300)).catch(() => '');
// --- handlers ------------------------------------------------------------------
// POST /api/report — body: { report, ttlDays }
const createReport = async (req, res) => {
// defensive; route gating covers this, but the smoke test asserts on it.
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method Not Allowed' });
}
const config = kvConfig();
if (!config) {
return res.status(503).json({ error: 'Report sharing is not configured' });
}
const { report, ttlDays } = req.body ?? {};
const { ok, errors } = validateReport(report);
if (!ok) {
return res.status(400).json({ error: 'Invalid report', details: errors });
}
const serialized = JSON.stringify(report);
if (Buffer.byteLength(serialized, 'utf8') > REPORT_MAX_BYTES) {
return res.status(413).json({ error: 'Report too large' });
}
try {
const id = crypto.randomBytes(16).toString('base64url');
const ttlSeconds = normalizeTtlDays(ttlDays) * 24 * 60 * 60;
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
// Stored value is a { expiresAt, report } wrapper so readers (the
// report page, AI agents fetching the link) can show when the report
// will disappear — KV's own TTL isn't readable via the values GET.
//
// The KV write endpoint requires multipart/form-data with `value` +
// `metadata` fields (a raw body is a 400); fetch derives the
// multipart boundary from the FormData, so no manual Content-Type.
const form = new FormData();
form.append('value', JSON.stringify({ expiresAt, report }));
form.append('metadata', '{}');
const response = await fetchUpstream(
kvValueUrl(config, id, `?expiration_ttl=${ttlSeconds}`),
{
method: 'PUT',
headers: { 'Authorization': `Bearer ${config.apiKey}` },
body: form,
},
);
if (!response.ok) throw new Error(`KV write responded ${response.status}: ${await kvErrorDetail(response)}`);
return res.status(201).json({ id, expiresAt });
} catch (error) {
logger.error({ err: error }, 'Report share create failed');
return res.status(500).json({ error: error.message });
}
};
// GET /api/report/:id — id format is enforced by requireValidReportId().
export const getReport = async (req, res) => {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method Not Allowed' });
}
const config = kvConfig();
if (!config) {
return res.status(503).json({ error: 'Report sharing is not configured' });
}
try {
const response = await fetchUpstream(kvValueUrl(config, req.params.id), {
headers: { 'Authorization': `Bearer ${config.apiKey}` },
});
if (response.status === 404) {
return res.status(404).json({ error: 'Report not found or expired' });
}
if (!response.ok) throw new Error(`KV read responded ${response.status}: ${await kvErrorDetail(response)}`);
// Stored values are validated { expiresAt, report } JSON; parse and
// re-serialize so a corrupt KV value fails here, not in the client.
return res.status(200).json(JSON.parse(await response.text()));
} catch (error) {
logger.error({ err: error, reportId: req.params.id }, 'Report share read failed');
return res.status(500).json({ error: error.message });
}
};
export default createReport;