Matchu
8dab442929
Things seemed to mostly work at first glance! I haven't tested outfitPageSSR because we'll need to redo it though, and also the outfit image routes aren't working anymore (vercel.json isn't how next.js works)
239 lines
8.5 KiB
JavaScript
239 lines
8.5 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",
|
|
disableInstrumentationOnLoad: true,
|
|
sampleRate: 10,
|
|
});
|
|
|
|
import fetch from "node-fetch";
|
|
import gql from "graphql-tag";
|
|
import { print as graphqlPrint } from "graphql/language/printer";
|
|
|
|
import connectToDb from "../../src/server/db";
|
|
import { renderOutfitImage } from "../../src/server/outfit-images";
|
|
import getVisibleLayers, {
|
|
petAppearanceFragmentForGetVisibleLayers,
|
|
itemAppearanceFragmentForGetVisibleLayers,
|
|
} from "../../src/shared/getVisibleLayers";
|
|
|
|
const VALID_LAYER_URLS = [
|
|
/^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]+\/(150|300|600)x(150|300|600)\.png(\?[a-zA-Z0-9_-]+)?$/,
|
|
/^http:\/\/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)(\?.*)?$/,
|
|
// NOTE: We don't validate the layer's libraryUrl, because assetImage performs validation better than we could!
|
|
/^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);
|
|
}
|
|
|
|
const GRAPHQL_ENDPOINT = process.env.VERCEL_URL
|
|
? `https://${process.env.VERCEL_URL}/api/graphql`
|
|
: process.env.NODE_ENV === "development"
|
|
? "http://localhost:3000/api/graphql"
|
|
: "https://impress-2020.openneo.net/api/graphql";
|
|
|
|
// NOTE: Unlike in-app views, we only load PNGs here. We expect this to
|
|
// generally perform better, and be pretty reliable now that TNT is
|
|
// generating canonical PNGs for every layer!
|
|
const GRAPHQL_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}
|
|
`;
|
|
const GRAPHQL_QUERY_STRING = graphqlPrint(GRAPHQL_QUERY);
|
|
|
|
async function loadLayerUrlsForSavedOutfit(outfitId, size) {
|
|
const { errors, data } = await fetch(GRAPHQL_ENDPOINT, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
query: GRAPHQL_QUERY_STRING,
|
|
variables: { outfitId, size: `SIZE_${size}` },
|
|
}),
|
|
}).then((res) => res.json());
|
|
|
|
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");
|
|
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;
|