diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index e0cced0..0b264e6 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -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) - : await client.auth.verifyOtp({ - token_hash: tokenHash!, - type: type as "email" | "recovery" | "magiclink", - }); + : 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(); diff --git a/docs/account-settings.md b/docs/account-settings.md index 87cd8d5..782d027 100644 --- a/docs/account-settings.md +++ b/docs/account-settings.md @@ -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). diff --git a/docs/surface-maps.md b/docs/surface-maps.md index 829f05a..ccf3a76 100644 --- a/docs/surface-maps.md +++ b/docs/surface-maps.md @@ -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. diff --git a/proxy.ts b/proxy.ts index 557a2c3..105da5f 100644 --- a/proxy.ts +++ b/proxy.ts @@ -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( diff --git a/renderer/map-view.mjs b/renderer/map-view.mjs new file mode 100644 index 0000000..9b25d2c --- /dev/null +++ b/renderer/map-view.mjs @@ -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)), + }; +} diff --git a/renderer/render.mjs b/renderer/render.mjs index 346c6c1..9d7f51c 100644 --- a/renderer/render.mjs +++ b/renderer/render.mjs @@ -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", diff --git a/renderer/scene.js b/renderer/scene.js index 2a82d55..a2d3b3a 100644 --- a/renderer/scene.js +++ b/renderer/scene.js @@ -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); - renderScene(camera, true); - return renderer.domElement.toDataURL("image/webp", 0.9); + 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 { + 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; diff --git a/scripts/render-map.mjs b/scripts/render-map.mjs index 6124d1d..4832586 100644 --- a/scripts/render-map.mjs +++ b/scripts/render-map.mjs @@ -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 }); +} diff --git a/scripts/test-renderer.mjs b/scripts/test-renderer.mjs index 49847c0..0f81113 100644 --- a/scripts/test-renderer.mjs +++ b/scripts/test-renderer.mjs @@ -22,7 +22,9 @@ for (let x = -24; x < 24; x++) { z, x > 12 && z < -12 ? "minecraft:water[level=0]" - : "minecraft:grass_block[snowy=false]", + : 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( diff --git a/shared/legacy-recovery.ts b/shared/legacy-recovery.ts new file mode 100644 index 0000000..3831d12 --- /dev/null +++ b/shared/legacy-recovery.ts @@ -0,0 +1,6 @@ +// A previously issued recovery email can land at /code= 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; +} diff --git a/tests/legacy-recovery.test.ts b/tests/legacy-recovery.test.ts new file mode 100644 index 0000000..9da30cb --- /dev/null +++ b/tests/legacy-recovery.test.ts @@ -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); +}); diff --git a/tests/map-view.test.ts b/tests/map-view.test.ts new file mode 100644 index 0000000..f76687c --- /dev/null +++ b/tests/map-view.test.ts @@ -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/, + ); +});