Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 注册 / 新建用户 / 批量建用户:先算密码哈希,再同步地查重和插入, 同名并发请求返回 400 而不是撞唯一约束变成 500 - 上传任务素材改用 INSERT ... ON CONFLICT DO UPDATE - CLAUDE.md 补充同步阻塞、行内大文本列、索引、类型差异等注意事项 - 忽略根目录的 db.sqlite3(线上数据备份) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
5.2 KiB
TypeScript
135 lines
5.2 KiB
TypeScript
import { Hono } from "hono"
|
|
import { mkdir, rm } from "node:fs/promises"
|
|
import { existsSync } from "node:fs"
|
|
import { dirname, extname, join, resolve, sep } from "node:path"
|
|
import { config } from "../config"
|
|
import { all, get, run } from "../db"
|
|
import { requireAdmin, requireSuper, type User } from "../auth"
|
|
import { HttpError, ValidationError, intParam, orNotFound, type Ctx, type Env } from "../http"
|
|
|
|
// 与 Django 的 get_valid_filename 一致:空格换成下划线,去掉除字母数字、-、_、. 以外的字符
|
|
export function validFilename(name: string) {
|
|
const s = name.trim().replaceAll(" ", "_").replace(/[^-\p{L}\p{N}_.]/gu, "")
|
|
if (!s || s === "." || s === "..") throw new HttpError(400, `无效的文件名:${name}`)
|
|
return s
|
|
}
|
|
|
|
// 解析 media 下的相对路径,防止越出 media 目录
|
|
export function mediaPath(relative: string) {
|
|
const root = config.mediaRoot
|
|
const full = resolve(root, relative)
|
|
if (full !== root && !full.startsWith(root + sep)) throw new HttpError(400, "无效的路径")
|
|
return full
|
|
}
|
|
|
|
async function saveFile(relative: string, file: File) {
|
|
let target = relative
|
|
// 同名文件已存在时加随机后缀(Django 存储的行为)
|
|
while (existsSync(mediaPath(target))) {
|
|
const ext = extname(relative)
|
|
const suffix = Math.random().toString(36).slice(2, 9)
|
|
target = relative.slice(0, relative.length - ext.length) + "_" + suffix + ext
|
|
}
|
|
const full = mediaPath(target)
|
|
await mkdir(dirname(full), { recursive: true })
|
|
await Bun.write(full, file)
|
|
return target
|
|
}
|
|
|
|
async function formFile(c: Ctx, field: string) {
|
|
const form = await c.req.formData().catch(() => null)
|
|
const value = form?.get(field)
|
|
if (!(value instanceof File)) {
|
|
throw new ValidationError([{ loc: ["body", field], msg: "缺少文件" }])
|
|
}
|
|
return { form: form!, file: value }
|
|
}
|
|
|
|
// ── 任务素材 ──────────────────────────────────────────────────────────────────
|
|
|
|
export const assets = new Hono<Env>()
|
|
|
|
type Kind = "challenge" | "tutorial"
|
|
|
|
function findTask(kind: Kind, display: number) {
|
|
return orNotFound(
|
|
get<{ id: number; task_type: string; display: number }>(
|
|
`SELECT t.id, t.task_type, t.display FROM task_task t
|
|
JOIN task_${kind} x ON x.task_ptr_id = t.id WHERE t.display = $display`,
|
|
{ display },
|
|
),
|
|
)
|
|
}
|
|
|
|
const assetOut = (a: { name: string; file: string }) => ({
|
|
name: a.name,
|
|
url: config.mediaUrl + a.file,
|
|
})
|
|
|
|
for (const kind of ["challenge", "tutorial"] as const) {
|
|
// 挑战素材管理员可改,教程素材仅超管可改
|
|
const requireEditor: (c: Ctx) => User = kind === "challenge" ? requireAdmin : requireSuper
|
|
|
|
assets.get(`/${kind}/:display`, (c) => {
|
|
const task = findTask(kind, intParam(c, "display"))
|
|
const rows = all<{ name: string; file: string }>(
|
|
`SELECT name, file FROM task_taskasset WHERE task_id = $id ORDER BY id`,
|
|
{ id: task.id },
|
|
)
|
|
return c.json(rows.map(assetOut))
|
|
})
|
|
|
|
assets.post(`/${kind}/:display`, async (c) => {
|
|
requireEditor(c)
|
|
const task = findTask(kind, intParam(c, "display"))
|
|
const { form, file } = await formFile(c, "file")
|
|
const name = form.get("name")
|
|
if (typeof name !== "string" || !name) {
|
|
throw new ValidationError([{ loc: ["body", "name"], msg: "缺少文件名" }])
|
|
}
|
|
const existing = get<{ id: number; file: string }>(
|
|
`SELECT id, file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
|
|
{ task_id: task.id, name },
|
|
)
|
|
if (existing?.file) await rm(mediaPath(existing.file), { force: true })
|
|
const stored = await saveFile(
|
|
`tasks/${task.task_type}/${task.display}/${validFilename(name)}`,
|
|
file,
|
|
)
|
|
// 上面 await 期间可能有同名上传先写入了,用 upsert 而不是按 existing 选 INSERT / UPDATE
|
|
run(
|
|
`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)
|
|
ON CONFLICT (task_id, name) DO UPDATE SET file = excluded.file`,
|
|
{ name, file: stored, task_id: task.id },
|
|
)
|
|
return c.json(assetOut({ name, file: stored }))
|
|
})
|
|
|
|
assets.delete(`/${kind}/:display/:name`, async (c) => {
|
|
requireEditor(c)
|
|
const task = findTask(kind, intParam(c, "display"))
|
|
const asset = orNotFound(
|
|
get<{ id: number; file: string }>(
|
|
`SELECT id, file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
|
|
{ task_id: task.id, name: c.req.param("name") },
|
|
),
|
|
)
|
|
if (asset.file) await rm(mediaPath(asset.file), { force: true })
|
|
run(`DELETE FROM task_taskasset WHERE id = $id`, { id: asset.id })
|
|
return c.json({ message: "删除成功" })
|
|
})
|
|
}
|
|
|
|
// ── 编辑器里插入图片 ──────────────────────────────────────────────────────────
|
|
|
|
export const upload = new Hono<Env>()
|
|
|
|
upload.post("/", async (c) => {
|
|
requireSuper(c)
|
|
const { file } = await formFile(c, "image")
|
|
const filename = crypto.randomUUID() + extname(file.name)
|
|
await mkdir(config.mediaRoot, { recursive: true })
|
|
await Bun.write(join(config.mediaRoot, filename), file)
|
|
return c.json({ url: config.mediaUrl + filename })
|
|
})
|