Compare commits

...

2 Commits

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 code = request.nextUrl.searchParams.get("code");
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 next = authDestination(request.nextUrl.searchParams.get("next"));
if (
code ||
legacyToken ||
(tokenHash &&
(type === "email" || type === "recovery" || type === "magiclink"))
) {
const client = await authClient();
const { data, error } = code
let { data, error } = code
? await client.auth.exchangeCodeForSession(code)
: legacyToken
? await client.auth.exchangeCodeForSession(legacyToken)
: await client.auth.verifyOtp({
token_hash: tokenHash!,
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) {
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));
if (destination === "/auth/reset-password") {
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.
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).

View File

@@ -1,15 +1,18 @@
# Surface maps
Plugin exports now produce a flat, north-up map rather than a textured aerial
render. The map uses up to 128 cells along its longest side, chooses the dominant
surface material in each cell, shades slopes and water depth, and retains the
original world bounds. Transparent glass and small vegetation do not hide the
ground. Unknown blocks use stone gray; material colors are approximations.
Plugin exports produce a north-up, textured aerial view of the same Minecraft
terrain used for panoramas. An orthographic camera looks straight down from
Y=320. An angled sun adds shadows that show mountain slopes and structures;
panorama lighting is unchanged. Vanilla block models, textures, biome tints,
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
and nearest-neighbor scaling, with a subtle grid. Fabric maps retain their
client-captured colors and are also compressed losslessly. The web preview,
player guessing map and results share a parchment frame.
Uncaptured terrain appears as parchment. The saved WebP uses high-quality
compression. Fabric maps retain their client-captured colors and are compressed
losslessly. The web preview, player guessing map and results share a parchment
frame.
New plugin uploads automatically use the new renderer. Existing processed images
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
```
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 { NextRequest, NextResponse } from "next/server";
import { legacyRecoveryToken } from "./shared/legacy-recovery";
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 });
if (!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) return response;
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 { assetsRoot, prepareAssets } from "../scripts/assets.mjs";
import { assertTerrainImage } from "./image-validation.mjs";
import { renderSurfaceMap } from "./surface-map.mjs";
/** @param {import('../shared/protocol').TerrainSnapshot} snapshot
* @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints
* @param {string} output
@@ -203,7 +202,15 @@ export async function renderSnapshot(
}
}
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({
percent: 90,
message: "Rendered all panoramas and the surface map",

View File

@@ -1,6 +1,7 @@
import * as THREE from "/three.js";
import { buildTerrain } from "./terrain-mesh.js";
import { firstTextureFrame } from "./block-colors.mjs";
import { mapDimensions } from "./map-view.mjs";
// This renderer consumes snapshots, never connects to a Minecraft server.
const renderer = new THREE.WebGLRenderer({
antialias: true,
@@ -21,10 +22,12 @@ window.rendererInfo = {
: "unavailable",
};
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);
sun.position.set(-40, 90, -30);
scene.add(sun);
scene.add(sun.target);
const loader = new THREE.TextureLoader(),
jsonCache = new Map(),
textureCache = new Map(),
@@ -142,7 +145,17 @@ function selectModels(state, props) {
.filter(Boolean)
.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 || [])
@@ -282,9 +295,10 @@ window.renderFace = (p, face, size) => {
renderScene(camera);
return renderer.domElement.toDataURL("image/webp", 0.85);
};
window.renderMap = (size) => {
window.renderMap = () => {
const b = window.terrainBounds;
renderer.setSize(size, size);
const { width, height } = mapDimensions(b);
renderer.setSize(width, height);
const camera = new THREE.OrthographicCamera(
-(b.maxX - b.minX) / 2,
(b.maxX - b.minX) / 2,
@@ -293,10 +307,60 @@ window.renderMap = (size) => {
0.1,
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.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);
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;

View File

@@ -1,7 +1,7 @@
import { readFile, mkdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { readFile, mkdir, mkdtemp, copyFile, rm } from "node:fs/promises";
import { dirname, resolve, sep } from "node:path";
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);
if (!input || !output)
@@ -13,8 +13,17 @@ const terrain = JSON.parse(
maxOutputLength: 256 * 1024 * 1024,
}).toString(),
);
await mkdir(dirname(resolve(output)), { recursive: true });
const report = await renderSurfaceMap(terrain, output);
console.log(
`Rendered ${report.width} × ${report.height} map cells from ${report.columns.toLocaleString()} surface columns: ${output}`,
);
const parent = dirname(resolve(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(
`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,
x > 12 && z < -12
? "minecraft:water[level=0]"
: x < -12
? "minecraft:grass_block"
: "minecraft:grass_block[snowy=false]",
]);
}
@@ -91,6 +93,32 @@ assert.equal(
false,
);
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 face of ["px", "nx", "py", "ny", "pz", "nz"]) {
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/,
);
});