Compare commits
2 Commits
94304a488c
...
891de77a10
| Author | SHA1 | Date | |
|---|---|---|---|
| 891de77a10 | |||
| 2cbaf45340 |
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
9
proxy.ts
9
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(
|
||||
|
||||
19
renderer/map-view.mjs
Normal file
19
renderer/map-view.mjs
Normal 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)),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
6
shared/legacy-recovery.ts
Normal file
6
shared/legacy-recovery.ts
Normal 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;
|
||||
}
|
||||
14
tests/legacy-recovery.test.ts
Normal file
14
tests/legacy-recovery.test.ts
Normal 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
18
tests/map-view.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user