files.js
5.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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<void>, get(file_id: string): Promise<Buffer> },
* }} 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<string|null>}
*/
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 }
}