From 21dc02c33326927338dc6ce42f41a3ee3fc2366f Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 2 Sep 2026 22:55:57 +0800 Subject: [PATCH] Add T39: playback management + operator blacklist (M.11) --- dance-admin-ops.test.js | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ dressup-store.js | 7 ++++++- server.js | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 396 insertions(+), 11 deletions(-) create mode 100644 dance-admin-ops.test.js diff --git a/dance-admin-ops.test.js b/dance-admin-ops.test.js new file mode 100644 index 0000000..0db5776 --- /dev/null +++ b/dance-admin-ops.test.js @@ -0,0 +1,247 @@ +// Integration test for T39 (playback management + operator blacklist), +// dev-plan-5.md. Same mock-H3-Console approach as dance-mode2/3.test.js. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const http = require("node:http"); +const { spawn } = require("node:child_process"); + +const PORT = 34934 + Math.floor(Math.random() * 1000); +const BASE = `http://localhost:${PORT}`; +const ADMIN_PASSWORD = "test-password-only-for-this-run"; +let adminCookie, child, cwd, mockH3, mockH3Port, videoPollCounts, defaultCharacter; + +function waitForServer(url, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + return (async function poll() { + while (Date.now() < deadline) { + try { + await fetch(url); + return; + } catch { + await new Promise((r) => setTimeout(r, 100)); + } + } + throw new Error("server did not come up in time"); + })(); +} +function sessionCookie(res) { + return (res.headers.get("set-cookie") || "").split(";")[0]; +} +function authed(cookie, opts = {}) { + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } }; +} + +function startMockH3() { + videoPollCounts = new Map(); + let nextId = 1; + return new Promise((resolve) => { + mockH3 = http.createServer((req, res) => { + req.url = req.url.replace(/^\/+/, "/"); + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + if (req.method === "POST" && req.url === "/v1/assets") { + const id = `asset-${nextId++}`; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" })); + return; + } + if (req.method === "POST" && req.url === "/v1/videos") { + const id = `job-${nextId++}`; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id })); + return; + } + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/); + if (pollMatch) { + const id = pollMatch[1]; + const seen = (videoPollCounts.get(id) || 0) + 1; + videoPollCounts.set(id, seen); + if (seen < 2) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id, status: "generating" })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id, status: "done", partition: "ref2va", req: { width: 480, height: 832, frames: 240 } })); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found in mock" })); + }); + }); + mockH3.listen(0, () => { + mockH3Port = mockH3.address().port; + resolve(); + }); + }); +} + +async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const v = await fn(); + if (v) return v; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error("condition not met in time"); +} + +async function registerVerifiedUser(base) { + const email = `adminops-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`; + const registerRes = await fetch(`${base}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password: "correct-horse-battery" }), + }); + const { devVerifyUrl } = await registerRes.json(); + const cookie = sessionCookie(registerRes); + await fetch(`${base}${devVerifyUrl}`); + return cookie; +} + +test.before(async () => { + await startMockH3(); + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-admin-ops-")); + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); + } + fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true }); + fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes"); + fs.writeFileSync(path.join(cwd, "uploads", "garment.png"), "fake-png-bytes"); + fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes"); + + child = spawn(process.execPath, ["server.js"], { + cwd, + env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`, H3_CONSOLE_TOKEN: "mock-token", ADMIN_PASSWORD, GENERATION_POLL_INTERVAL_MS: "150", SCHEDULE_TICK_MS: "100" }, + stdio: "pipe", + }); + await waitForServer(BASE); + + const loginRes = await fetch(`${BASE}/api/admin/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: ADMIN_PASSWORD }) }); + adminCookie = sessionCookie(loginRes); + defaultCharacter = await fetch( + `${BASE}/api/admin/characters`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) }) + ).then((r) => r.json()); + await fetch(`${BASE}/api/admin/dance-refs`, authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })); +}); +test.after(() => { + child.kill(); + mockH3.close(); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +test("blacklist CRUD: create/list/delete, and creation is attributed to the acting admin", async () => { + const created = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie, { method: "POST", body: JSON.stringify({ targetType: "garment", targetId: "some-garment-id", reason: "测试" }) })).then((r) => r.json()); + assert.ok(created.id); + assert.equal(created.targetType, "garment"); + assert.equal(created.createdBy, null); // legacy ADMIN_PASSWORD channel has no per-admin identity, same as T30's credit/adjust + + const list = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie)).then((r) => r.json()); + assert.ok(list.some((b) => b.id === created.id)); + + const del = await fetch(`${BASE}/api/admin/blacklist/${created.id}`, authed(adminCookie, { method: "DELETE" })).then((r) => r.json()); + assert.equal(del.deleted, true); + const listAfter = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie)).then((r) => r.json()); + assert.ok(!listAfter.some((b) => b.id === created.id)); +}); + +test("blacklisting a garment removes it from the wardrobe and rejects /api/dance/pick, without deleting the record", async () => { + const garment = await fetch( + `${BASE}/api/admin/garments`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "待拉黑的裙子", imageUrl: "/uploads/garment.png", appearanceDescriptor: "black dress" }) }) + ).then((r) => r.json()); + + let wardrobe = await fetch(`${BASE}/api/dance/wardrobe`).then((r) => r.json()); + assert.ok(wardrobe.some((g) => g.id === garment.id)); + + const entry = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie, { method: "POST", body: JSON.stringify({ targetType: "garment", targetId: garment.id, reason: "举报" }) })).then((r) => r.json()); + + wardrobe = await fetch(`${BASE}/api/dance/wardrobe`).then((r) => r.json()); + assert.ok(!wardrobe.some((g) => g.id === garment.id), "a blacklisted garment must not appear in the wardrobe"); + + const pickRes = await fetch(`${BASE}/api/dance/pick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ garmentId: garment.id }) }); + assert.equal(pickRes.status, 403); + + // record itself is untouched + const stillThere = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json()); + assert.ok(stillThere.some((g) => g.id === garment.id)); + + await fetch(`${BASE}/api/admin/blacklist/${entry.id}`, authed(adminCookie, { method: "DELETE" })); // cleanup for later tests +}); + +test("blacklisting a user rejects their paid uploads at the entry point (403, before any spend)", async () => { + const cookie = await registerVerifiedUser(BASE); + const me = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie, { method: "POST", body: JSON.stringify({ targetType: "user", targetId: me.id, reason: "测试" }) })); + + const fd = new FormData(); + fd.append("file", new Blob([Buffer.from("not-really-an-image")], { type: "image/png" }), "x.png"); + const res = await fetch(`${BASE}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd }); + assert.equal(res.status, 403); +}); + +test("admin can pin an existing clip to play next", async () => { + const garment = await fetch( + `${BASE}/api/admin/garments`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "置顶测试裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "pink dress" }) }) + ).then((r) => r.json()); + const genRes = await fetch( + `${BASE}/api/admin/generate`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ characterId: defaultCharacter.id, garmentId: garment.id }) }) + ).then((r) => r.json()); + const clip = await waitUntil(async () => { + const job = await fetch(`${BASE}/api/admin/generate/${genRes.jobId}`, authed(adminCookie)).then((r) => r.json()); + return job.status === "done" ? job : null; + }).then((job) => fetch(`${BASE}/api/library`).then((r) => r.json()).then((lib) => lib.find((c) => c.id === job.generatedVideoId))); + + const pinRes = await fetch(`${BASE}/api/admin/dance/schedule/pin`, authed(adminCookie, { method: "POST", body: JSON.stringify({ videoId: clip.id }) })); + assert.equal(pinRes.status, 200); + const { schedule } = await pinRes.json(); + assert.ok(schedule.length >= 2); + assert.equal(schedule[1].videoId, clip.id, "the pinned clip must land in the very next slot"); +}); + +test("admin can skip the currently-playing clip", async () => { + const before = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); + const beforeStartAt = before.current ? before.current.startAt : null; + + const skipRes = await fetch(`${BASE}/api/admin/dance/schedule/skip`, authed(adminCookie, { method: "POST" })); + assert.equal(skipRes.status, 200); + + const after = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); + // Compare startAt, not clip id — with a small candidate pool, skip's + // replacement can legitimately be the same clip content again; what + // must change is that it's a *freshly scheduled* entry, not the same + // stale one. + assert.notEqual(after.current.startAt, beforeStartAt, "skip must replace the current schedule entry with a freshly scheduled one"); +}); + +test("pausing auto-fill stops the schedule from growing, resuming lets it grow again", async () => { + await fetch(`${BASE}/api/admin/dance/pause`, authed(adminCookie, { method: "POST", body: JSON.stringify({ paused: true }) })); + + // Drain down to a stable size while paused — skip() only removes/expires + // entries; with the fill loop gated by dancePaused, nothing refills it, + // so repeated skips converge on length 1 (skip's own "only one entry + // left" branch just re-stamps it in place rather than removing it). + let drainedLength = null; + for (let i = 0; i < 6; i++) { + const { schedule } = await fetch(`${BASE}/api/admin/dance/schedule/skip`, authed(adminCookie, { method: "POST" })).then((r) => r.json()); + if (schedule.length === drainedLength) break; + drainedLength = schedule.length; + } + + await new Promise((r) => setTimeout(r, 500)); // several SCHEDULE_TICK_MS, still paused + const stillPaused = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); + assert.equal((stillPaused.upNext || []).length + 1, drainedLength, "schedule must not grow while paused"); + + await fetch(`${BASE}/api/admin/dance/pause`, authed(adminCookie, { method: "POST", body: JSON.stringify({ paused: false }) })); + await waitUntil(async () => { + const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); + return (s.upNext || []).length + 1 > drainedLength ? true : null; + }); +}); diff --git a/dressup-store.js b/dressup-store.js index 42b0321..9314eb3 100644 --- a/dressup-store.js +++ b/dressup-store.js @@ -18,4 +18,9 @@ const generationJobs = createStore(path.join(__dirname, "generation-jobs.json")) // what the automated gate did even before a real vendor is picked. const moderationLog = createStore(path.join(__dirname, "moderation-log.json")); -module.exports = { characters, garments, danceRefs, generationJobs, moderationLog }; +// Operator hand-curated blocklist (dev-plan-5.md T39; schema M.9's near +// neighbor is M.11 — a *different* table: this is manual takedowns, not +// the automated face-blacklist model (T32) or garment moderation (T26). +const blacklist = createStore(path.join(__dirname, "blacklist.json")); + +module.exports = { characters, garments, danceRefs, generationJobs, moderationLog, blacklist }; diff --git a/server.js b/server.js index 3b39990..1539dc3 100644 --- a/server.js +++ b/server.js @@ -74,7 +74,7 @@ const { forwardToH3Assets, forwardToFalAssets, } = require("./uploads.js"); -const { characters, garments, danceRefs, generationJobs, moderationLog } = require("./dressup-store.js"); +const { characters, garments, danceRefs, generationJobs, moderationLog, blacklist } = require("./dressup-store.js"); const { buildStage2Prompt } = require("./prompt-template.js"); const { users: userStore, sessions: userSessions, creditLedger } = require("./db.js"); const { pricing } = require("./pricing.js"); @@ -1030,6 +1030,14 @@ async function falFetchResult(requestId) { return body; // { video: { url, ... }, expanded_prompt } } +// T39 (dev-plan-5.md; schema M.11) — operator hand-curated blocklist, +// distinct from T32's automated face-blacklist model and T26's garment +// moderation (see blacklist store's own doc comment, dressup-store.js). +function isBlacklisted(targetType, targetId) { + if (!targetId) return false; + return blacklist.list().some((b) => b.targetType === targetType && b.targetId === targetId); +} + function pickRefDanceVideo() { const pool = danceRefs.list().filter((d) => d.isActive); if (pool.length === 0) throw new Error("no active dance-refs available"); @@ -1322,6 +1330,26 @@ function resumeDressupGenerations() { // danceQueue/danceSchedule instead, and picks its random-fill pool from // channel:"dance" clips only. +// Shared by T34's priorityInbox drain and T39's admin "置顶下一条" pin — +// inserts right after whatever's currently playing (danceSchedule[0]), +// pushing every later entry's timing back by this clip's duration rather +// than appending at the tail like the normal fill loop does. +function insertClipNext(clip, requestedBy, now) { + const insertAt = danceSchedule.length ? 1 : 0; + const prevEndAt = insertAt > 0 ? danceSchedule[insertAt - 1].endAt : now; + danceSchedule.splice(insertAt, 0, { videoId: clip.id, startAt: prevEndAt, endAt: prevEndAt + clip.duration * 1000, requestedBy: requestedBy || null }); + for (let i = insertAt + 1; i < danceSchedule.length; i++) { + danceSchedule[i].startAt += clip.duration * 1000; + danceSchedule[i].endAt += clip.duration * 1000; + } +} + +// T39: admin "暂停自动填充" — freezes only the random-fill loop below (free +// mode-一 content competing for slots); priorityInbox (paid_boost) still +// drains immediately even while paused, matching 需求文档 6.5 节's own +// framing of "暂停自动填充" as a filler-specific control, not a full stop. +let dancePaused = false; + function pickNextDanceClip() { const readyIdx = pickBestIndexByStatus(danceQueue, "ready"); if (readyIdx !== -1) { @@ -1332,8 +1360,14 @@ function pickNextDanceClip() { // (status:"expired") drops out of random-fill selection — the record // and its videos stay in library.json (M.3/M.7: "记录保留"), just no // longer eligible to be picked here. + // T39: a blacklisted generated_video/character/garment (M.11) is filtered + // out of the candidate pool the same way — "从排播候选和衣柜列表里过滤 + // 掉(不删除记录)". const pool = getLibrary().filter((c) => { if (c.channel !== "dance") return false; + if (isBlacklisted("generated_video", c.id)) return false; + if (c.characterId && isBlacklisted("character", c.characterId)) return false; + if (c.garmentId && isBlacklisted("garment", c.garmentId)) return false; if (!c.characterId) return true; const character = characters.get(c.characterId); return !character || character.status !== "expired"; @@ -1419,16 +1453,10 @@ function ensureDanceSchedule() { if (!clip) continue; // stale/removed videoId — drop rather than schedule a dead entry markPlayed(clip.id); consumeCharacterSegment(clip); - const insertAt = danceSchedule.length ? 1 : 0; - const prevEndAt = insertAt > 0 ? danceSchedule[insertAt - 1].endAt : now; - danceSchedule.splice(insertAt, 0, { videoId: next.videoId, startAt: prevEndAt, endAt: prevEndAt + clip.duration * 1000, requestedBy: next.requestedBy }); - for (let i = insertAt + 1; i < danceSchedule.length; i++) { - danceSchedule[i].startAt += clip.duration * 1000; - danceSchedule[i].endAt += clip.duration * 1000; - } + insertClipNext(clip, next.requestedBy, now); } - while (danceSchedule.length === 0 || danceSchedule[danceSchedule.length - 1].endAt < now + FILLER_DURATION_MS) { + while (!dancePaused && (danceSchedule.length === 0 || danceSchedule[danceSchedule.length - 1].endAt < now + FILLER_DURATION_MS)) { const picked = pickNextDanceClip(); if (!picked || !picked.videoId) break; const clip = getLibrary().find((c) => c.id === picked.videoId); @@ -1796,6 +1824,91 @@ http return; } + // T39 (M.11): 运营手工拉黑. 拦截点在提交类接口自身 + // (/api/dance/pick、两个 upload——见各自的 isBlacklisted 调用),这里 + // 只是维护这张表. + if (pathname === "/api/admin/blacklist" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(blacklist.list())); + return; + } + if (pathname === "/api/admin/blacklist" && req.method === "POST") { + readJsonBody(req) + .then((body) => { + const targetType = (body.targetType || "").trim(); + const targetId = (body.targetId || "").trim(); + const validTypes = ["user", "character", "garment", "generated_video", "ip"]; + if (!validTypes.includes(targetType) || !targetId) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: `targetType must be one of ${validTypes.join("/")}, targetId is required` })); + return; + } + const entry = blacklist.create({ targetType, targetId, reason: (body.reason || "").trim(), createdBy: adminActorUserId(req) }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(entry)); + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + }); + return; + } + if (pathname.startsWith("/api/admin/blacklist/") && req.method === "DELETE") { + const removed = blacklist.remove(pathname.slice("/api/admin/blacklist/".length)); + res.writeHead(removed ? 200 : 404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ deleted: removed })); + return; + } + + // T39 (需求文档 6.5 节): dance.html 播放管理——插播/置顶、跳过当前、 + // 暂停/恢复自动填充. + if (pathname === "/api/admin/dance/schedule/pin" && req.method === "POST") { + readJsonBody(req) + .then((body) => { + const clip = getLibrary().find((c) => c.id === body.videoId && c.channel === "dance"); + if (!clip) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "videoId must reference an existing dance clip" })); + return; + } + ensureDanceSchedule(); // settle any pending state before splicing + insertClipNext(clip, "管理员置顶", Date.now()); + saveDanceState(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, schedule: danceSchedule })); + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + }); + return; + } + if (pathname === "/api/admin/dance/schedule/skip" && req.method === "POST") { + ensureDanceSchedule(); + if (danceSchedule.length > 1) { + danceSchedule.shift(); // next tick's own "shift expired entries" step will pick up the new current from here + } else if (danceSchedule.length === 1) { + danceSchedule[0].endAt = Date.now(); // nothing queued behind it — force it to expire now, next tick fills a replacement + } + ensureDanceSchedule(); // fill immediately rather than waiting up to SCHEDULE_TICK_MS + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, schedule: danceSchedule })); + return; + } + if (pathname === "/api/admin/dance/pause" && req.method === "POST") { + readJsonBody(req) + .then((body) => { + dancePaused = Boolean(body.paused); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ paused: dancePaused })); + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + }); + return; + } + if (pathname === "/api/admin/generate" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(generationJobs.list())); @@ -1884,7 +1997,7 @@ http if (pathname === "/api/dance/wardrobe" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(garments.list().filter((g) => g.isActive))); + res.end(JSON.stringify(garments.list().filter((g) => g.isActive && !isBlacklisted("garment", g.id)))); return; } @@ -1947,6 +2060,14 @@ http res.end(JSON.stringify({ error: "garmentId must reference an active garment" })); return; } + // T39 (M.11): a blacklisted garment is rejected at the submission + // entry point itself — "不进流水线、不扣积分" (moot here, mode 1 + // is free, but the entry-point rejection still applies). + if (isBlacklisted("garment", garment.id)) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "this garment is unavailable" })); + return; + } const defaultCharacter = characters.list().find((c) => c.isDefault); if (!defaultCharacter) { res.writeHead(400, { "Content-Type": "application/json" }); @@ -2032,6 +2153,13 @@ http res.end(JSON.stringify({ error: "email verification required before uploading a garment" })); return; } + // T39 (M.11): a blacklisted account is rejected at the submission + // entry point — "不进流水线、不扣积分". + if (isBlacklisted("user", user.id)) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "account unavailable" })); + return; + } const boundary = boundaryFromContentType(req.headers["content-type"] || ""); if (!boundary) { res.writeHead(400, { "Content-Type": "application/json" }); @@ -2170,6 +2298,11 @@ http res.end(JSON.stringify({ error: "email verification required before uploading a character" })); return; } + if (isBlacklisted("user", user.id)) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "account unavailable" })); + return; + } const boundary = boundaryFromContentType(req.headers["content-type"] || ""); if (!boundary) { res.writeHead(400, { "Content-Type": "application/json" }); -- libgit2 0.21.4