Matchu
7373a46bd7
Dice makes a good point! This is slightly clearer about what the valid set of sizes are! Why the hell not be slightly clearer! :3
237 lines
8.8 KiB
JavaScript
237 lines
8.8 KiB
JavaScript
/**
|
|
* /api/outfitImage returns an image of an outfit!
|
|
*
|
|
* Parameters:
|
|
* - size: Must be "150", "300", or "600", to indicate the image size you'd
|
|
* like back. (For example, "150" will return a 150x150 image.)
|
|
* - layerUrls: A comma-separated list of URLs to render, in order from
|
|
* bottom to top. This is a sorta "independent" render mode,
|
|
* not bound to any saved outfit. The URLs must match a known
|
|
* layer URL format. This mode will return a long-term cache
|
|
* header, so the client and our CDN cache can cache the
|
|
* requested URL forever. (NOTE: The Vercel cache seems pretty
|
|
* quick to eject them, though...)
|
|
* - id: Instead of `layerUrls`, you can instead provide an outfit ID, which
|
|
* will load the outfit data and render it directly. By default, this
|
|
* will return a 10-minute cache header, to keep individual users from
|
|
* re-loading the image from scratch too often, while still keeping it
|
|
* relatively fresh. (If you provide `updatedAt` too, we cache it for
|
|
* longer!)
|
|
* - updatedAt: If you provide an `id`, you may also provide `updatedAt`:
|
|
* the UNIX timestamp for when the outfit was last updated. This
|
|
* has no effect on image output, but it enables us to return a
|
|
* long-term cache header, so the client and our CDN cache can
|
|
* cache the requested URL forever. (NOTE: The Vercel cache
|
|
* seems pretty quick to eject them, though...)
|
|
*/
|
|
const beeline = require("honeycomb-beeline")({
|
|
writeKey: process.env["HONEYCOMB_WRITE_KEY"],
|
|
dataset:
|
|
process.env["NODE_ENV"] === "production"
|
|
? "Dress to Impress (2020)"
|
|
: "Dress to Impress (2020, dev)",
|
|
serviceName: "impress-2020-gql-server",
|
|
sampleRate: 10,
|
|
});
|
|
|
|
import gql from "graphql-tag";
|
|
import { ApolloServer } from "apollo-server";
|
|
import { createTestClient } from "apollo-server-testing";
|
|
|
|
import connectToDb from "../../src/server/db";
|
|
import { config as graphqlConfig } from "../../src/server";
|
|
import { renderOutfitImage } from "../../src/server/outfit-images";
|
|
import getVisibleLayers, {
|
|
petAppearanceFragmentForGetVisibleLayers,
|
|
itemAppearanceFragmentForGetVisibleLayers,
|
|
} from "../../src/shared/getVisibleLayers";
|
|
|
|
// We're overly cautious about what image URLs we're willing to download and
|
|
// layer together for our output! We'll only accept `layerUrls` that match one
|
|
// of the following patterns:
|
|
const VALID_LAYER_URLS = [
|
|
// Some layers are converted from SWF to PNG by Classic DTI, living on S3.
|
|
/^https:\/\/(impress-asset-images\.openneo\.net|impress-asset-images\.s3\.amazonaws\.com)\/(biology|object)\/[0-9]{3}\/[0-9]{3}\/[0-9]{3}\/[0-9]+\/(150x150|300x300|600x600)\.png(\?[a-zA-Z0-9_-]+)?$/,
|
|
|
|
// Some layers are converted to PNG or SVG by Neopets themselves, extracted
|
|
// from the manifest file.
|
|
// TODO: I don't think we serve the `http://` variant of this layer URL
|
|
// anymore, we could disallow that someday, but I'm keeping it for
|
|
// compatibility with any potential old caches for now!
|
|
/^https?:\/\/images\.neopets\.com\/cp\/(bio|object|items)\/data\/[0-9]{3}\/[0-9]{3}\/[0-9]{3}\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\.(svg|png)(\?.*)?$/,
|
|
|
|
// Some layers are converted from HTML5 movie to PNG, by our new system.
|
|
// NOTE: We don't validate the layer's libraryUrl, because we're expecting
|
|
// the assetImage endpoint to have its own validation!
|
|
/^https:\/\/impress-2020\.openneo\.net\/api\/assetImage\?libraryUrl=[^&]+(&size=(150|300|600))?$/,
|
|
];
|
|
|
|
async function handle(req, res) {
|
|
const size = parseInt(req.query.size);
|
|
if (size !== 150 && size !== 300 && size !== 600) {
|
|
return reject(res, `Size must be 150, 300, or 600`);
|
|
}
|
|
|
|
let layerUrls;
|
|
if (req.query.layerUrls) {
|
|
layerUrls = req.query.layerUrls.split(",");
|
|
} else if (req.query.id && req.query.updatedAt) {
|
|
const outfitId = req.query.id;
|
|
try {
|
|
layerUrls = await loadLayerUrlsForSavedOutfit(outfitId, size);
|
|
} catch (e) {
|
|
console.error(e);
|
|
return reject(
|
|
res,
|
|
`Error loading data for outfit ${outfitId}: ${e.message}`,
|
|
500
|
|
);
|
|
}
|
|
} else if (req.query.id) {
|
|
// If there's an outfit ID, but no `updatedAt`, redirect to the URL with
|
|
// `updatedAt` added. (NOTE: Our Fastly config will try to handle this
|
|
// redirect internally, instead of making the user do a round-trip! That
|
|
// way, we load the version cached at the CDN instead of regenerating it,
|
|
// if possible.)
|
|
const outfitId = req.query.id;
|
|
let updatedAt;
|
|
try {
|
|
updatedAt = await loadUpdatedAtForSavedOutfit(outfitId);
|
|
} catch (e) {
|
|
return reject(
|
|
res,
|
|
`Error loading data for outfit ${outfitId}: ${e.message}`,
|
|
500
|
|
);
|
|
}
|
|
|
|
const updatedAtTimestamp = Math.floor(updatedAt.getTime() / 1000);
|
|
const urlWithUpdatedAt =
|
|
`/outfits` +
|
|
`/${encodeURIComponent(outfitId)}` +
|
|
`/v/${encodeURIComponent(updatedAtTimestamp)}` +
|
|
`/${encodeURIComponent(req.query.size)}.png`;
|
|
|
|
// Cache this result for 10 minutes, so individual users don't wait on
|
|
// image reloads too much, but it's still always relatively fresh!
|
|
res.setHeader("Cache-Control", "public, max-age=600");
|
|
return res.redirect(urlWithUpdatedAt);
|
|
} else {
|
|
return reject(res, `Missing required parameter: layerUrls`);
|
|
}
|
|
|
|
for (const layerUrl of layerUrls) {
|
|
if (!VALID_LAYER_URLS.some((pattern) => layerUrl.match(pattern))) {
|
|
return reject(res, `Unexpected layer URL format: ${layerUrl}`);
|
|
}
|
|
}
|
|
|
|
let imageResult;
|
|
try {
|
|
imageResult = await renderOutfitImage(layerUrls, size);
|
|
} catch (e) {
|
|
console.error(e);
|
|
return reject(res, `Error rendering image: ${e.message}`);
|
|
}
|
|
|
|
const { image, status } = imageResult;
|
|
|
|
if (status === "success") {
|
|
// This image is ready, and it either used `layerUrls` or `updatedAt`, so
|
|
// it shouldn't change much, if ever. Send a long-term cache header!
|
|
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
res.status(200);
|
|
} else {
|
|
// On partial failure, we still send the image, but with a 500 status. We
|
|
// send a one-week cache header, but in such a way that the user can
|
|
// refresh the page to try again. (`private` means the CDN won't cache it,
|
|
// and we don't send `immutable`, which would save it even across reloads.)
|
|
// The 500 won't really affect the client, which will still show the image
|
|
// without feedback to the user - but it's a helpful debugging hint.
|
|
res.setHeader("Cache-Control", "private, max-age=604800");
|
|
res.status(500);
|
|
}
|
|
|
|
res.setHeader("Content-Type", "image/png");
|
|
return res.send(image);
|
|
}
|
|
|
|
// Check out this scrappy way of making a query against server code ^_^`
|
|
const graphqlClient = createTestClient(new ApolloServer(graphqlConfig));
|
|
|
|
async function loadLayerUrlsForSavedOutfit(outfitId, size) {
|
|
const { errors, data } = await graphqlClient.query({
|
|
query: gql`
|
|
query ApiOutfitImage($outfitId: ID!, $size: LayerImageSize) {
|
|
outfit(id: $outfitId) {
|
|
petAppearance {
|
|
layers {
|
|
id
|
|
imageUrl(size: $size)
|
|
}
|
|
...PetAppearanceForGetVisibleLayers
|
|
}
|
|
itemAppearances {
|
|
layers {
|
|
id
|
|
imageUrl(size: $size)
|
|
}
|
|
...ItemAppearanceForGetVisibleLayers
|
|
}
|
|
}
|
|
}
|
|
${petAppearanceFragmentForGetVisibleLayers}
|
|
${itemAppearanceFragmentForGetVisibleLayers}
|
|
`,
|
|
variables: { outfitId, size: `SIZE_${size}` },
|
|
});
|
|
|
|
if (errors && errors.length > 0) {
|
|
throw new Error(
|
|
`GraphQL Error: ${errors.map((e) => e.message).join(", ")}`
|
|
);
|
|
}
|
|
|
|
if (!data.outfit) {
|
|
throw new Error(`outfit ${outfitId} not found`);
|
|
}
|
|
|
|
const { petAppearance, itemAppearances } = data.outfit;
|
|
const visibleLayers = getVisibleLayers(petAppearance, itemAppearances);
|
|
|
|
for (const layer of visibleLayers) {
|
|
if (!layer.imageUrl) {
|
|
throw new Error(`layer ${layer.id} has no imageUrl for size ${size}`);
|
|
}
|
|
}
|
|
|
|
return visibleLayers
|
|
.sort((a, b) => a.depth - b.depth)
|
|
.map((layer) => layer.imageUrl);
|
|
}
|
|
|
|
async function loadUpdatedAtForSavedOutfit(outfitId) {
|
|
const db = await connectToDb();
|
|
const [rows] = await db.query(`SELECT updated_at FROM outfits WHERE id = ?`, [
|
|
outfitId,
|
|
]);
|
|
const row = rows[0];
|
|
if (!row) {
|
|
throw new Error(`outfit ${outfitId} not found`);
|
|
}
|
|
return row.updated_at;
|
|
}
|
|
|
|
function reject(res, message, status = 400) {
|
|
res.setHeader("Content-Type", "text/plain; charset=utf8");
|
|
return res.status(status).send(message);
|
|
}
|
|
|
|
async function handleWithBeeline(req, res) {
|
|
beeline.withTrace(
|
|
{ name: "api/outfitImage", operation_name: "api/outfitImage" },
|
|
() => handle(req, res)
|
|
);
|
|
}
|
|
|
|
export default handleWithBeeline;
|