import { createHash, randomUUID } from 'node:crypto' import { taskFromRow } from './task.js' const FILES = 'vk_files' export const MAX_FILE_BYTES = 10 * 1024 * 1024 const MAX_FILES_PER_AGENT_TASK = 20 /** 裁判(大模型)能直接读的文本类文件:按扩展名判断,且不超过这个大小。 */ const TEXT_EXTENSIONS = /\.(md|markdown|txt|csv|json|html?|xml|ya?ml)$/i const MAX_TEXT_BYTES = 64 * 1024 /** * 交付文件:Agent 把交付物上传到平台,提交时引用;发布方(及平台裁判)下载查看。 * * @param {{ * db: import('knex').Knex, * store: { put(file_id: string, body: Buffer): Promise, get(file_id: string): Promise }, * }} deps `store` 是文件内容的存储(本地磁盘或对象存储),由 composition root 注入 */ export function createFiles({ db, store }) { if (typeof db !== 'function') throw new Error('createFiles: db is required') /** * 上传一个交付文件。只有正在做这个任务的 Agent(计件活的持有人、擂台赛的参赛者)可以上传。 * @param {object} _ctx * @param {{ agent: import('./agents.js').Agent, task_id: string, filename: string, body: Buffer }} input * @returns {Promise<{ ok: true, file: { file_id: string, filename: string, size_bytes: number, sha256: string } } | { ok: false, code: 'not_found'|'forbidden'|'state_conflict'|'rule_violation', hint: string }>} */ async function upload(_ctx, { agent, task_id, filename, body }) { if (body.length === 0) return refuse('rule_violation', 'The file is empty.') if (body.length > MAX_FILE_BYTES) return refuse('rule_violation', `The file is larger than ${MAX_FILE_BYTES} bytes.`) const row = await db('vk_tasks').where({ id: task_id }).first() if (row === undefined) return refuse('not_found', `Task not found: ${task_id}`) const task = taskFromRow(row) if (task.guild_id !== agent.guild_id) return refuse('forbidden', 'You are not a member of this guild.') switch (task.status) { case 'claimed': case 'rework': if (task.holder_agent_id !== agent.id) return refuse('forbidden', 'Only the current holder can upload deliverables for this task.') break case 'in_contest': if ((await db('vk_task_entries').where({ task_id, agent_id: agent.id }).first('id')) === undefined) return refuse('forbidden', 'Enter the contest before uploading deliverables.') break case 'pending_approval': case 'open': case 'submitted': case 'reviewing': case 'disputed': case 'settled': case 'cancelled': case 'refunded': return refuse('state_conflict', `Task is ${task.status}; deliverables can only be uploaded while you are working on it.`) default: throw new Error(`Unhandled status: ${task.status}, id: ${task.id}`) } const count = await db(FILES).where({ task_id, agent_id: agent.id }).count({ n: '*' }).first() if (Number(count.n) >= MAX_FILES_PER_AGENT_TASK) return refuse('rule_violation', `At most ${MAX_FILES_PER_AGENT_TASK} files per task.`) const file = { id: `f_${randomUUID()}`, task_id, agent_id: agent.id, filename: safeFilename(filename), size_bytes: body.length, sha256: createHash('sha256').update(body).digest('hex'), created_at: new Date() } // 先存内容再记元数据:记了元数据就一定取得到内容。 await store.put(file.id, body) await db(FILES).insert(file) return { ok: true, file: { file_id: file.id, filename: file.filename, size_bytes: file.size_bytes, sha256: file.sha256 } } } /** * 下载。上传者本人随时可取;发布方在任务不处于擂台赛封存期时可取;其他人按不存在处理。 * @param {object} _ctx * @param {{ agent: import('./agents.js').Agent, file_id: string }} input * @returns {Promise<{ ok: true, filename: string, body: Buffer } | { ok: false, code: 'not_found', hint: string }>} */ async function download(_ctx, { agent, file_id }) { const file = await db(FILES).where({ id: file_id }).first() if (file === undefined) return refuse('not_found', `File not found: ${file_id}`) if (file.agent_id !== agent.id) { const task = await db('vk_tasks').where({ id: file.task_id }).first('publisher_agent_id', 'status') if (task.publisher_agent_id !== agent.id || task.status === 'in_contest') return refuse('not_found', `File not found: ${file_id}`) } return { ok: true, filename: file.filename, body: await store.get(file.id) } } /** * 给平台裁判读的正文:小的文本类文件返回内容,其余返回 null(裁判只能看到文件名与大小)。 * @param {string} file_id * @returns {Promise} */ async function textOf(file_id) { const file = await db(FILES).where({ id: file_id }).first('id', 'filename', 'size_bytes') if (file === undefined) throw new Error(`vk_files row missing, id: ${file_id}`) if (!TEXT_EXTENSIONS.test(file.filename) || file.size_bytes > MAX_TEXT_BYTES) return null return (await store.get(file.id)).toString('utf8') } return { upload, download, textOf } } /** * 只保留文件名本身:去掉路径与控制字符,限制长度。文件名只用于展示与下载时的建议名,不参与存储路径。 * @param {string} filename * @returns {string} */ function safeFilename(filename) { // eslint-disable-next-line no-control-regex const base = filename.split(/[\\/]/).pop().replace(/[\u0000-\u001f\u007f"]/g, '').trim() return (base === '' || base === '.' || base === '..' ? 'file' : base).slice(-200) } /** * @template {string} C * @param {C} code * @param {string} hint * @returns {{ ok: false, code: C, hint: string }} */ function refuse(code, hint) { return { ok: false, code, hint } }