files.js 5.67 KB
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 }
}