// ── Admin · Client assets ────────────────────────────────────────────────── // // Operating the asset import: what the site holds, what the shard's client files // currently are, and a re-import after a client patch (docs/link/v8.md §6, §8, // §14, protocol 8 phase 3). // // The policy lives in the model. This controller does three things and no more: // it validates input, it maps an import RESULT onto an HTTP status, and it // records the action in the admin activity log. // // **An import result is not an exception**, exactly as for clilocs. A shard that // is down, an asset plane the operator has switched off, a Linux host with no // libgdiplus, a client patched halfway through the walk — each is a 200 carrying // `status: 'unavailable'` and a reason naming what to fix, not a 500 that says // only "something broke". The one thing that DOES 500 is this file having a bug. // // **This is the only thing that imports.** Boot never calls the shard for assets, // for the same reason it stopped calling it for clilocs: the files change when an // operator patches their client, which is an event they know about and the site // does not. So this endpoint is what an operator presses afterwards. // // Phase 8 built the panel these serve (`Admin → Client Files`) and added one // thing to this pair: the import records a summary of what it did, and the // vanished keys it refuses to apply come back with the pictures they currently // have. Both exist because an operator pressing Update needs to see an answer, // and the audit log — which still receives every action here — is one unfiltered // list of every admin action on the site, so an import from three client patches // ago cannot be found in it (org lead, 2026-09-14). const assets = require('../../model/shardAssets/shardAssets.model') const itemArt = require('../../model/shardAssets/shardItemArt.model') const { activity } = require('../../core') const log = require('../../core').logger('admin-shard-assets') // GET /admin/shard/assets — what is loaded, what the shard says, whether they // disagree. No public counterpart: the assets themselves are served as ordinary // files under /uploads, and this is the operating view of the import. async function getStatus(req, res) { try { return res.json(await assets.getStatus()) } catch (err) { log.error('getStatus', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // POST /admin/shard/assets/import — import or update the body catalogue, then // re-resolve the atlas's creatures and re-derive their artwork. // // `force` re-imports even when the client files are unchanged. It is also how an // operator recovers a wiped uploads volume: the database still holds every hash, // so the ordinary gate would report "unchanged" while every picture is missing. // (The import checks for the file on disk per key as well, so that case usually // heals itself — `force` is the answer when it does not.) // // `approve` accepts a catalogue that no longer offers keys this site holds. // Refused by default because an unmounted client volume and a deliberate // downgrade look identical from the server, and the wrong guess deletes artwork. async function importAssets(req, res) { try { const force = !!req.body?.force const approve = !!req.body?.approve // From the session, never the body — the same rule the in-game ops routes // apply, and for the same reason: this is recorded as who did it. const result = await assets.importAssets({ force, approve, by: req.user?.username ?? null }) await activity.log({ req, action: 'shard.assets.import', detail: { force, approve, status: result.status, code: result.code ?? null, assets: result.assets ?? null, fetched: result.fetched ?? null, written: result.written ?? null, removed: result.removed ?? null, // The body pass is logged as its own tally rather than as a single // number: `unknown` means the spawn files name a type this shard's // scripts do not define, which is real drift an operator should see, and // it reads identically to `failed` if both are summed into "not resolved". bodies: result.bodies?.tally ?? null, vanished: result.vanishedCount ?? null, }, }) return res.json(result) } catch (err) { log.error('importAssets', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // POST /admin/shard/assets/warm — run one item-art warm pass now. // // The pass runs on its own timer and needs no operator, so this exists for the // two moments where waiting for the interval is the wrong answer: an operator who // has just configured the bridge and wants to see it work, and one who has just // patched their client and would rather not wait for pictures to refresh. // // `force` re-fetches keys the site already holds. The body import's `force` means // the same thing for the same reason — a wiped uploads volume leaves every // database row correct and every picture missing, and only an explicit re-fetch // recovers it. // // It is bounded: one pass asks for at most `limit` keys, because the shard's // asset plane serves one request at a time and a pass must not hold that slot // against the operator's own import. async function warmItemArt(req, res) { try { const force = !!req.body?.force const limit = Number.isFinite(Number(req.body?.limit)) ? Number(req.body.limit) : undefined const result = await itemArt.warm({ force, ...(limit ? { limit } : {}) }) await activity.log({ req, action: 'shard.assets.warm', detail: { force, limit: limit ?? null, status: result.status, code: result.code ?? null, wanted: result.wanted ?? null, asked: result.asked ?? null, written: result.written ?? null, remaining: result.remaining ?? null, }, }) return res.json(result) } catch (err) { log.error('warmItemArt', err) return res.status(500).json({ message: 'Internal Server Error' }) } } module.exports = { getStatus, importAssets, warmItemArt, }