- Q3: prod void DB role NOSUPERUSER (vector marked trusted; deploy/README documents it) - Q4: buildChildEnv allow-list for the claude subprocess (no OWNER_TOKEN/DATABASE_URL/secrets leak) - Q5: pending-change approve claims-before-applying + reopens on failure (no re-approvable dup) - Q6: /capture/upload validates space_id (UUID+existence); pg pool statement_timeout 30s - Q9: disabled failing syncoid-donatello timer on Z Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
2.9 KiB
JavaScript
92 lines
2.9 KiB
JavaScript
import { Router } from 'express';
|
|
import { z } from 'zod';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import multer from 'multer';
|
|
import * as queue from '../../jobs/queue.js';
|
|
import { pool } from '../../db/pool.js';
|
|
import { validate } from '../validate.js';
|
|
import { requireWrite } from '../cap.js';
|
|
import { asyncWrap } from '../errors.js';
|
|
|
|
const captureBody = z.object({
|
|
space_id: z.string().uuid(),
|
|
url: z.string().url(),
|
|
hint: z.object({
|
|
project_id: z.string().uuid().optional(),
|
|
title: z.string().optional(),
|
|
tags: z.array(z.string()).optional()
|
|
}).optional()
|
|
});
|
|
|
|
const UPLOAD_TMP = process.env.UPLOAD_TMP || path.join(os.tmpdir(), 'void-uploads');
|
|
fs.mkdirSync(UPLOAD_TMP, { recursive: true });
|
|
const upload = multer({ dest: UPLOAD_TMP, limits: { fileSize: 100 * 1024 * 1024 } });
|
|
|
|
function key(space_id, url) {
|
|
return crypto.createHash('sha256').update(space_id + '\x00' + url).digest('hex');
|
|
}
|
|
|
|
const VIDEO_HOST_RE = /(^|\.)(youtube\.com|youtu\.be|vimeo\.com)$/i;
|
|
|
|
function isVideoUrl(url) {
|
|
try { return VIDEO_HOST_RE.test(new URL(url).hostname); }
|
|
catch { return false; }
|
|
}
|
|
|
|
export const router = Router();
|
|
|
|
router.post('/',
|
|
requireWrite('ref'),
|
|
validate({ body: captureBody }),
|
|
asyncWrap(async (req, res) => {
|
|
const { space_id, url } = req.body;
|
|
const idem = key(space_id, url);
|
|
const { rows: [existing] } = await pool.query(
|
|
`SELECT id FROM refs WHERE source_kind='url' AND external_id=$1 LIMIT 1`,
|
|
[idem]
|
|
);
|
|
if (existing) {
|
|
return res.status(202).json({
|
|
job_id: null, idempotency_key: idem, ref_id: existing.id
|
|
});
|
|
}
|
|
const job_name = isVideoUrl(url) ? 'ingest.video' : 'ingest.url';
|
|
const job_id = await queue.enqueue(job_name, { space_id, url });
|
|
res.status(202).json({ job_id, idempotency_key: idem });
|
|
})
|
|
);
|
|
|
|
router.post('/upload',
|
|
requireWrite('ref'),
|
|
upload.single('file'),
|
|
asyncWrap(async (req, res) => {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: { code: 'validation_failed', message: 'file required' } });
|
|
}
|
|
const space_id = req.body.space_id;
|
|
if (!z.string().uuid().safeParse(space_id).success) {
|
|
return res.status(400).json({ error: { code: 'validation_failed', message: 'space_id must be a UUID' } });
|
|
}
|
|
const { rowCount } = await pool.query('SELECT 1 FROM spaces WHERE id=$1', [space_id]);
|
|
if (!rowCount) {
|
|
return res.status(404).json({ error: { code: 'not_found', message: 'space not found' } });
|
|
}
|
|
let meta = {};
|
|
if (req.body.meta) {
|
|
try { meta = JSON.parse(req.body.meta); }
|
|
catch { /* leave empty */ }
|
|
}
|
|
const job_id = await queue.enqueue('ingest.blob', {
|
|
space_id,
|
|
tmp_path: req.file.path,
|
|
filename: req.file.originalname,
|
|
content_type: req.file.mimetype,
|
|
meta
|
|
});
|
|
res.status(202).json({ job_id });
|
|
})
|
|
);
|