Files
nntrivi2001 ed1bf20e6b feat(sagi): custom pets — Codex pet bundles, images, featured downloads
The mascot can wear a pet. Right-click it for Hide pet, Size (48-250
px), Choose pet (Sagi, featured, your own - deletable), Upload your
own..., Import .codex-pet bundle..., Find more on codex-pets.net and
Pet settings.... The worn pet and its size are viewer prefs
(agenthub.pet, agenthub.pet.size); the pets live on the server under
<data dir>/pets/<id>/ (server/lib/pets.js, /api/pets).

A Codex pet is the .codex-pet.zip format: pet.json + a 1536x1872
spritesheet.webp, 8x9 frames of 192x208, one row per state.
PetSprite.tsx plays the row of the mascot's mood; an uploaded image
bobs. Only the two bundle files are read, sizes are checked from the
zip directory before inflating, the sheet's WebP header must match,
uploads are raster only (no SVG), ids are made by the server, every
write is same-origin.

The four featured pets (Goku, Bac Ho, Border Collie, 11) are
codex-pets.net uploads with no redistribution licence, so none is
bundled: a pick downloads it from codex-pets.net by a fixed id.
2026-10-03 16:25:42 +07:00

100 lines
2.7 KiB
JavaScript

/**
* @file pets.js (routes)
* @description `/api/pets` — the mascot's custom pets (`server/lib/pets.js`):
* the list plus the featured ones, a pet's sprite, importing a `.codex-pet.zip`,
* uploading an image, installing a featured pet from codex-pets.net by its
* fixed id, and deleting one. Every write is same-origin; no route takes a
* URL or a path.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const express = require("express");
const pets = require("../lib/pets");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
const STATUS = {
ENOPET: 404,
ETOOBIG: 413,
ETOOMANY: 409,
EFETCH: 502,
EBADORIGIN: 403,
};
function sendError(res, err) {
if (err && typeof err.code === "string" && /^E[A-Z]+$/.test(err.code)) {
return res
.status(STATUS[err.code] || 400)
.json({ error: { code: err.code, message: err.message } });
}
return res
.status(500)
.json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
}
router.get("/", (_req, res) => {
try {
res.json({ pets: pets.listPets(), featured: pets.featured(), sheet: pets.SHEET });
} catch (err) {
sendError(res, err);
}
});
router.get("/:id/sprite", (req, res) => {
try {
const file = pets.spriteFile(req.params.id);
res.set("Content-Type", file.type);
res.set("X-Content-Type-Options", "nosniff");
res.set("Cache-Control", "private, max-age=3600");
res.sendFile(file.path);
} catch (err) {
sendError(res, err);
}
});
const raw = (limit) => express.raw({ type: () => true, limit });
router.post("/import", sameOriginGuard, raw(pets.MAX_BUNDLE_BYTES), (req, res) => {
try {
res.status(201).json({ pet: pets.importBundle(req.body) });
} catch (err) {
sendError(res, err);
}
});
router.post("/upload", sameOriginGuard, raw(pets.MAX_IMAGE_BYTES), (req, res) => {
try {
res.status(201).json({ pet: pets.addImage(req.body, req.query.name) });
} catch (err) {
sendError(res, err);
}
});
router.post("/featured/:id", sameOriginGuard, async (req, res) => {
try {
res.status(201).json({ pet: await pets.installFeatured(req.params.id) });
} catch (err) {
sendError(res, err);
}
});
router.delete("/:id", sameOriginGuard, (req, res) => {
try {
pets.removePet(req.params.id);
res.json({ ok: true });
} catch (err) {
sendError(res, err);
}
});
// An upload past its limit reaches here as express.raw's 413.
router.use((err, _req, res, next) => {
if (err && err.type === "entity.too.large")
return res.status(413).json({ error: { code: "ETOOBIG", message: "the file is too large" } });
return next(err);
});
module.exports = router;