Render textured aerial maps and support legacy recovery links

This commit is contained in:
2026-10-05 18:11:12 +05:00
parent dcc3779262
commit 2cbaf45340
12 changed files with 226 additions and 35 deletions

View File

@@ -8,23 +8,35 @@ export async function GET(request: NextRequest) {
const origin = requestOrigin(request, process.env.MINEGEO_URL); const origin = requestOrigin(request, process.env.MINEGEO_URL);
const code = request.nextUrl.searchParams.get("code"); const code = request.nextUrl.searchParams.get("code");
const tokenHash = request.nextUrl.searchParams.get("token_hash"); const tokenHash = request.nextUrl.searchParams.get("token_hash");
const legacyToken = request.nextUrl.searchParams.get("legacy_recovery_token");
const type = request.nextUrl.searchParams.get("type"); const type = request.nextUrl.searchParams.get("type");
const next = authDestination(request.nextUrl.searchParams.get("next")); const next = authDestination(request.nextUrl.searchParams.get("next"));
if ( if (
code || code ||
legacyToken ||
(tokenHash && (tokenHash &&
(type === "email" || type === "recovery" || type === "magiclink")) (type === "email" || type === "recovery" || type === "magiclink"))
) { ) {
const client = await authClient(); const client = await authClient();
const { data, error } = code let { data, error } = code
? await client.auth.exchangeCodeForSession(code) ? await client.auth.exchangeCodeForSession(code)
: legacyToken
? await client.auth.exchangeCodeForSession(legacyToken)
: await client.auth.verifyOtp({ : await client.auth.verifyOtp({
token_hash: tokenHash!, token_hash: tokenHash!,
type: type as "email" | "recovery" | "magiclink", type: type as "email" | "recovery" | "magiclink",
}); });
// Older email templates could place a recovery token hash in /code=...
// instead of a PKCE code. Only Supabase can decide whether it is valid.
if (legacyToken && error)
({ data, error } = await client.auth.verifyOtp({
token_hash: legacyToken,
type: "recovery",
}));
if (!error && data.user) { if (!error && data.user) {
await accountProfile(data.user); await accountProfile(data.user);
const destination = type === "recovery" ? "/auth/reset-password" : next; const destination =
type === "recovery" || legacyToken ? "/auth/reset-password" : next;
const response = NextResponse.redirect(new URL(destination, origin)); const response = NextResponse.redirect(new URL(destination, origin));
if (destination === "/auth/reset-password") { if (destination === "/auth/reset-password") {
const { data: claims } = await client.auth.getClaims(); const { data: claims } = await client.auth.getClaims();

View File

@@ -12,7 +12,7 @@ Recovery uses Supabase's verified code/token exchange and a ten-minute, HttpOnly
No database migration is needed for these account changes: `profiles.display_name` already exists. Deploy the updated website, and configure `MINEGEO_URL` with its public HTTPS origin. No database migration is needed for these account changes: `profiles.display_name` already exists. Deploy the updated website, and configure `MINEGEO_URL` with its public HTTPS origin.
In Supabase Auth, set the Site URL to that origin and allow its `/auth/callback` URL (plus any development callback URLs you actually use). Keep email confirmation enabled and configure working email delivery. PKCE email redirects use `/auth/callback`; token-hash templates can use `/auth/confirm?token_hash={{ .TokenHash }}&type=email`, or `type=recovery` for password resets. Custom confirmation templates should preserve the appropriate destination when returning to a pairing flow. In Supabase Auth, set the Site URL to that origin and allow its `/auth/callback` URL (plus any development callback URLs you actually use). Keep email confirmation enabled and configure working email delivery. The recovery email template should link to `{{ .ConfirmationURL }}` so Supabase honors the `redirectTo` URL sent by MineGeo. A custom token-hash link may use `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery`. Do not append `/code=...` to the Site URL: it skips the callback route. PKCE email redirects use `/auth/callback`; custom confirmation templates should preserve the appropriate destination when returning to a pairing flow.
Set the provider's minimum password length to 12 as well, since direct Auth requests do not pass through application validation. Consider enabling secure password changes and leaked-password protection where your plan supports it. Provider hardening and SMTP settings are configured in Supabase; this change does not alter them automatically. See [password security](https://supabase.com/docs/guides/auth/password-security) and [redirect URL configuration](https://supabase.com/docs/guides/auth/redirect-urls). Set the provider's minimum password length to 12 as well, since direct Auth requests do not pass through application validation. Consider enabling secure password changes and leaked-password protection where your plan supports it. Provider hardening and SMTP settings are configured in Supabase; this change does not alter them automatically. See [password security](https://supabase.com/docs/guides/auth/password-security) and [redirect URL configuration](https://supabase.com/docs/guides/auth/redirect-urls).

View File

@@ -1,15 +1,18 @@
# Surface maps # Surface maps
Plugin exports now produce a flat, north-up map rather than a textured aerial Plugin exports produce a north-up, textured aerial view of the same Minecraft
render. The map uses up to 128 cells along its longest side, chooses the dominant terrain used for panoramas. An orthographic camera looks straight down from
surface material in each cell, shades slopes and water depth, and retains the Y=320. An angled sun adds shadows that show mountain slopes and structures;
original world bounds. Transparent glass and small vegetation do not hide the panorama lighting is unchanged. Vanilla block models, textures, biome tints,
ground. Unknown blocks use stone gray; material colors are approximations. buildings, trees and water appear in the map. The image keeps one world block
square, including rectangular exports, so the guessing pin still matches X/Z
coordinates. Exports up to 256 blocks across render at eight pixels per block;
larger exports are capped at 2048 pixels along their longest side.
Uncaptured terrain appears as parchment. The saved WebP uses lossless compression Uncaptured terrain appears as parchment. The saved WebP uses high-quality
and nearest-neighbor scaling, with a subtle grid. Fabric maps retain their compression. Fabric maps retain their client-captured colors and are compressed
client-captured colors and are also compressed losslessly. The web preview, losslessly. The web preview, player guessing map and results share a parchment
player guessing map and results share a parchment frame. frame.
New plugin uploads automatically use the new renderer. Existing processed images New plugin uploads automatically use the new renderer. Existing processed images
are not regenerated merely by restarting the web server. For an unpublished are not regenerated merely by restarting the web server. For an unpublished
@@ -24,4 +27,6 @@ panoramas or changing stored data:
npm run render:map -- path/to/terrain.json.gz path/to/map.webp npm run render:map -- path/to/terrain.json.gz path/to/map.webp
``` ```
The parchment is a local SVG UI asset; no remote textures are required. The parchment frame is a local SVG UI asset. Textured plugin maps use the
verified vanilla Minecraft asset cache prepared by `npm run setup` or by the
worker on demand.

View File

@@ -1,6 +1,15 @@
import { createServerClient } from "@supabase/ssr"; import { createServerClient } from "@supabase/ssr";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { legacyRecoveryToken } from "./shared/legacy-recovery";
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
const recoveryToken = legacyRecoveryToken(request.nextUrl.pathname);
if (recoveryToken) {
const destination = request.nextUrl.clone();
destination.pathname = "/auth/callback";
destination.search = "";
destination.searchParams.set("legacy_recovery_token", recoveryToken);
return NextResponse.redirect(destination);
}
let response = NextResponse.next({ request }); let response = NextResponse.next({ request });
if (!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) return response; if (!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) return response;
const client = createServerClient( const client = createServerClient(

19
renderer/map-view.mjs Normal file
View File

@@ -0,0 +1,19 @@
// Keep one world block square in the image, including rectangular exports.
// Eight pixels per block preserves the vanilla textures in typical 128-block
// exports; large worlds are capped to a manageable image size.
export function mapDimensions(bounds) {
const spanX = bounds.maxX - bounds.minX;
const spanZ = bounds.maxZ - bounds.minZ;
if (
!Number.isInteger(spanX) ||
!Number.isInteger(spanZ) ||
spanX < 1 ||
spanZ < 1
)
throw new Error("Surface map bounds must have a positive integer size");
const scale = Math.min(8, 2048 / Math.max(spanX, spanZ));
return {
width: Math.max(1, Math.round(spanX * scale)),
height: Math.max(1, Math.round(spanZ * scale)),
};
}

View File

@@ -5,7 +5,6 @@ import { resolve, sep } from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import { assetsRoot, prepareAssets } from "../scripts/assets.mjs"; import { assetsRoot, prepareAssets } from "../scripts/assets.mjs";
import { assertTerrainImage } from "./image-validation.mjs"; import { assertTerrainImage } from "./image-validation.mjs";
import { renderSurfaceMap } from "./surface-map.mjs";
/** @param {import('../shared/protocol').TerrainSnapshot} snapshot /** @param {import('../shared/protocol').TerrainSnapshot} snapshot
* @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints * @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints
* @param {string} output * @param {string} output
@@ -203,7 +202,15 @@ export async function renderSnapshot(
} }
} }
await report({ percent: 85, message: "Rendering the surface map" }); await report({ percent: 85, message: "Rendering the surface map" });
stats.map = await renderSurfaceMap(snapshot, `${output}/map.webp`); const map = await page.evaluate(() => window.renderMap());
const mapBytes = Buffer.from(map.data.split(",")[1], "base64");
if (map.data.startsWith("data:image/webp;"))
await writeFile(`${output}/map.webp`, mapBytes);
else
await sharp(mapBytes)
.webp({ quality: 92 })
.toFile(`${output}/map.webp`);
stats.map = { width: map.width, height: map.height, cameraY: 320 };
await report({ await report({
percent: 90, percent: 90,
message: "Rendered all panoramas and the surface map", message: "Rendered all panoramas and the surface map",

View File

@@ -1,6 +1,7 @@
import * as THREE from "/three.js"; import * as THREE from "/three.js";
import { buildTerrain } from "./terrain-mesh.js"; import { buildTerrain } from "./terrain-mesh.js";
import { firstTextureFrame } from "./block-colors.mjs"; import { firstTextureFrame } from "./block-colors.mjs";
import { mapDimensions } from "./map-view.mjs";
// This renderer consumes snapshots, never connects to a Minecraft server. // This renderer consumes snapshots, never connects to a Minecraft server.
const renderer = new THREE.WebGLRenderer({ const renderer = new THREE.WebGLRenderer({
antialias: true, antialias: true,
@@ -21,10 +22,12 @@ window.rendererInfo = {
: "unavailable", : "unavailable",
}; };
scene.background = new THREE.Color("#95c4eb"); scene.background = new THREE.Color("#95c4eb");
scene.add(new THREE.HemisphereLight("#e9f3ff", "#73704e", 2.0)); const ambient = new THREE.HemisphereLight("#e9f3ff", "#73704e", 2.0);
scene.add(ambient);
const sun = new THREE.DirectionalLight("#fff4d6", 2.2); const sun = new THREE.DirectionalLight("#fff4d6", 2.2);
sun.position.set(-40, 90, -30); sun.position.set(-40, 90, -30);
scene.add(sun); scene.add(sun);
scene.add(sun.target);
const loader = new THREE.TextureLoader(), const loader = new THREE.TextureLoader(),
jsonCache = new Map(), jsonCache = new Map(),
textureCache = new Map(), textureCache = new Map(),
@@ -142,7 +145,17 @@ function selectModels(state, props) {
.filter(Boolean) .filter(Boolean)
.map((p) => p.split("=")), .map((p) => p.split("=")),
); );
if (match(c, props)) return [Array.isArray(v) ? v[0] : v]; // Some exports omit default properties (for example snowy=false on a
// grass block). Match every property we do have, so the block remains
// visible instead of silently disappearing from the terrain and map.
if (
Object.entries(c).every(
([name, value]) =>
props[name] === undefined ||
String(value).split("|").includes(props[name]),
)
)
return [Array.isArray(v) ? v[0] : v];
} }
} }
return (state.multipart || []) return (state.multipart || [])
@@ -282,9 +295,10 @@ window.renderFace = (p, face, size) => {
renderScene(camera); renderScene(camera);
return renderer.domElement.toDataURL("image/webp", 0.85); return renderer.domElement.toDataURL("image/webp", 0.85);
}; };
window.renderMap = (size) => { window.renderMap = () => {
const b = window.terrainBounds; const b = window.terrainBounds;
renderer.setSize(size, size); const { width, height } = mapDimensions(b);
renderer.setSize(width, height);
const camera = new THREE.OrthographicCamera( const camera = new THREE.OrthographicCamera(
-(b.maxX - b.minX) / 2, -(b.maxX - b.minX) / 2,
(b.maxX - b.minX) / 2, (b.maxX - b.minX) / 2,
@@ -293,10 +307,60 @@ window.renderMap = (size) => {
0.1, 0.1,
1024, 1024,
); );
camera.position.set((b.minX + b.maxX) / 2, 512, (b.minZ + b.maxZ) / 2); camera.position.set((b.minX + b.maxX) / 2, 320, (b.minZ + b.maxZ) / 2);
camera.up.set(0, 0, -1); camera.up.set(0, 0, -1);
camera.lookAt(camera.position.x, 0, camera.position.z); camera.lookAt(camera.position.x, 0, camera.position.z);
const background = scene.background;
const previousSunPosition = sun.position.clone();
const previousSunTarget = sun.target.position.clone();
const previousAmbient = ambient.intensity;
const previousSunIntensity = sun.intensity;
const centerX = camera.position.x;
const centerZ = camera.position.z;
const radius = Math.hypot(b.maxX - b.minX, b.maxZ - b.minZ) / 2 + 256;
// The panorama renderer keeps its broad, shadowless lighting. For the map,
// an angled sun makes ridges, cliffs and buildings cast readable shadows.
ambient.intensity = 1.25;
sun.intensity = 2.2;
sun.position.set(centerX - 220, 300, centerZ - 180);
sun.target.position.set(centerX, 64, centerZ);
sun.castShadow = true;
sun.shadow.mapSize.set(4096, 4096);
sun.shadow.camera.left = -radius;
sun.shadow.camera.right = radius;
sun.shadow.camera.top = radius;
sun.shadow.camera.bottom = -radius;
sun.shadow.camera.near = 1;
sun.shadow.camera.far = radius * 4;
sun.shadow.camera.updateProjectionMatrix();
sun.shadow.bias = -0.0004;
sun.shadow.normalBias = 0.03;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
for (const mesh of window.terrainMeshes) {
mesh.castShadow = true;
mesh.receiveShadow = true;
}
scene.background = new THREE.Color("#ddc79f");
try {
renderScene(camera, true); renderScene(camera, true);
return renderer.domElement.toDataURL("image/webp", 0.9); return {
data: renderer.domElement.toDataURL("image/webp", 0.92),
width,
height,
};
} finally {
renderer.shadowMap.enabled = false;
sun.castShadow = false;
for (const mesh of window.terrainMeshes) {
mesh.castShadow = false;
mesh.receiveShadow = false;
}
sun.position.copy(previousSunPosition);
sun.target.position.copy(previousSunTarget);
ambient.intensity = previousAmbient;
sun.intensity = previousSunIntensity;
scene.background = background;
}
}; };
window.rendererReady = true; window.rendererReady = true;

View File

@@ -1,7 +1,7 @@
import { readFile, mkdir } from "node:fs/promises"; import { readFile, mkdir, mkdtemp, copyFile, rm } from "node:fs/promises";
import { dirname, resolve } from "node:path"; import { dirname, resolve, sep } from "node:path";
import { gunzipSync } from "node:zlib"; import { gunzipSync } from "node:zlib";
import { renderSurfaceMap } from "../renderer/surface-map.mjs"; import { renderSnapshot } from "../renderer/render.mjs";
const [input, output] = process.argv.slice(2); const [input, output] = process.argv.slice(2);
if (!input || !output) if (!input || !output)
@@ -13,8 +13,17 @@ const terrain = JSON.parse(
maxOutputLength: 256 * 1024 * 1024, maxOutputLength: 256 * 1024 * 1024,
}).toString(), }).toString(),
); );
await mkdir(dirname(resolve(output)), { recursive: true }); const parent = dirname(resolve(output));
const report = await renderSurfaceMap(terrain, output); await mkdir(parent, { recursive: true });
const temporary = await mkdtemp(resolve(parent, ".minegeo-map-"));
try {
const report = await renderSnapshot(terrain, [], temporary);
await copyFile(resolve(temporary, "map.webp"), resolve(output));
console.log( console.log(
`Rendered ${report.width} × ${report.height} map cells from ${report.columns.toLocaleString()} surface columns: ${output}`, `Rendered ${report.map.width} × ${report.map.height} aerial map from Y=${report.map.cameraY}: ${output}`,
); );
} finally {
if (!temporary.startsWith(parent + sep))
throw new Error("Refusing to remove a map directory outside its parent");
await rm(temporary, { recursive: true, force: true });
}

View File

@@ -22,6 +22,8 @@ for (let x = -24; x < 24; x++) {
z, z,
x > 12 && z < -12 x > 12 && z < -12
? "minecraft:water[level=0]" ? "minecraft:water[level=0]"
: x < -12
? "minecraft:grass_block"
: "minecraft:grass_block[snowy=false]", : "minecraft:grass_block[snowy=false]",
]); ]);
} }
@@ -91,6 +93,32 @@ assert.equal(
false, false,
); );
await assertTerrainImage(await readFile(`${output}/map.webp`), "Surface map"); await assertTerrainImage(await readFile(`${output}/map.webp`), "Surface map");
const mapMeta = await sharp(`${output}/map.webp`).metadata();
assert.equal(mapMeta.width, report.map.width);
assert.equal(mapMeta.height, report.map.height);
assert.equal(report.map.cameraY, 320);
const { data: mapPixels, info: mapInfo } = await sharp(`${output}/map.webp`)
.raw()
.toBuffer({ resolveWithObject: true });
const westGrass = (24 * mapInfo.width + 24) * mapInfo.channels;
assert.ok(
mapPixels[westGrass + 1] > mapPixels[westGrass],
"blocks without explicit default properties must appear on the aerial map",
);
const mapPixel = (x, z) => {
const pixelX = Math.floor(((x - snapshot.bounds.minX + 0.5) / 48) * mapInfo.width);
const pixelY = Math.floor(((z - snapshot.bounds.minZ + 0.5) / 48) * mapInfo.height);
const offset = (pixelY * mapInfo.width + pixelX) * mapInfo.channels;
return mapPixels.subarray(offset, offset + 3);
};
if (!process.argv[2]) {
const shadedGrass = mapPixel(10, 9);
const sunlitGrass = mapPixel(10, -9);
assert.ok(
sunlitGrass[1] - shadedGrass[1] > 20,
"the raised building must cast a visible shadow on the aerial map",
);
}
for (const point of points) { for (const point of points) {
for (const face of ["px", "nx", "py", "ny", "pz", "nz"]) { for (const face of ["px", "nx", "py", "ny", "pz", "nz"]) {
const metadata = await sharp( const metadata = await sharp(

View File

@@ -0,0 +1,6 @@
// A previously issued recovery email can land at /code=<token> when its email
// template omitted the callback route. Bring that link through verification.
export function legacyRecoveryToken(pathname: string) {
const match = /^\/code=([A-Za-z0-9._~-]{8,2048})$/.exec(pathname);
return match?.[1] ?? null;
}

View File

@@ -0,0 +1,14 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { legacyRecoveryToken } from "../shared/legacy-recovery";
test("legacy recovery email path yields only its code segment", () => {
assert.equal(legacyRecoveryToken("/code=abc12345-6789"), "abc12345-6789");
for (const path of [
"/code=short",
"/code=abc12345/other",
"/code=abc%2F12345",
"/auth/callback",
])
assert.equal(legacyRecoveryToken(path), null);
});

18
tests/map-view.test.ts Normal file
View File

@@ -0,0 +1,18 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mapDimensions } from "../renderer/map-view.mjs";
test("aerial map pixels keep rectangular X/Z bounds square", () => {
assert.deepEqual(
mapDimensions({ minX: -32, maxX: 96, minZ: -16, maxZ: 48 }),
{ width: 1024, height: 512 },
);
assert.deepEqual(
mapDimensions({ minX: 0, maxX: 2048, minZ: 0, maxZ: 1024 }),
{ width: 2048, height: 1024 },
);
assert.throws(
() => mapDimensions({ minX: 4, maxX: 4, minZ: 0, maxZ: 16 }),
/positive integer/,
);
});