Add map import and export with durable refresh progress

This commit is contained in:
2026-10-07 19:44:49 +05:00
parent dac8650ac1
commit 1307436e65
37 changed files with 2207 additions and 85 deletions

View File

@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
import { handle } from "@/lib/api";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
async function route(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },

View File

@@ -706,10 +706,32 @@ nav a:hover,
}
.catalog-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 20px;
justify-content: space-between;
margin: 35px 0;
}
.map-import-dialog {
margin: auto;
background: var(--panel);
color: var(--text);
border: 1px solid var(--line);
border-radius: 12px;
padding: 24px;
width: min(540px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
overflow: auto;
}
.map-import-dialog::backdrop { background: rgb(0 0 0 / 65%); }
.map-import-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.map-import-heading h2 { margin: 0; }
.map-import-dialog .field input { max-width: 100%; }
.map-export { margin: 16px 0; }
.catalog-search { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; }
.catalog-search .search-field { flex: 1; min-width: 0; }
.catalog-search .search-field input { min-width: 0; }
.catalog-search > .button { flex-shrink: 0; }
.search-field {
border: 1px solid var(--line);
background: var(--panel);
@@ -1399,6 +1421,7 @@ pre {
}
.catalog-controls {
flex-direction: column;
align-items: stretch;
}
.difficulty-options {
gap: 8px;

View File

@@ -6,6 +6,8 @@ import { StartGame } from "@/components/StartGame";
import { getMap } from "@/lib/catalog";
import { ContributionInstructions } from "@/components/ContributionInstructions";
import { ReportContent } from "@/components/ReportContent";
import { user } from "@/lib/supabase";
import { ExportMap } from "@/components/MapTransfer";
export default async function ServerPage({
params,
}: {
@@ -15,6 +17,7 @@ export default async function ServerPage({
const { id } = await params;
const map = await getMap(id);
if (!map) notFound();
const account = await user();
return (
<>
<Header />
@@ -31,6 +34,7 @@ export default async function ServerPage({
{t("Created by")} {map.credit}
</p>
<ReportContent datasetId={id} />
{account?.id === map.owner_id && <ExportMap id={id} />}
<p className="muted">
{t("Map ID:")} <code>{map.id}</code>
</p>

View File

@@ -1,11 +1,12 @@
import { getI18n } from "@/lib/i18n";
import { Header, Footer, EmptyState } from "@/components/Shell";
import { Header, Footer } from "@/components/Shell";
import { catalog } from "@/lib/catalog";
import { CatalogBrowser } from "@/components/CatalogBrowser";
import { user } from "@/lib/supabase";
export const dynamic = "force-dynamic";
export default async function Servers() {
const { t } = await getI18n();
const maps = await catalog();
const [maps, account] = await Promise.all([catalog(), user()]);
return (
<>
<Header />
@@ -19,15 +20,7 @@ export default async function Servers() {
"From familiar spawn towns to places nobody’s quite sure about. Pick your next expedition.",
)}
</p>
{maps.length ? (
<CatalogBrowser maps={maps} />
) : (
<EmptyState title={t("Fresh chunks, fresh start")}>
{t(
"No maps have been approved yet. Your server could be the first.",
)}
</EmptyState>
)}
<CatalogBrowser maps={maps} signedIn={!!account} />
</main>
<Footer />
</>

View File

@@ -4,7 +4,14 @@ import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { Search } from "lucide-react";
import { MapCard, type CardMap } from "./MapCard";
export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
import { ImportMap } from "./MapTransfer";
export function CatalogBrowser({
maps,
signedIn = false,
}: {
maps: CardMap[];
signedIn?: boolean;
}) {
const { t } = useI18n();
const [query, setQuery] = useState(""),
[source, setSource] = useState("all");
@@ -18,15 +25,18 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
return (
<>
<div className="catalog-controls">
<label className="search-field">
<Search size={17} />
<input
aria-label={t("Search worlds")}
placeholder={t("Search worlds or servers…")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<div className="catalog-search">
<label className="search-field">
<Search size={17} />
<input
aria-label={t("Search worlds")}
placeholder={t("Search worlds or servers…")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<ImportMap signedIn={signedIn} />
</div>
<div className="tabs" aria-label={t("Capture source")}>
{[
["all", t("All worlds")],

View File

@@ -10,6 +10,7 @@ import { api } from "./StartGame";
import type { MapListing } from "@/lib/catalog";
import type { ProcessingJob } from "@/shared/processing-progress";
import { ProcessingProgress } from "./ProcessingProgress";
import { ImportMap } from "./MapTransfer";
type Draft = MapListing & {
upload_jobs: ProcessingJob | null;
};
@@ -86,6 +87,7 @@ export function Dashboard() {
)}
</p>
<div className="inline-actions">
<ImportMap signedIn />
<Link className="button" href="/settings">
{t("Account settings")}
</Link>

View File

@@ -4,7 +4,7 @@ import { useI18n } from "@/components/I18nProvider";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { api } from "./StartGame";
import { api, StartGame } from "./StartGame";
import type { DatasetManifest } from "@/shared/protocol";
import type { ProcessingJob } from "@/shared/processing-progress";
import { ProcessingProgress } from "./ProcessingProgress";
@@ -13,7 +13,10 @@ import { OwnerContributions } from "./OwnerContributions";
import Link from "next/link";
import { MapRefresh } from "./MapRefresh";
import { MapAppeal } from "./MapAppeal";
import { ExportMap } from "./MapTransfer";
type Draft = {
imported_from_id: string | null;
imported_owner_name: string | null;
id: string;
name: string;
description: string;
@@ -147,9 +150,39 @@ export function DraftEditor({ id }: { id: string }) {
<div className="editor-navigation">
<Link href="/dashboard">{t("← Your maps")}</Link>
<a href="#locations">{t("Choose locations")}</a>
{!draft.parent_dataset_id && <a href="#map-tools">{t("Map tools")}</a>}
{!draft.imported_from_id && !draft.parent_dataset_id && (
<a href="#map-tools">{t("Map tools")}</a>
)}
</div>
<span className="badge">{t(statusMessage(draft.state))}</span>
{!draft.imported_from_id &&
!draft.parent_dataset_id &&
draft.map &&
!draft.banned_at && (
<ExportMap
id={id}
disabled={
busy ||
refreshing ||
["uploading", "processing", "failed"].includes(draft.state)
}
/>
)}
{draft.imported_from_id && (
<section className="editor-panel">
<h2>
{t("Owned by {owner}", {
owner: draft.imported_owner_name ?? "Explorer",
})}
</h2>
<p className="muted">
{t(
"This shared map is private and playable without moderator review. Only the original owner can submit it for public review.",
)}
</p>
{draft.state === "draft" && <StartGame datasetId={id} />}
</section>
)}
{!draft.parent_dataset_id &&
(draft.report_strikes > 0 || draft.banned_at) && (
<MapAppeal
@@ -333,7 +366,12 @@ export function DraftEditor({ id }: { id: string }) {
</button>
<button
className="button primary"
disabled={busy || refreshing || selected.length < minimum}
disabled={
busy ||
refreshing ||
selected.length < minimum ||
!!draft.imported_from_id
}
onClick={() => save(true)}
>
{draft.parent_dataset_id
@@ -342,21 +380,23 @@ export function DraftEditor({ id }: { id: string }) {
</button>
</div>
)}
{!draft.parent_dataset_id && (
{!draft.imported_from_id && !draft.parent_dataset_id && (
<div id="map-tools">
<MapRefresh
id={id}
refreshKey={version}
job={draft.upload_jobs}
disabled={busy || !editable || refreshing}
onQueued={() => {
setMessage(
"Map refresh queued. Keep this page open to follow progress.",
"Map refresh queued. Progress and the result are saved; you can reopen this page later.",
);
setVersion((v) => v + 1);
}}
/>
</div>
)}
{!draft.parent_dataset_id && (
{!draft.imported_from_id && !draft.parent_dataset_id && (
<OwnerContributions
id={id}
allowed={draft.allow_contributions}

View File

@@ -1,30 +1,46 @@
"use client";
import { useI18n } from "@/components/I18nProvider";
import { useEffect, useState } from "react";
import { api } from "./StartGame";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/client-api";
import { refreshCapture, type MapCapture } from "@/shared/map-refresh";
import type { ProcessingJob } from "@/shared/processing-progress";
export function MapRefresh({
id,
disabled,
onQueued,
refreshKey,
job,
}: {
id: string;
disabled: boolean;
onQueued: () => void;
refreshKey: number;
job: ProcessingJob | null;
}) {
const { t, formatDate } = useI18n();
const [sources, setSources] = useState<
{ id: string; name: string; created_at: string }[]
>([]);
const [source, setSource] = useState(id);
const [sources, setSources] = useState<MapCapture[]>([]);
const [source, setSource] = useState("");
const choice = useRef<{ mapId: string; sourceId: string } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
api<{ sources: typeof sources }>(`datasets/${id}/refresh-map`)
.then((r) => {
if (active) setSources(r.sources);
if (active) {
setSources(r.sources);
setSource(
refreshCapture(
r.sources,
choice.current?.mapId === id
? choice.current.sourceId
: undefined,
),
);
setError("");
}
})
.catch((e) => {
if (active) setError(e.message);
@@ -32,7 +48,7 @@ export function MapRefresh({
return () => {
active = false;
};
}, [id]);
}, [id, refreshKey, job?.id, job?.state]);
async function refresh() {
setBusy(true);
setError("");
@@ -58,7 +74,10 @@ export function MapRefresh({
{t("Terrain capture")}
<select
value={source}
onChange={(e) => setSource(e.target.value)}
onChange={(e) => {
choice.current = { mapId: id, sourceId: e.target.value };
setSource(e.target.value);
}}
disabled={disabled || busy}
>
{sources.map((s) => (
@@ -69,6 +88,11 @@ export function MapRefresh({
))}
</select>
</label>
<p className="muted">
{t(
"The newest eligible capture is selected automatically. Choose the original capture only if you want its older terrain.",
)}
</p>
<button
className="button"
disabled={disabled || busy || !sources.some((s) => s.id === source)}
@@ -81,6 +105,16 @@ export function MapRefresh({
"Panoramas stay unchanged. Refreshing uses no panorama slots, even at 24/24. Submit the finished map update for review to make it playable.",
)}
</p>
{job?.kind === "map" && job.state === "complete" && (
<p role="status" className="success-message">
{t("Last successful refresh used: {capture}.", {
capture:
job.source_dataset_id === id
? t("Original capture")
: `${sources.find((s) => s.id === job.source_dataset_id)?.name ?? t("Terrain capture")} · ${job.source_dataset_id?.slice(0, 8) ?? ""}`,
})}
</p>
)}
{error && (
<p role="alert" className="error-message">
{t(error)}

136
components/MapTransfer.tsx Normal file
View File

@@ -0,0 +1,136 @@
"use client";
import { useRef, useState } from "react";
import { Download, Upload, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useI18n } from "./I18nProvider";
export function ExportMap({
id,
disabled = false,
}: {
id: string;
disabled?: boolean;
}) {
const { t } = useI18n();
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
async function download() {
setBusy(true);
setError("");
try {
const { exportMap } = await import("@/lib/map-transfer");
await exportMap(id, (message, values) => setStatus(t(message, values)));
setStatus(t("Map ZIP downloaded. Share it with a friend."));
} catch (error) {
setStatus("");
setError(error instanceof Error ? error.message : "Could not export map");
} finally {
setBusy(false);
}
}
return (
<div className="map-export">
<button className="button" onClick={download} disabled={disabled || busy}>
<Download size={17} aria-hidden="true" />{" "}
{t(busy ? "Exporting…" : "Export ZIP")}
</button>
{status && (
<p role="status" className="muted">
{status}
</p>
)}
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
</div>
);
}
export function ImportMap({ signedIn }: { signedIn: boolean }) {
const { t } = useI18n();
const router = useRouter();
const dialog = useRef<HTMLDialogElement>(null);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
async function upload(file: File) {
setBusy(true);
setError("");
try {
const { importMap } = await import("@/lib/map-transfer");
const id = await importMap(file, (message, values) =>
setStatus(t(message, values)),
);
dialog.current?.close();
router.push(`/dashboard/${id}`);
router.refresh();
} catch (error) {
setStatus("");
setError(error instanceof Error ? error.message : "Could not import map");
} finally {
setBusy(false);
}
}
return (
<>
<button className="button" onClick={() => dialog.current?.showModal()}>
<Upload size={17} aria-hidden="true" /> {t("Import")}
</button>
<dialog
ref={dialog}
className="map-import-dialog"
aria-labelledby="map-import-title"
onCancel={(event) => {
if (busy) event.preventDefault();
}}
>
<div className="map-import-heading">
<h2 id="map-import-title">{t("Import a shared map")}</h2>
<button
className="button"
aria-label={t("Close")}
disabled={busy}
onClick={() => dialog.current?.close()}
>
<X size={18} aria-hidden="true" />
</button>
</div>
<p className="muted">
{t(
"Choose a MineGeo ZIP exported by the map owner. Play it privately without moderator review. The original owner keeps publication rights. Maximum size: 256 MB.",
)}
</p>
{signedIn ? (
<label className="field">
{t("Map ZIP file")}
<input
type="file"
accept=".zip,application/zip"
disabled={busy}
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (file) void upload(file);
}}
/>
</label>
) : (
<Link className="button primary" href="/log-in?next=/servers">
{t("Sign in to import")}
</Link>
)}
{status && <p role="status">{status}</p>}
{error && (
<p className="error-message" role="alert">
{t(error)}
</p>
)}
</dialog>
</>
);
}

View File

@@ -7,16 +7,53 @@ import {
type ProcessingJob,
} from "@/shared/processing-progress";
const completionDeadlines = new Map<string, number>();
export function ProcessingProgress({ job }: { job: ProcessingJob | null }) {
const { t } = useI18n();
const [now, setNow] = useState<number | null>(null);
const [visibleCompletion, setVisibleCompletion] = useState<string | null>(
null,
);
const complete = job?.kind === "map" && job.state === "complete";
const completionKey = complete
? `minegeo:map-refresh-notice:${job.id ?? job.updated_at}`
: null;
useEffect(() => {
setVisibleCompletion(null);
if (!completionKey) return;
let deadline = completionDeadlines.get(completionKey);
try {
const saved = Number(localStorage.getItem(completionKey));
if (Number.isFinite(saved) && saved > 0) deadline = saved;
} catch {
// Keep the same deadline during navigation if browser storage is blocked.
}
deadline ??= Date.now() + 10_000;
completionDeadlines.set(completionKey, deadline);
try {
localStorage.setItem(completionKey, String(deadline));
} catch {}
const remaining = deadline - Date.now();
if (remaining <= 0) return;
setVisibleCompletion(completionKey);
const timer = setTimeout(() => setVisibleCompletion(null), remaining);
return () => clearTimeout(timer);
}, [completionKey]);
useEffect(() => {
setNow(Date.now());
if (job?.state !== "running") return;
const timer = setInterval(() => setNow(Date.now()), 5000);
return () => clearInterval(timer);
}, []);
if (!job || !["queued", "running"].includes(job.state)) return null;
const progress = Math.max(0, Math.min(100, job.progress));
}, [job?.state]);
if (
!job ||
(complete
? visibleCompletion !== completionKey
: !["queued", "running"].includes(job.state))
)
return null;
const progress = complete ? 100 : Math.max(0, Math.min(100, job.progress));
const stopped =
now !== null &&
job.state === "running" &&
@@ -24,9 +61,11 @@ export function ProcessingProgress({ job }: { job: ProcessingJob | null }) {
return (
<div className="processing-progress" role="status" aria-live="polite">
<p>
{job.state === "queued"
? t("Waiting for a processing worker")
: `${progress}% · ${job.kind === "map" ? t("Refreshing guessing map") : t(processingStage(progress))}`}
{complete
? `100% · ${t("Guessing map refreshed successfully")}`
: job.state === "queued"
? t("Waiting for a processing worker")
: `${progress}% · ${job.kind === "map" ? t("Refreshing guessing map") : t(processingStage(progress))}`}
</p>
<progress
max={100}
@@ -34,15 +73,17 @@ export function ProcessingProgress({ job }: { job: ProcessingJob | null }) {
aria-label={t("Map processing progress")}
/>
<p className="muted">
{stopped
? t(
"The worker has stopped reporting. Check its terminal and restart it if necessary. Your uploaded export is saved.",
)
: job.state === "running"
{complete
? t("The updated guessing map is ready to preview.")
: stopped
? t(
"Worker connected. Large exports can take a while; progress measures completed work.",
"The worker has stopped reporting. Check its terminal and restart it if necessary. Your uploaded export is saved.",
)
: t("Processing will start when a worker is available.")}
: job.state === "running"
? t(
"Worker connected. Large exports can take a while; progress measures completed work.",
)
: t("Processing will start when a worker is available.")}
</p>
</div>
);

26
docs/map-sharing.md Normal file
View File

@@ -0,0 +1,26 @@
# Share a map with friends
The original owner can choose **Export ZIP** in their map editor or on the public
map page. The ZIP includes the saved map, selected panoramas, coordinates, and
signed original-owner attribution. Save edits before exporting. Raw world files,
server addresses, account IDs, tokens, and storage links are excluded.
On **Servers**, choose **Import** beside search, then select the exported ZIP.
Sign in first to keep the imported map in your dashboard. The ZIP limit is 256 MB.
Files upload directly to private Supabase Storage, then the deployed image
processor checks sizes, hashes, formats, and dimensions and creates the draft.
Neither Minecraft nor the terrain-rendering worker is required for these imports.
Imported maps are private and show **Owned by [original owner]**. The recipient
can play solo or host an invite-only multiplayer room after processing, with no
moderator approval. Submission, publication, and re-export of imported copies
are blocked. The original owner keeps the normal export and public review flow.
Private imported games do not contribute to the public leaderboard.
Apply `supabase/migrations/20261007145000_map_sharing.sql` before deploying.
Archive ownership signatures use a purpose-specific HMAC derived from the
server's Supabase secret key. Rotating that key invalidates ownership signatures
in older ZIPs; the original owner can export a new ZIP.
Verification: `npm test`, `npm run typecheck -- --incremental false`,
`node scripts/test-map-sharing-sql.mjs`, and `npm run build`.

View File

@@ -33,8 +33,9 @@ its contributor token, Minecraft connection address, and export settings.
The website accepts 1.21.11 and 26.1/26.2 capture manifests. The updated worker
selects and verifies vanilla models/textures for each export's version. Restart
the rendering worker on its existing host after updating these source files;
Vercel deploys only the website and JAR downloads, not that separate worker.
the terrain rendering worker on its existing host after updating these source
files. Vercel includes processing for the mod's captured PNGs; plugin terrain
rendering still runs on that separate worker host.
Verification commands:

View File

@@ -3,7 +3,16 @@
Run `npm run deploy` from the project directory. The script collects all web
source files and public assets, uploads them in one request, waits for the build,
and prints the deployment URL. It uses the [Vercel deployments API](https://vercel.com/docs/rest-api/deployments/create-a-new-deployment). The database credentials already configured in
Vercel remain there. The Minecraft rendering worker stays on its current host.
Vercel remain there. Mod captures are processed by the deployed Vercel API:
upload finalization starts background image processing with Next.js `after`,
and the authenticated dashboard resumes queued or expired jobs. The function
has a 300-second limit and uses the existing Supabase queue, Storage buckets,
retry count, and completion transaction. It processes one PNG at a time in
memory, so it needs no local capture directory or running Minecraft client.
Plugin terrain exports still require the standalone Minecraft rendering worker
on its current host. That renderer builds panoramas with Chromium and vanilla
Minecraft assets; it is separate from the deployed mod-image processor.
The script excludes Git metadata, local environment files, caches, generated
captures, tests, Java build sources, and development tools. It checks uploaded
@@ -33,5 +42,9 @@ additional Supabase credentials.
Multiplayer uses the Supabase migration described in [multiplayer.md](multiplayer.md).
Apply that migration to a new database before deploying the multiplayer web files.
Map ZIP sharing requires `20261007145000_map_sharing.sql`. Imported maps stay
private and can be played without moderation; publication is blocked. See
[map-sharing.md](map-sharing.md) for the archive format and verification.
The script targets the existing MineGeo project. Set `VERCEL_PROJECT_ID` and
`VERCEL_ORG_ID` to deploy a copy to another Vercel project.

View File

@@ -19,6 +19,13 @@ import { multiplayerApi } from "./multiplayer-api";
import { contributionCutoff, reportSchema } from "../shared/moderation";
import { siteSettingsSchema } from "../shared/site-settings";
import { getSiteSettings } from "./site-settings";
import { processUploadAfterResponse } from "./process-uploads";
import { needsProcessing } from "./fabric-jobs";
import { archiveMetadataSchema, importManifest } from "../shared/map-archive";
import {
archiveSignature,
verifyArchiveSignature,
} from "./map-archive-signature";
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex");
const idSchema = z.string().uuid();
@@ -259,20 +266,39 @@ export async function handle(request: NextRequest, path: string[]) {
}
if (path[0] === "uploads") {
const owner = await contributor(request);
if (method === "POST" && path.length === 1) {
if (
method === "POST" &&
(path.length === 1 || (path[1] === "import" && path.length === 2))
) {
await limit("upload:" + owner, 10, 3600);
const manifest = parseUploadManifest(
(await jsonBody(request)).manifest,
);
const body = await jsonBody(request);
const manifest =
path[1] === "import"
? importManifest(body.manifest)
: parseUploadManifest(body.manifest);
const archive =
path[1] === "import"
? archiveMetadataSchema.parse(
(({ assets: _assets, ...metadata }) => metadata)(body.manifest),
)
: null;
if (archive)
verifyArchiveSignature(archive, process.env.SUPABASE_SECRET_KEY!);
if (
manifest.assets.reduce((n, a) => n + a.bytes, 0) >
Number(process.env.MINEGEO_MAX_UPLOAD_BYTES || 268435456)
)
throw new Error("Dataset exceeds upload limit");
const datasetId = required(
await client.rpc("create_upload", {
await client.rpc(archive ? "create_map_import" : "create_upload", {
p_owner: owner,
p_manifest: manifest,
...(archive
? {
p_original_map: archive.originalMapId,
p_original_owner: archive.originalOwner,
}
: {}),
}),
);
return NextResponse.json({ datasetId });
@@ -333,6 +359,7 @@ export async function handle(request: NextRequest, path: string[]) {
p_owner: owner,
}),
);
if (state === "processing") processUploadAfterResponse(d.id);
return NextResponse.json({ datasetId: d.id, state });
}
}
@@ -343,7 +370,7 @@ export async function handle(request: NextRequest, path: string[]) {
await client
.from("datasets")
.select(
"*, upload_jobs!dataset_id(state,progress,error,updated_at,lease_until,kind)",
"*, upload_jobs!dataset_id(id,state,progress,error,updated_at,lease_until,kind,source_dataset_id)",
)
.eq("owner_id", owner)
.or(
@@ -353,15 +380,87 @@ export async function handle(request: NextRequest, path: string[]) {
);
return NextResponse.json({
maps: await Promise.all(
maps.map(async (m) => ({
...m,
image: await mediaUrl(m.cover_path),
map: await mediaUrl(m.map_path),
})),
maps.map(async (m) => {
if (needsProcessing(m.upload_jobs))
processUploadAfterResponse(m.id);
return {
...m,
image: await mediaUrl(m.cover_path),
map: await mediaUrl(m.map_path),
};
}),
),
});
}
const d = await ownedDataset(request, path[1]);
if (method === "GET" && path[2] === "export" && path.length === 3) {
if (
d.imported_from_id ||
d.parent_dataset_id ||
d.banned_at ||
!d.map_path ||
!["draft", "rejected", "review", "published", "unpublished"].includes(
d.state,
)
)
throw new Error("This map is not ready to export");
await limit("export:" + d.owner_id, 10, 3600);
const input = required(
await client
.from("dataset_inputs")
.select("manifest,validated")
.eq("dataset_id", d.id)
.single(),
);
if (!input.validated)
throw new Error("This map is not ready to export");
const manifest = datasetSchema.parse(input.manifest);
const profile = required(
await client
.from("profiles")
.select("display_name")
.eq("id", d.owner_id)
.single(),
);
const metadata = archiveMetadataSchema.parse({
format: "minegeo-map/v1",
minecraftVersion: manifest.minecraftVersion,
dimension: manifest.dimension,
north: manifest.north,
source: manifest.source,
name: manifest.name,
description: manifest.description,
serverName: manifest.serverName,
bounds: manifest.bounds,
viewpoints: manifest.viewpoints.map(({ id, x, y, z }) => ({
id,
x,
y,
z,
})),
originalMapId: d.id,
originalOwner: profile.display_name || "Explorer",
signature: "0".repeat(64),
});
metadata.signature = archiveSignature(
metadata,
process.env.SUPABASE_SECRET_KEY!,
);
const assets = [
{ path: "map.webp", storage: manifest.mapPath ?? d.map_path },
...manifest.viewpoints.flatMap((p) =>
faces.map((f) => ({
path: `${p.id}/${f}.webp`,
storage: panoramaPath(d.id, p, `${f}.webp`),
})),
),
];
const urls = await mediaUrls(assets.map((a) => a.storage));
return NextResponse.json({
manifest: metadata,
assets: assets.map((a, i) => ({ path: a.path, url: urls[i] })),
});
}
if (path[2] === "appeals" && path.length === 3 && !d.parent_dataset_id) {
if (method === "GET") {
const appeals = required(
@@ -405,6 +504,7 @@ export async function handle(request: NextRequest, path: string[]) {
})
).error,
);
processUploadAfterResponse(d.id);
return NextResponse.json({ ok: true });
}
if (method === "GET") {
@@ -466,10 +566,13 @@ export async function handle(request: NextRequest, path: string[]) {
const manifest = datasetSchema.parse(input.manifest);
const jobResult = await client
.from("upload_jobs")
.select("state,progress,error,updated_at,lease_until,kind")
.select(
"id,state,progress,error,updated_at,lease_until,kind,source_dataset_id",
)
.eq("dataset_id", d.id)
.maybeSingle();
checkServiceError(jobResult.error);
if (needsProcessing(jobResult.data)) processUploadAfterResponse(d.id);
return NextResponse.json({
...d,
upload_jobs: jobResult.data,
@@ -540,6 +643,11 @@ export async function handle(request: NextRequest, path: string[]) {
return NextResponse.json({ ok: true });
}
if (method === "POST" && path[2] === "submit") {
if (d.imported_from_id)
throw new ServiceError(
`Owned by "${d.imported_owner_name}". Shared maps cannot be submitted for moderator review.`,
403,
);
const { error } = await client.rpc("submit_draft", {
p_dataset: d.id,
p_owner: d.owner_id,
@@ -794,12 +902,18 @@ export async function handle(request: NextRequest, path: string[]) {
const reviewer = await moderator(request);
if (path[1] === "settings" && path.length === 2 && method === "PATCH") {
const body = siteSettingsSchema.parse(await jsonBody(request));
const enabled = required(await client.rpc("set_beta_badge", {
p_reviewer: reviewer, p_enabled: body.betaBadgeEnabled,
}));
return NextResponse.json({ betaBadgeEnabled: enabled }, {
headers: { "Cache-Control": "no-store" },
});
const enabled = required(
await client.rpc("set_beta_badge", {
p_reviewer: reviewer,
p_enabled: body.betaBadgeEnabled,
}),
);
return NextResponse.json(
{ betaBadgeEnabled: enabled },
{
headers: { "Cache-Control": "no-store" },
},
);
}
if (path[1] === "appeals" && path.length === 3 && method === "POST") {
const body = z

98
lib/fabric-images.ts Normal file
View File

@@ -0,0 +1,98 @@
import { createHash } from "node:crypto";
import sharp from "sharp";
import { faces, type DatasetManifest } from "../shared/protocol";
type ImageIO = {
download: (path: string) => Promise<Buffer>;
save: (path: string, bytes: Buffer) => Promise<void>;
progress: (percent: number) => Promise<void>;
signal: AbortSignal;
mapOnly?: boolean;
};
// Process one image at a time. Vercel needs neither a local capture directory
// nor a Minecraft/Chromium process for images already captured by the mod.
export async function processFabricImages(
manifest: DatasetManifest,
io: ImageIO,
) {
if (manifest.source !== "fabric" && !manifest.mediaFormat)
throw new Error("A Fabric capture is required");
const format = manifest.mediaFormat ?? "png";
const assets = new Map(manifest.assets.map((asset) => [asset.path, asset]));
const points = io.mapOnly
? []
: (manifest.availableViewpoints ?? manifest.viewpoints);
const total = 1 + points.length * faces.length;
let completed = 0;
async function read(path: string) {
io.signal.throwIfAborted();
const asset = assets.get(path);
if (!asset) throw new Error(`Missing export asset: ${path}`);
const bytes = await io.download(path);
io.signal.throwIfAborted();
if (
bytes.length !== asset.bytes ||
createHash("sha256").update(bytes).digest("hex") !== asset.sha256
)
throw new Error(`Asset checksum/size mismatch: ${path}`);
return bytes;
}
async function save(path: string, bytes: Buffer) {
io.signal.throwIfAborted();
await io.save(path, bytes);
io.signal.throwIfAborted();
}
const map = await read(`map.${format}`);
const mapMeta = await sharp(map, { limitInputPixels: 16777216 }).metadata();
if (
mapMeta.format !== format ||
!mapMeta.width ||
!mapMeta.height ||
(mapMeta.pages ?? 1) !== 1
)
throw new Error("The captured map has an invalid image format");
await save(
"map.webp",
await sharp(map, { limitInputPixels: 16777216 })
.webp({ lossless: true })
.toBuffer(),
);
await io.progress((95 * ++completed) / total);
for (const point of points) {
for (const face of faces) {
const bytes = await read(`${point.id}/${face}.${format}`);
const meta = await sharp(bytes, {
limitInputPixels: 16777216,
}).metadata();
if (
meta.format !== format ||
(meta.pages ?? 1) !== 1 ||
!meta.width ||
meta.width !== meta.height ||
meta.width < 256 ||
meta.width > 2048
)
throw new Error(
format === "png"
? "Panorama faces must be square PNGs, 256–2048 pixels"
: "Panorama faces must be square WebPs, 256–2048 pixels",
);
await save(
`${point.id}/${face}.webp`,
await sharp(bytes, { limitInputPixels: 16777216 })
.webp({ quality: 85 })
.toBuffer(),
);
if (face === "nz")
await save(
`${point.id}/preview.webp`,
await sharp(bytes, { limitInputPixels: 16777216 })
.resize(512, 512)
.webp({ quality: 76 })
.toBuffer(),
);
await io.progress((95 * ++completed) / total);
}
}
}

198
lib/fabric-jobs.ts Normal file
View File

@@ -0,0 +1,198 @@
import { randomUUID } from "node:crypto";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "../shared/database";
import { datasetSchema } from "../shared/protocol";
import { processFabricImages } from "./fabric-images";
type Job = Database["public"]["Tables"]["upload_jobs"]["Row"];
type JobStatus = Pick<Job, "state" | "lease_until">;
export function needsProcessing(job: JobStatus | null | undefined) {
return (
!!job &&
(job.state === "queued" ||
(job.state === "running" &&
!!job.lease_until &&
Date.parse(job.lease_until) < Date.now()))
);
}
function checked<T>(result: {
data: T;
error: { message: string } | null;
}): NonNullable<T> {
if (result.error) throw new Error(result.error.message);
if (result.data == null) throw new Error("Processing record not found");
return result.data!;
}
// Same queue, attempts and two-minute renewable leases as the standalone worker.
// Scope each invocation to a dataset the API has already authorized.
export async function processFabricJob(
client: SupabaseClient<Database>,
datasetId: string,
deadlineAt = Date.now() + 250000,
) {
const pending = checked(
await client
.from("upload_jobs")
.select("*")
.eq("dataset_id", datasetId)
.maybeSingle(),
);
if (
!needsProcessing(pending) ||
pending.attempts >= 3 ||
deadlineAt - Date.now() < 20000
)
return "skipped";
const mapOnly = pending.kind === "map";
const sourceId = mapOnly ? pending.source_dataset_id : pending.dataset_id;
if (!sourceId) throw new Error("Missing capture for map refresh");
const dataset = checked(
await client
.from("datasets")
.select("owner_id,source")
.eq("id", sourceId)
.single(),
);
// Terrain exports still use the standalone renderer; do not consume a retry.
const input = checked(
await client
.from("dataset_inputs")
.select("manifest")
.eq("dataset_id", sourceId)
.single(),
);
const manifest = datasetSchema.parse(input.manifest);
if (dataset.source !== "fabric" && !manifest.mediaFormat) return "skipped";
const worker = "vercel-" + randomUUID();
const now = Date.now();
let claim = client
.from("upload_jobs")
.update({
state: "running",
attempts: pending.attempts + 1,
lease_owner: worker,
lease_until: new Date(now + 120000).toISOString(),
progress: 0,
error: null,
updated_at: new Date(now).toISOString(),
})
.eq("id", pending.id)
.eq("state", pending.state)
.eq("attempts", pending.attempts);
if (pending.state === "running")
claim = claim.lt("lease_until", new Date(now).toISOString());
const rows = checked(await claim.select("*"));
if (!rows.length) return "skipped";
const job = rows[0];
const abort = new AbortController();
// Leave time to release the lease before Vercel's 300-second limit.
const deadline = setTimeout(
() =>
abort.abort(new Error("Image processing time limit reached; retrying")),
Math.max(1, deadlineAt - Date.now()),
);
let lastProgress = 0;
async function update(
values: Database["public"]["Tables"]["upload_jobs"]["Update"],
) {
const rows = checked(
await client
.from("upload_jobs")
.update(values)
.eq("id", job.id)
.eq("lease_owner", worker)
.eq("state", "running")
.select("id"),
);
if (!rows.length) abort.abort(new Error("Lease lost or cancelled"));
abort.signal.throwIfAborted();
}
const heartbeat = setInterval(() => {
void update({
lease_until: new Date(Date.now() + 120000).toISOString(),
updated_at: new Date().toISOString(),
}).catch((error) => abort.abort(error));
}, 20000);
try {
console.log(
`Vercel worker: processing ${job.id} (${manifest.viewpoints.length} panoramas)`,
);
await processFabricImages(manifest, {
mapOnly,
signal: abort.signal,
async download(path) {
const blob = checked(
await client.storage
.from("minegeo-raw")
.download(`${dataset.owner_id}/${sourceId}/${path}`),
);
return Buffer.from(await blob.arrayBuffer());
},
async save(path, bytes) {
checked(
await client.storage
.from("minegeo-media")
.upload(
mapOnly
? `${job.dataset_id}/maps/${job.id}.webp`
: `${job.dataset_id}/${path}`,
bytes,
{ contentType: "image/webp", upsert: true, cacheControl: "60" },
),
);
},
async progress(percent) {
if (Date.now() - lastProgress < 2000 && percent < 95) return;
lastProgress = Date.now();
await update({
progress: Math.floor(percent),
updated_at: new Date().toISOString(),
});
},
});
abort.signal.throwIfAborted();
const result = await client.rpc("complete_job", {
p_job: job.id,
p_worker: worker,
p_count: manifest.viewpoints.length,
p_cover: manifest.viewpoints[0].id,
});
if (result.error) throw new Error(result.error.message);
console.log(`Vercel worker: ${job.id} complete; private draft ready`);
return "complete";
} catch (error) {
const message =
error instanceof Error ? error.message : "Image processing failed";
console.error(`Vercel worker: ${job.id}: ${message}`);
const state = job.attempts >= 3 ? "failed" : "queued";
const released = checked(
await client
.from("upload_jobs")
.update({
state,
error: message,
lease_owner: null,
lease_until: null,
progress: 0,
updated_at: new Date().toISOString(),
})
.eq("id", job.id)
.eq("lease_owner", worker)
.eq("state", "running")
.select("id"),
);
if (!mapOnly && state === "failed" && released.length) {
const result = await client
.from("datasets")
.update({ state: "failed", rejection_reason: message })
.eq("id", job.dataset_id)
.eq("state", "processing");
if (result.error) throw new Error(result.error.message);
}
return released.length ? state : "skipped";
} finally {
clearInterval(heartbeat);
clearTimeout(deadline);
}
}

View File

@@ -0,0 +1,23 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import {
archiveMetadataSchema,
type ArchiveMetadata,
} from "../shared/map-archive";
export function archiveSignature(value: ArchiveMetadata, key: string) {
const { signature: _signature, ...metadata } =
archiveMetadataSchema.strip().parse(value);
return createHmac("sha256", key)
.update("minegeo-map-share/v1\n")
.update(JSON.stringify(metadata))
.digest("hex");
}
export function verifyArchiveSignature(value: ArchiveMetadata, key: string) {
const expected = Buffer.from(archiveSignature(value, key), "hex");
const supplied = Buffer.from(value.signature, "hex");
if (
expected.length !== supplied.length ||
!timingSafeEqual(expected, supplied)
)
throw new Error("Map ZIP ownership verification failed");
}

133
lib/map-transfer.ts Normal file
View File

@@ -0,0 +1,133 @@
import { api } from "./client-api";
import type { ArchiveMetadata } from "../shared/map-archive";
type Progress = (message: string, values?: Record<string, number>) => void;
export async function exportMap(id: string, progress: Progress) {
const {
archiveMetadataSchema,
archiveSchema,
archiveLimit,
archiveFileLimit,
archiveManifestName,
sha256,
} = await import("../shared/map-archive");
const { zipSync, strToU8 } = await import("fflate");
const result = await api<{
manifest: ArchiveMetadata;
assets: { path: string; url: string }[];
}>(`datasets/${id}/export`);
const metadata = archiveMetadataSchema.parse(result.manifest);
const files: Record<string, Uint8Array> = Object.create(null);
const assets = [];
let total = 0;
for (const [index, asset] of result.assets.entries()) {
progress("Exporting images: {done} / {total}", {
done: index + 1,
total: result.assets.length,
});
const response = await fetch(asset.url, {
signal: AbortSignal.timeout(60000),
cache: "no-store",
});
if (!response.ok || !response.body)
throw new Error("Could not download map images. Please try again.");
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
size += value.length;
total += value.length;
if (size > archiveFileLimit || total > archiveLimit - 128 * 1024) {
await reader.cancel();
throw new Error("Map archive exceeds the 256 MB limit");
}
chunks.push(value);
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.length;
}
files[asset.path] = bytes;
assets.push({ path: asset.path, bytes: size, sha256: await sha256(bytes) });
}
const archive = archiveSchema.parse({ ...metadata, assets });
files[archiveManifestName] = strToU8(JSON.stringify(archive));
progress("Preparing ZIP…");
const zip = zipSync(files, { level: 0 });
if (zip.length > archiveLimit)
throw new Error("Map archive exceeds the 256 MB limit");
const url = URL.createObjectURL(
new Blob([new Uint8Array(zip).buffer], { type: "application/zip" }),
);
const link = document.createElement("a");
link.href = url;
link.download = `${metadata.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80) || "minegeo-map"}.zip`;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
export async function importMap(file: File, progress: Progress) {
const { archiveLimit, readMapArchive } = await import(
"../shared/map-archive"
);
if (!file.name.toLowerCase().endsWith(".zip"))
throw new Error("Choose a MineGeo ZIP file");
if (file.size > archiveLimit)
throw new Error("Map archive exceeds the 256 MB limit");
progress("Checking map ZIP…");
const { archive, files } = await readMapArchive(
new Uint8Array(await file.arrayBuffer()),
);
const { datasetId } = await api<{ datasetId: string }>("uploads/import", {
manifest: archive,
});
const signed = await api<{
assets: {
path: string;
objectName: string;
token: string;
apiKey: string;
endpoint: string;
}[];
}>("uploads/sign", { datasetId });
const { createBrowserClient } = await import("@supabase/ssr");
const client = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
try {
for (const [index, asset] of signed.assets.entries()) {
progress("Importing images: {done} / {total}", {
done: index + 1,
total: signed.assets.length,
});
const content = files.get(asset.path);
if (!content) throw new Error("Invalid or incomplete map ZIP");
const { error } = await client.storage
.from("minegeo-raw")
.uploadToSignedUrl(
asset.objectName,
asset.token,
new Blob([new Uint8Array(content).buffer], { type: "image/webp" }),
{ contentType: "image/webp" },
);
if (error)
throw new Error(
"Map upload failed. Check your connection and import the ZIP again.",
);
}
progress("Preparing your private draft…");
await api("uploads/finalize", { datasetId });
return datasetId;
} catch (error) {
await api(`datasets/${datasetId}/cancel`, {}).catch(() => {});
throw error;
}
}

View File

@@ -2,7 +2,7 @@ import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { admin, required } from "./supabase";
import { admin, required, user } from "./supabase";
import { mediaUrls } from "./catalog";
import { requestOrigin } from "./request-origin";
import { ServiceError } from "./service-error";
@@ -53,13 +53,15 @@ export async function multiplayerApi(
if (request.method === "POST" && path.length === 1) {
const body = createRoomSchema.parse(await jsonBody(request));
await limit("multiplayer:create:" + accessHash, 20, 3600);
const account = await user();
const id = required(
await client.rpc("create_multiplayer_room", {
await client.rpc("create_map_room", {
p_dataset: body.datasetId,
p_difficulty: body.difficulty,
p_name: body.displayName,
p_limit: body.playerLimit,
p_access_hash: accessHash,
p_owner: account?.id ?? null,
}),
);
return respond({ id });

22
lib/process-uploads.ts Normal file
View File

@@ -0,0 +1,22 @@
import "server-only";
import { after } from "next/server";
import { admin } from "./supabase";
import { processFabricJob } from "./fabric-jobs";
export function processUploadAfterResponse(datasetId: string) {
after(async () => {
try {
const deadlineAt = Date.now() + 250000;
for (let attempt = 0; attempt < 3; attempt++) {
const state = await processFabricJob(admin(), datasetId, deadlineAt);
if (state !== "queued" || deadlineAt - Date.now() < 20000) break;
await new Promise((resolve) => setTimeout(resolve, 2500));
}
} catch (error) {
console.error(
"Vercel upload worker:",
error instanceof Error ? error.message : error,
);
}
});
}

View File

@@ -0,0 +1,259 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { chromium } from "@playwright/test";
import { createClient } from "@supabase/supabase-js";
import sharp from "sharp";
import { datasetSchema, faces } from "../shared/protocol";
import { readMapArchive } from "../shared/map-archive";
process.loadEnvFile(".env.local");
const origin = new URL(process.argv[2] || "https://minegeo.vercel.app").origin;
const db = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
const output = ".data/sharing-browser";
fs.mkdirSync(output, { recursive: true });
const records: {
users: string[];
datasets: string[];
server?: string;
assets: string[];
} = { users: [], datasets: [], assets: [] };
const save = () =>
fs.writeFileSync(`${output}/fixtures.json`, JSON.stringify(records));
const checked = <T>(result: { data: T; error: { message: string } | null }) => {
if (result.error) throw new Error(result.error.message);
return result.data!;
};
const run = randomUUID();
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
try {
const accounts = [];
for (const role of ["Owner", "Friend"]) {
const email = `zip-${role.toLowerCase()}-${run}@example.com`,
password = `Zip-test-${randomUUID()}!`;
const created = await db.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { display_name: `ZIP verification ${role}` },
});
if (created.error || !created.data.user)
throw new Error("Could not create a temporary fixture account");
const account = created.data.user;
accounts.push({ email, password, id: account.id });
records.users.push(account.id);
save();
checked(
await db
.from("profiles")
.upsert({ id: account.id, display_name: `ZIP verification ${role}` }),
);
}
const viewpoints = Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
x: i,
y: 70,
z: i,
}));
const manifest = datasetSchema.parse({
format: "minegeo-dataset/v1",
minecraftVersion: "26.2",
dimension: "minecraft:overworld",
source: "plugin",
name: "ZIP verification world",
description: "Temporary browser fixture",
serverName: `ZIP verification ${run}`,
bounds: { minX: 0, minZ: 0, maxX: 32, maxZ: 32 },
north: "-z",
viewpoints,
assets: [{ path: "terrain.json.gz", bytes: 1, sha256: "0".repeat(64) }],
});
const original = checked(
await db.rpc("create_upload", {
p_owner: accounts[0].id,
p_manifest: manifest,
}),
);
records.datasets.push(original);
save();
records.server = checked(
await db.from("datasets").select("server_id").eq("id", original).single(),
).server_id;
save();
const image = await sharp({
create: { width: 256, height: 256, channels: 3, background: "#729343" },
})
.webp()
.toBuffer();
records.assets = [
"map.webp",
...viewpoints.flatMap((p) => [
...faces.map((f) => `${p.id}/${f}.webp`),
`${p.id}/preview.webp`,
]),
];
save();
for (const path of records.assets)
checked(
await db.storage
.from("minegeo-media")
.upload(`${original}/${path}`, image, { contentType: "image/webp" }),
);
checked(
await db
.from("dataset_inputs")
.update({ validated: true })
.eq("dataset_id", original),
);
checked(
await db
.from("datasets")
.update({
state: "draft",
panorama_count: 5,
map_path: `${original}/map.webp`,
cover_path: `${original}/p0/preview.webp`,
})
.eq("id", original),
);
browser = await chromium.launch({ headless: true });
const owner = await browser.newContext({ locale: "en-US" });
const friend = await browser.newContext({ locale: "en-US" });
for (const [index, context] of [owner, friend].entries()) {
const response = await context.request.post(`${origin}/api/v1/auth`, {
headers: { origin },
data: {
action: "login",
email: accounts[index].email,
password: accounts[index].password,
},
});
assert.equal(response.status(), 200, "Fixture login failed");
}
const ownerPage = await owner.newPage();
await ownerPage.goto(`${origin}/dashboard/${original}`);
const downloadEvent = ownerPage.waitForEvent("download");
await ownerPage
.getByRole("button", { name: "Export ZIP", exact: true })
.click();
const download = await downloadEvent;
const zipPath = `${output}/map.zip`;
await download.saveAs(zipPath);
const restored = await readMapArchive(
new Uint8Array(fs.readFileSync(zipPath)),
);
assert.equal(restored.archive.originalOwner, "ZIP verification Owner");
assert.equal(restored.archive.source, "plugin");
console.log("Owner exported a portable ZIP in the browser.");
const page = await friend.newPage();
await page.goto(`${origin}/servers`);
await page.getByRole("button", { name: "Import", exact: true }).click();
await page.getByLabel("Map ZIP file", { exact: true }).setInputFiles(zipPath);
await page.waitForURL(/\/dashboard\/[0-9a-f-]{36}$/, { timeout: 120000 });
const copy = page.url().split("/").pop()!;
records.datasets.push(copy);
save();
await page
.getByRole("heading", { name: "Owned by ZIP verification Owner" })
.waitFor();
await page
.getByRole("button", { name: "Start exploring", exact: true })
.waitFor({ timeout: 120000 });
await page.screenshot({
path: `${output}/imported-desktop.png`,
fullPage: true,
});
assert.equal(
await page.getByRole("button", { name: "Export ZIP", exact: true }).count(),
0,
);
assert.equal(
await page
.getByRole("button", { name: "Submit for review →", exact: true })
.isDisabled(),
true,
);
const blocked = await friend.request.post(
`${origin}/api/v1/datasets/${copy}/submit`,
{ headers: { origin }, data: {} },
);
assert.equal(blocked.status(), 403);
assert.match(
(await blocked.json()).error,
/Owned by "ZIP verification Owner"/,
);
const foreignExport = await friend.request.get(
`${origin}/api/v1/datasets/${original}/export`,
);
assert.equal(foreignExport.status(), 404);
const foreignCopy = await owner.request.post(`${origin}/api/v1/sessions`, {
headers: { origin },
data: { datasetId: copy, difficulty: "normal" },
});
assert.notEqual(foreignCopy.status(), 200);
console.log(
"Friend imported and processed the ZIP; original-owner attribution, export restriction, and submission block passed.",
);
await page
.getByRole("button", { name: "Start exploring", exact: true })
.click();
await page.waitForURL(/\/play\//);
const sessionId = page.url().split("/").pop()!;
const sessionResponse = await friend.request.get(
`${origin}/api/v1/sessions/${sessionId}`,
);
assert.equal(sessionResponse.status(), 200);
const session = await sessionResponse.json();
assert.equal(session.rounds.length, 5);
assert.ok(
session.rounds.every(
(r: { faces: string[] }) =>
r.faces.length === 6 && r.faces.every(Boolean),
),
);
const roomResponse = await friend.request.post(`${origin}/api/v1/rooms`, {
headers: { origin },
data: {
datasetId: copy,
difficulty: "normal",
displayName: "ZIP Friend",
playerLimit: 4,
},
});
assert.equal(roomResponse.status(), 200, "Private room failed");
const roomId = (await roomResponse.json()).id;
const invited = await browser.newContext();
const join = await invited.request.post(
`${origin}/api/v1/rooms/${roomId}/join`,
{ headers: { origin }, data: { displayName: "ZIP Guest" } },
);
assert.equal(join.status(), 200);
console.log(
"Private solo game and invited multiplayer guest passed without moderation.",
);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${origin}/servers`);
await page.getByRole("button", { name: "Import", exact: true }).click();
await page.getByRole("heading", { name: "Import a shared map" }).waitFor();
await page.screenshot({
path: `${output}/import-mobile.png`,
fullPage: true,
});
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth,
),
false,
);
console.log("Mobile import dialog passed at 390px.");
} finally {
await browser?.close();
save();
console.log("Temporary fixture IDs saved for cleanup.");
}

View File

@@ -0,0 +1,22 @@
import { readFile, readdir } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
const db = new PGlite();
try {
await db.exec(`create role anon; create role authenticated; create role service_role;
create schema auth; create table auth.users(id uuid primary key);
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
for (const file of (await readdir("supabase/migrations"))
.filter((f) => f.endsWith(".sql"))
.sort())
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
await db.exec(await readFile("tests/sql/map-sharing.sql", "utf8"));
console.log(
"Map sharing SQL passed: private solo/rooms, owner attribution, publication blocked, original owner can submit.",
);
} catch (error) {
console.error(error.message, error.where ?? "");
process.exitCode = 1;
} finally {
await db.close();
}

View File

@@ -60,6 +60,7 @@ export function authDestination(value: string | null | undefined): string {
if (
value === "/settings" ||
value === "/dashboard" ||
value === "/servers" ||
value === "/auth/reset-password"
)
return value;

View File

@@ -236,6 +236,8 @@ export type Database = {
};
datasets: {
Row: MapModerationFields & {
imported_from_id: string | null;
imported_owner_name: string | null;
allow_contributions: boolean;
bounds: Json;
contribution_count: number;
@@ -627,6 +629,26 @@ export type Database = {
[_ in never]: never;
};
Functions: {
create_map_import: {
Args: {
p_owner: string;
p_manifest: Json;
p_original_map: string;
p_original_owner: string;
};
Returns: string;
};
create_map_room: {
Args: {
p_owner: string | null;
p_dataset: string;
p_difficulty: string;
p_name: string;
p_limit: number;
p_access_hash: string;
};
Returns: string;
};
create_multiplayer_room: {
Args: {
p_dataset: string;

View File

@@ -668,5 +668,37 @@
"Minecraft Java 1.21.11, 26.1 & 26.2 + Fabric Loader + Fabric API": "Minecraft Java 1.21.11, 26.1 и 26.2 + Fabric Loader + Fabric API",
"Bukkit/Paper 1.21.11, 26.1 & 26.2": "Bukkit/Paper 1.21.11, 26.1 и 26.2",
"Java 21 for 1.21.11; Java 25 for 26.1 and 26.2. Both tools connect to minegeo.vercel.app automatically.": "Java 21 для 1.21.11; Java 25 для 26.1 и 26.2. Оба инструмента автоматически подключаются к minegeo.vercel.app.",
"Put the JAR in plugins/ and restart. Set server-name and server-address in plugins/MineGeo/config.yml, then run /minegeo reload. Permission: minegeo.admin. The same plugin JAR supports all three versions.": "Поместите JAR в plugins/ и перезапустите сервер. Укажите server-name и server-address в plugins/MineGeo/config.yml, затем выполните /minegeo reload. Разрешение: minegeo.admin. Один JAR плагина поддерживает все три версии."
"Put the JAR in plugins/ and restart. Set server-name and server-address in plugins/MineGeo/config.yml, then run /minegeo reload. Permission: minegeo.admin. The same plugin JAR supports all three versions.": "Поместите JAR в plugins/ и перезапустите сервер. Укажите server-name и server-address в plugins/MineGeo/config.yml, затем выполните /minegeo reload. Разрешение: minegeo.admin. Один JAR плагина поддерживает все три версии.",
"The newest eligible capture is selected automatically. Choose the original capture only if you want its older terrain.": "Автоматически выбирается самый новый подходящий снимок. Выбирай исходный снимок, только если нужен старый ландшафт.",
"Last successful refresh used: {capture}.": "Последнее успешное обновление использовало: {capture}.",
"Map refresh queued. Progress and the result are saved; you can reopen this page later.": "Обновление карты в очереди. Прогресс и результат сохраняются; эту страницу можно открыть позже.",
"Guessing map refreshed successfully": "Карта для угадывания успешно обновлена",
"The updated guessing map is ready to preview.": "Обновлённая карта для угадывания готова к просмотру.",
"Import": "Импорт",
"Export ZIP": "Экспорт ZIP",
"Exporting…": "Экспорт…",
"Import a shared map": "Импорт общей карты",
"Map ZIP file": "ZIP-файл карты",
"Sign in to import": "Войти для импорта",
"Map ZIP downloaded. Share it with a friend.": "ZIP-файл карты скачан. Поделитесь им с другом.",
"Choose a MineGeo ZIP exported by the map owner. Play it privately without moderator review. The original owner keeps publication rights. Maximum size: 256 MB.": "Выберите ZIP-файл MineGeo, экспортированный владельцем карты. Играйте приватно без проверки модератора. Право публикации остаётся у первоначального владельца. Максимальный размер: 256 МБ.",
"Owned by {owner}": "Владелец: {owner}",
"This shared map is private and playable without moderator review. Only the original owner can submit it for public review.": "Эта общая карта приватная. Играть можно без проверки модератора. Только первоначальный владелец может отправить её на проверку для публикации.",
"Exporting images: {done} / {total}": "Экспорт изображений: {done} / {total}",
"Importing images: {done} / {total}": "Импорт изображений: {done} / {total}",
"Preparing ZIP…": "Подготовка ZIP…",
"Checking map ZIP…": "Проверка ZIP-файла карты…",
"Preparing your private draft…": "Подготовка приватной карты…",
"Choose a MineGeo ZIP file": "Выберите ZIP-файл MineGeo",
"Map archive exceeds the 256 MB limit": "Архив карты превышает ограничение 256 МБ",
"This ZIP is not a supported MineGeo map": "Этот ZIP-файл не является поддерживаемой картой MineGeo",
"Invalid or incomplete map ZIP": "ZIP-файл карты повреждён или неполный",
"Map archive checksum or size mismatch": "Контрольная сумма или размер файла в архиве карты не совпадает",
"Map upload failed. Check your connection and import the ZIP again.": "Не удалось загрузить карту. Проверьте подключение и импортируйте ZIP ещё раз.",
"Could not download map images. Please try again.": "Не удалось скачать изображения карты. Попробуйте ещё раз.",
"Map ZIP ownership verification failed": "Не удалось проверить владельца ZIP-файла карты",
"This map is not ready to export": "Эта карта ещё не готова к экспорту",
"Could not export map": "Не удалось экспортировать карту",
"Could not import map": "Не удалось импортировать карту",
"Close": "Закрыть"
}

199
shared/map-archive.ts Normal file
View File

@@ -0,0 +1,199 @@
import { z } from "zod";
import { Unzip, UnzipInflate, strFromU8 } from "fflate";
import { boundsSchema, datasetSchema, faces } from "./protocol";
export const archiveLimit = 256 * 1024 * 1024;
export const archiveFileLimit = 50 * 1024 * 1024;
export const archiveManifestName = "minegeo-map.json";
const locationSchema = z.strictObject({
id: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/),
x: z.number().finite(),
y: z.number().finite(),
z: z.number().finite(),
});
export const archiveMetadataSchema = z.strictObject({
format: z.literal("minegeo-map/v1"),
minecraftVersion: datasetSchema.shape.minecraftVersion,
dimension: z.literal("minecraft:overworld"),
north: z.literal("-z"),
source: z.enum(["plugin", "fabric"]),
name: z.string().min(3).max(80),
description: z.string().max(2000),
serverName: z.string().min(2).max(80),
bounds: boundsSchema,
viewpoints: z.array(locationSchema).min(5).max(24),
originalMapId: z.string().uuid(),
originalOwner: z.string().min(1).max(200),
signature: z.string().regex(/^[a-f0-9]{64}$/),
});
export const archiveSchema = archiveMetadataSchema
.extend({
assets: z
.array(
z.strictObject({
path: z
.string()
.regex(
/^(?:map\.webp|[a-zA-Z0-9_-]{1,64}\/(?:px|nx|py|ny|pz|nz)\.webp)$/,
),
bytes: z.number().int().positive().max(archiveFileLimit),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
}),
)
.min(31)
.max(145),
})
.superRefine((archive, ctx) => {
const paths = new Set(archive.assets.map((a) => a.path));
const expected = [
"map.webp",
...archive.viewpoints.flatMap((p) =>
faces.map((f) => `${p.id}/${f}.webp`),
),
];
if (
paths.size !== archive.assets.length ||
paths.size !== expected.length ||
expected.some((p) => !paths.has(p))
)
ctx.addIssue({
code: "custom",
message:
"The archive must contain the map and all six faces for each location",
});
if (
new Set(archive.viewpoints.map((p) => p.id)).size !==
archive.viewpoints.length
)
ctx.addIssue({ code: "custom", message: "Viewpoint IDs must be unique" });
if (archive.assets.reduce((n, a) => n + a.bytes, 0) > archiveLimit)
ctx.addIssue({
code: "custom",
message: "Map archive exceeds the 256 MB limit",
});
for (const p of archive.viewpoints)
if (
p.x < archive.bounds.minX ||
p.x > archive.bounds.maxX ||
p.z < archive.bounds.minZ ||
p.z > archive.bounds.maxZ
)
ctx.addIssue({
code: "custom",
message: "Viewpoint outside map bounds",
});
});
export type MapArchive = z.infer<typeof archiveSchema>;
export type ArchiveMetadata = z.infer<typeof archiveMetadataSchema>;
// Strict portable metadata has no account IDs, original storage paths, server
// address, credentials, publication state, or contribution permissions.
export function importManifest(value: unknown) {
const archive = archiveSchema.parse(value);
return datasetSchema.parse({
...archive,
format: "minegeo-dataset/v1",
mediaFormat: "webp",
});
}
export async function sha256(bytes: Uint8Array) {
const digest = await crypto.subtle.digest(
"SHA-256",
new Uint8Array(bytes).buffer,
);
return Array.from(new Uint8Array(digest), (b) =>
b.toString(16).padStart(2, "0"),
).join("");
}
export async function readMapArchive(bytes: Uint8Array) {
if (!bytes.length || bytes.length > archiveLimit)
throw new Error("Map archive exceeds the 256 MB limit");
const files = new Map<string, Uint8Array>();
const names = new Set<string>();
let total = 0;
let failure: Error | undefined;
const unzip = new Unzip((file) => {
const valid =
file.name === archiveManifestName ||
/^(?:map\.webp|[a-zA-Z0-9_-]{1,64}\/(?:px|nx|py|ny|pz|nz)\.webp)$/.test(
file.name,
);
const max =
file.name === archiveManifestName ? 128 * 1024 : archiveFileLimit;
if (
!valid ||
names.has(file.name) ||
names.size >= 146 ||
(file.originalSize != null && file.originalSize > max)
)
throw new Error(
"Invalid map ZIP: unexpected, duplicate, or oversized file",
);
names.add(file.name);
const chunks: Uint8Array[] = [];
let size = 0;
file.ondata = (error, data, final) => {
if (failure) return;
if (error) {
failure = error;
file.terminate();
return;
}
size += data.length;
total += data.length;
if (size > max || total > archiveLimit) {
failure = new Error("Map archive exceeds the 256 MB limit");
file.terminate();
return;
}
chunks.push(data);
if (final) {
const content = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
content.set(chunk, offset);
offset += chunk.length;
}
files.set(file.name, content);
}
};
file.start();
});
unzip.register(UnzipInflate);
try {
// Bound inflation per push even when a ZIP lies about its original sizes.
for (let offset = 0; offset < bytes.length; offset += 1024) {
unzip.push(
bytes.subarray(offset, offset + 1024),
offset + 1024 >= bytes.length,
);
if (failure) throw failure;
}
} catch (error) {
throw new Error(error instanceof Error ? error.message : "Invalid map ZIP");
}
if (files.size !== names.size || !files.has(archiveManifestName))
throw new Error("Invalid or incomplete map ZIP");
let archive: MapArchive;
try {
archive = archiveSchema.parse(
JSON.parse(strFromU8(files.get(archiveManifestName)!)),
);
} catch {
throw new Error("This ZIP is not a supported MineGeo map");
}
if (files.size !== archive.assets.length + 1)
throw new Error("Map ZIP contains unexpected files");
for (const asset of archive.assets) {
const content = files.get(asset.path);
if (
!content ||
content.length !== asset.bytes ||
(await sha256(content)) !== asset.sha256
)
throw new Error("Map archive checksum or size mismatch");
}
return { archive, files };
}

17
shared/map-refresh.ts Normal file
View File

@@ -0,0 +1,17 @@
export type MapCapture = { id: string; name: string; created_at: string };
// A contribution can be newer than the original capture, even when both have
// the same name/date in the picker. Keep deliberate choices; otherwise use the
// newest eligible capture, including when accepting a contribution adds one.
export function refreshCapture(sources: MapCapture[], chosen?: string) {
if (chosen && sources.some((source) => source.id === chosen)) return chosen;
return (
sources.reduce<MapCapture | undefined>(
(latest, source) =>
!latest || Date.parse(source.created_at) > Date.parse(latest.created_at)
? source
: latest,
undefined,
)?.id ?? ""
);
}

View File

@@ -1,4 +1,6 @@
export type ProcessingJob = {
id?: string;
source_dataset_id?: string | null;
kind?: string;
state: string;
progress: number;

View File

@@ -49,6 +49,8 @@ export const datasetSchema = z
),
dimension: z.literal("minecraft:overworld"),
source: z.enum(["plugin", "fabric"]),
// Portable map imports contain rendered images rather than capture inputs.
mediaFormat: z.literal("webp").optional(),
name: z.string().min(3).max(80),
description: z.string().max(2000).default(""),
serverName: z.string().min(2).max(80),
@@ -88,16 +90,23 @@ export const datasetSchema = z
});
const paths = new Set(d.assets.map((asset) => asset.path));
const requiredPaths =
d.source === "plugin"
? ["terrain.json.gz"]
: [
"map.png",
...(d.availableViewpoints ?? d.viewpoints)
.filter((point) => !point.assetRoot)
.flatMap((point) =>
faces.map((face) => `${point.id}/${face}.png`),
),
];
d.mediaFormat === "webp"
? [
"map.webp",
...d.viewpoints.flatMap((point) =>
faces.map((face) => `${point.id}/${face}.webp`),
),
]
: d.source === "plugin"
? ["terrain.json.gz"]
: [
"map.png",
...(d.availableViewpoints ?? d.viewpoints)
.filter((point) => !point.assetRoot)
.flatMap((point) =>
faces.map((face) => `${point.id}/${face}.png`),
),
];
for (const path of requiredPaths) {
if (!paths.has(path))
ctx.addIssue({
@@ -135,6 +144,8 @@ export const datasetSchema = z
// Media provenance is assigned by the server after the owner accepts a contribution.
export function parseUploadManifest(value: unknown) {
const manifest = datasetSchema.parse(value);
if (manifest.mediaFormat)
throw new Error("Use map import for rendered archives");
manifest.serverAddress =
normalizeServerAddress(manifest.serverAddress) ?? undefined;
for (const p of [

View File

@@ -0,0 +1,155 @@
-- Shared copies stay private. Account ownership permits managing and playing a
-- copy; publication and export rights remain with the original map owner.
alter table public.datasets add column imported_from_id uuid;
alter table public.datasets add column imported_owner_name text;
alter table public.datasets add constraint imported_map_attribution check (
(imported_from_id is null and imported_owner_name is null) or
(imported_from_id is not null and imported_owner_name is not null and length(imported_owner_name) between 1 and 200)
);
create function public.protect_shared_map() returns trigger
language plpgsql security invoker set search_path='' as $$
begin
if TG_OP='UPDATE' and old.imported_from_id is not null and
(new.imported_from_id is distinct from old.imported_from_id or new.imported_owner_name is distinct from old.imported_owner_name) then
raise exception 'Shared map ownership cannot be changed';
end if;
if new.imported_from_id is not null and
(new.state in ('review','published') or new.update_state='review' or new.allow_contributions) then
raise exception 'Owned by "%". Shared maps cannot be submitted for moderator review.',new.imported_owner_name;
end if;
return new;
end; $$;
create trigger protect_shared_map before insert or update on public.datasets
for each row execute function public.protect_shared_map();
create function public.create_map_import(p_owner uuid,p_manifest jsonb,p_original_map uuid,p_original_owner text)
returns uuid language plpgsql security invoker set search_path='' as $$
declare result uuid;
begin
if p_manifest->>'mediaFormat' is distinct from 'webp' or p_manifest ? 'contributionTo' or
p_original_map is null or coalesce(length(p_original_owner),0) not between 1 and 200 then
raise exception 'Invalid shared map';
end if;
result:=public.create_upload(p_owner,p_manifest);
update public.datasets set imported_from_id=p_original_map,imported_owner_name=p_original_owner where id=result;
return result;
end; $$;
-- Take an immutable private snapshot when starting a game. Other accounts and
-- guests can only start public maps; invited multiplayer players use room keys.
create function public.playable_map_revision(p_dataset uuid,p_owner uuid) returns uuid
language plpgsql security invoker set search_path='' as $$
declare d public.datasets; m jsonb; rev uuid;
begin
select * into d from public.datasets where id=p_dataset for share;
if not found then raise exception 'Map unavailable'; end if;
if d.state='published' then return d.revision_id; end if;
if d.imported_from_id is null or d.owner_id is distinct from p_owner or d.state<>'draft' or d.banned_at is not null then
raise exception 'Map unavailable';
end if;
if exists(select 1 from public.upload_jobs where dataset_id=d.id and state in ('queued','running')) then
raise exception 'Wait for map rendering to finish before playing';
end if;
select manifest into m from public.dataset_inputs where dataset_id=d.id and validated;
if m is null or jsonb_array_length(m->'viewpoints')<5 then raise exception 'Map unavailable'; end if;
insert into public.revisions(dataset_id,manifest,asset_root) values(d.id,m,d.id::text) returning id into rev;
return rev;
end; $$;
create or replace function public.create_game(p_dataset uuid,p_difficulty text,p_owner uuid,p_access_hash text)
returns uuid language plpgsql security invoker set search_path='' as $$
declare sid uuid; revision uuid; rev public.revisions; vp jsonb; n integer:=0;
begin
if p_difficulty not in ('easy','normal','hard') then raise exception 'Invalid difficulty'; end if;
revision:=public.playable_map_revision(p_dataset,p_owner);
select * into rev from public.revisions where id=revision;
if not found then raise exception 'Map unavailable'; end if;
if jsonb_array_length(rev.manifest->'viewpoints')<5 then raise exception 'Map has fewer than five viewpoints'; end if;
insert into public.game_sessions(revision_id,owner_id,access_hash,difficulty) values(rev.id,p_owner,p_access_hash,p_difficulty) returning id into sid;
for vp in select value from jsonb_array_elements(rev.manifest->'viewpoints') order by random() limit 5 loop
insert into public.game_rounds(session_id,round_number,viewpoint) values(sid,n,vp); n:=n+1;
end loop;
return sid;
end; $$;
create function public.create_map_room(p_dataset uuid,p_difficulty text,p_name text,p_limit integer,p_access_hash text,p_owner uuid)
returns uuid language plpgsql security invoker set search_path='' as $$
declare rid uuid; revision uuid; pid uuid:=gen_random_uuid(); rev public.revisions; vp jsonb; n integer:=0;
begin
if p_limit is null or p_limit not between 2 and 16 then raise exception 'Choose between 2 and 16 players'; end if;
if p_difficulty is null or p_difficulty not in ('easy','normal','hard') then raise exception 'Invalid difficulty'; end if;
revision:=public.playable_map_revision(p_dataset,p_owner);
select * into rev from public.revisions where id=revision;
if not found then raise exception 'Map unavailable'; end if;
if coalesce(jsonb_array_length(rev.manifest->'viewpoints'),0)<5 then raise exception 'Map has fewer than five viewpoints'; end if;
insert into public.multiplayer_rooms(revision_id,host_player_id,difficulty,player_limit) values(rev.id,pid,p_difficulty,p_limit) returning id into rid;
insert into public.multiplayer_players(id,room_id,access_hash,display_name) values(pid,rid,p_access_hash,btrim(p_name));
for vp in select value from jsonb_array_elements(rev.manifest->'viewpoints') order by random() limit 5 loop
insert into public.multiplayer_rounds(room_id,round_number,viewpoint) values(rid,n,vp); n:=n+1;
end loop;
return rid;
end; $$;
revoke all on function public.protect_shared_map(),public.create_map_import(uuid,jsonb,uuid,text),public.playable_map_revision(uuid,uuid),public.create_map_room(uuid,text,text,integer,text,uuid) from public,anon,authenticated;
grant execute on function public.create_map_import(uuid,jsonb,uuid,text),public.playable_map_revision(uuid,uuid),public.create_map_room(uuid,text,text,integer,text,uuid) to service_role;
-- Private unreviewed copies are excluded from public scores.
create or replace function public.player_leaderboard(
p_difficulty text default 'all',
p_search text default '',
p_page integer default 1,
p_limit integer default 25
) returns jsonb
language sql stable security invoker set search_path = '' as $$
with completed as (
select s.id, s.owner_id, sum((r.result->>'score')::integer) as score
from public.game_sessions s
join public.game_rounds r on r.session_id = s.id
join public.revisions v on v.id = s.revision_id
join public.datasets d on d.id = v.dataset_id
where s.current_round = 5 and s.owner_id is not null and d.imported_from_id is null
and (p_difficulty = 'all' or s.difficulty = p_difficulty)
group by s.id, s.owner_id
having count(*) = 5 and count(r.result) = 5
), players as (
select p.id as player_id, p.display_name,
max(c.score) as best_score, avg(c.score) as average_score,
count(*) as games_played
from completed c join public.profiles p on p.id = c.owner_id
group by p.id, p.display_name
), ranked as (
select row_number() over (
order by best_score desc, average_score desc, games_played desc, player_id
) as rank, player_id, display_name, best_score,
round(average_score)::integer as average_score, games_played
from players
), matched as (
select * from ranked
where strpos(lower(display_name), lower(left(trim(coalesce(p_search, '')), 64))) > 0
), settings as (
select greatest(1, least(coalesce(p_limit, 25), 100)) as page_size
), counts as (
select (select count(*) from matched) as matching_players,
greatest(1, ceil((select count(*) from matched)::numeric / settings.page_size)::integer) as pages
from settings
), pagination as (
select *, greatest(1, least(coalesce(p_page, 1), pages)) as page
from counts cross join settings
), entries as (
select * from matched order by rank
limit (select page_size from pagination)
offset (select (page - 1) * page_size from pagination)
)
select jsonb_build_object(
'entries', coalesce((select jsonb_agg(to_jsonb(e) order by rank) from entries e), '[]'::jsonb),
'leaders', coalesce((select jsonb_agg(to_jsonb(r) order by rank) from
(select * from ranked order by rank limit 3) r), '[]'::jsonb),
'total_players', (select count(*) from ranked),
'total_games', (select count(*) from completed),
'matching_players', matching_players, 'page', page, 'pages', pages
) from pagination;
$$;
revoke all on function public.player_leaderboard(text, text, integer, integer) from public, anon, authenticated;
grant execute on function public.player_leaderboard(text, text, integer, integer) to service_role;

View File

@@ -74,6 +74,7 @@ test("authentication redirects accept supported local routes and reject open red
])
assert.equal(authDestination(next), "/dashboard");
assert.equal(authDestination("/settings"), "/settings");
assert.equal(authDestination("/servers"), "/servers");
assert.equal(authDestination("/pair?code=1234ABCD"), "/pair?code=1234ABCD");
assert.equal(authDestination("/auth/reset-password"), "/auth/reset-password");
});

View File

@@ -0,0 +1,228 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createHash, randomUUID } from "node:crypto";
import { createServer } from "node:http";
import sharp from "sharp";
import { createClient } from "@supabase/supabase-js";
import { datasetSchema, faces } from "../shared/protocol";
import type { Database } from "../shared/database";
import { processFabricImages } from "../lib/fabric-images";
import { processFabricJob } from "../lib/fabric-jobs";
async function fixture() {
const map = await sharp({
create: { width: 32, height: 32, channels: 3, background: "#349d66" },
})
.png()
.toBuffer();
const face = await sharp({
create: { width: 256, height: 256, channels: 3, background: "#548fda" },
})
.png()
.toBuffer();
const viewpoints = Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
x: i,
y: 64,
z: i,
}));
const files = new Map<string, Buffer>([
["map.png", map],
...viewpoints.flatMap((p) =>
faces.map((f) => [`${p.id}/${f}.png`, face] as [string, Buffer]),
),
]);
const manifest = datasetSchema.parse({
format: "minegeo-dataset/v1",
minecraftVersion: "26.2",
dimension: "minecraft:overworld",
source: "fabric",
name: "Image worker fixture",
serverName: "Local",
bounds: { minX: 0, minZ: 0, maxX: 32, maxZ: 32 },
north: "-z",
viewpoints,
assets: [...files].map(([path, bytes]) => ({
path,
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
})),
});
return { files, manifest };
}
test("mod images become a lossless map, six WebP faces per location, and previews", async () => {
const { manifest, files } = await fixture();
const images = new Map<string, Buffer>();
const progress: number[] = [];
await processFabricImages(manifest, {
signal: new AbortController().signal,
async download(path) {
return files.get(path)!;
},
async save(path, bytes) {
images.set(path, bytes);
},
async progress(value) {
progress.push(value);
},
});
assert.equal(images.size, 36);
for (const point of manifest.viewpoints) {
for (const face of faces) {
const meta = await sharp(
images.get(`${point.id}/${face}.webp`),
).metadata();
assert.equal(meta.format, "webp");
assert.equal(meta.width, 256);
assert.equal(meta.height, 256);
}
assert.equal(
(await sharp(images.get(`${point.id}/preview.webp`)).metadata()).width,
512,
);
}
assert.deepEqual(
await sharp(images.get("map.webp")).raw().toBuffer(),
await sharp(files.get("map.png")).raw().toBuffer(),
);
assert.equal(progress.at(-1), 95);
assert.ok(progress.every((p, i) => i === 0 || p > progress[i - 1]));
});
test("map-only refresh processes only the captured map", async () => {
const { manifest, files } = await fixture();
const saved: string[] = [],
downloaded: string[] = [];
await processFabricImages(manifest, {
mapOnly: true,
signal: new AbortController().signal,
async download(path) {
downloaded.push(path);
return files.get(path)!;
},
async save(path) {
saved.push(path);
},
async progress() {},
});
assert.deepEqual(downloaded, ["map.png"]);
assert.deepEqual(saved, ["map.webp"]);
});
test("corrupt assets, non-square faces, and cancelled work cannot complete", async () => {
const { manifest, files } = await fixture();
const io = {
signal: new AbortController().signal,
async download(path: string) {
return files.get(path)!;
},
async save() {},
async progress() {},
};
await assert.rejects(
processFabricImages(manifest, {
...io,
async download() {
return Buffer.from("corrupt");
},
}),
/checksum\/size mismatch/,
);
const rectangular = await sharp({
create: { width: 512, height: 256, channels: 3, background: "white" },
})
.png()
.toBuffer();
files.set("p0/px.png", rectangular);
const asset = manifest.assets.find((a) => a.path === "p0/px.png")!;
asset.bytes = rectangular.length;
asset.sha256 = createHash("sha256").update(rectangular).digest("hex");
await assert.rejects(
processFabricImages(manifest, io),
/must be square PNGs/,
);
const controller = new AbortController();
controller.abort(new Error("Cancelled fixture"));
await assert.rejects(
processFabricImages(manifest, { ...io, signal: controller.signal }),
/Cancelled fixture/,
);
});
// Exercise the actual Supabase client against a local Storage/PostgREST service.
// No credentials, game clients, or real user data are involved.
test("Vercel worker claims the existing queue job, stores images, and completes through the existing RPC", async (t) => {
const { files, manifest } = await fixture();
const owner = randomUUID(),
datasetId = randomUUID();
let job: Record<string, any> = {
id: randomUUID(),
dataset_id: datasetId,
source_dataset_id: null,
kind: "upload",
state: "queued",
attempts: 0,
progress: 0,
lease_until: null,
};
let completed = 0;
const saved = new Map<string, Buffer>();
const server = createServer(async (req, res) => {
const url = new URL(req.url!, "http://localhost"),
chunks = [];
for await (const chunk of req) chunks.push(chunk);
const bytes = Buffer.concat(chunks);
function json(data: unknown) {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data));
}
if (url.pathname === "/rest/v1/upload_jobs") {
if (req.method === "PATCH") {
const body = JSON.parse(bytes.toString());
assert.equal(url.searchParams.get("id"), `eq.${job.id}`);
if (body.lease_owner)
assert.equal(url.searchParams.get("state"), "eq.queued");
job = { ...job, ...body };
}
return json([job]);
}
if (url.pathname === "/rest/v1/datasets")
return json({ owner_id: owner, source: "fabric" });
if (url.pathname === "/rest/v1/dataset_inputs") return json({ manifest });
if (url.pathname === "/rest/v1/rpc/complete_job") {
const body = JSON.parse(bytes.toString());
assert.equal(body.p_job, job.id);
assert.equal(body.p_worker, job.lease_owner);
assert.equal(body.p_count, 5);
assert.equal(body.p_cover, "p0");
assert.equal(saved.size, 36);
completed++;
job.state = "complete";
return json(null);
}
const raw = `/storage/v1/object/minegeo-raw/${owner}/${datasetId}/`;
if (url.pathname.startsWith(raw))
return res.end(files.get(url.pathname.slice(raw.length)));
const media = `/storage/v1/object/minegeo-media/${datasetId}/`;
if (url.pathname.startsWith(media)) {
assert.equal(req.headers["x-upsert"], "true");
assert.equal((await sharp(bytes).metadata()).format, "webp");
saved.set(url.pathname.slice(media.length), bytes);
return json({ Key: url.pathname, Id: randomUUID() });
}
res.statusCode = 404;
json({ error: url.pathname });
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise<void>((resolve) => server.close(() => resolve())));
const client = createClient<Database>(
`http://127.0.0.1:${(server.address() as any).port}`,
"local-fixture-only",
{ auth: { persistSession: false, autoRefreshToken: false } },
);
assert.equal(await processFabricJob(client, datasetId), "complete");
assert.equal(job.state, "complete");
assert.equal(job.attempts, 1);
assert.equal(completed, 1);
});

27
tests/map-refresh.test.ts Normal file
View File

@@ -0,0 +1,27 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { refreshCapture } from "../shared/map-refresh";
const original = {
id: "original",
name: "My Minecraft map",
created_at: "2026-10-07T09:51:39Z",
};
const contribution = {
id: "accepted",
name: "My Minecraft map",
created_at: "2026-10-07T10:28:01Z",
};
test("refresh chooses the newest eligible capture, including newly accepted contributions with the same name", () => {
assert.equal(refreshCapture([original]), "original");
assert.equal(refreshCapture([original, contribution]), "accepted");
assert.equal(refreshCapture([contribution, original]), "accepted");
});
test("a deliberately selected older capture stays selected when the list reloads", () => {
assert.equal(
refreshCapture([contribution, original], "original"),
"original",
);
assert.equal(refreshCapture([contribution], "original"), "accepted");
assert.equal(refreshCapture([]), "");
});

170
tests/map-sharing.test.ts Normal file
View File

@@ -0,0 +1,170 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { randomUUID } from "node:crypto";
import sharp from "sharp";
import { zipSync, strToU8 } from "fflate";
import {
archiveSchema,
importManifest,
readMapArchive,
sha256,
archiveManifestName,
} from "../shared/map-archive";
import { datasetSchema, faces, parseUploadManifest } from "../shared/protocol";
import {
archiveSignature,
verifyArchiveSignature,
} from "../lib/map-archive-signature";
import { processFabricImages } from "../lib/fabric-images";
async function fixture(source: "fabric" | "plugin" = "plugin") {
const bytes = await sharp({
create: { width: 256, height: 256, channels: 3, background: "#78ab23" },
})
.webp()
.toBuffer();
const viewpoints = Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
x: i,
y: 70,
z: i,
}));
const files: Record<string, Uint8Array> = { "map.webp": bytes };
for (const p of viewpoints)
for (const f of faces) files[`${p.id}/${f}.webp`] = bytes;
const archive = archiveSchema.parse({
format: "minegeo-map/v1",
name: "Shared world",
description: "Map for friends",
serverName: "Test server",
source,
dimension: "minecraft:overworld",
north: "-z",
minecraftVersion: "26.2",
bounds: { minX: 0, minZ: 0, maxX: 32, maxZ: 32 },
viewpoints,
originalMapId: randomUUID(),
originalOwner: "Original explorer",
signature: "0".repeat(64),
assets: await Promise.all(
Object.entries(files).map(async ([path, content]) => ({
path,
bytes: content.length,
sha256: await sha256(content),
})),
),
});
archive.signature = archiveSignature(archive, "test-key");
files[archiveManifestName] = strToU8(JSON.stringify(archive));
return { archive, files };
}
test("ZIP roundtrip preserves source, six faces, coordinates, and signed original owner", async () => {
for (const source of ["plugin", "fabric"] as const) {
const { archive, files } = await fixture(source);
for (const level of [0, 6] as const) {
const restored = await readMapArchive(zipSync(files, { level }));
assert.deepEqual(restored.archive, archive);
const manifest = importManifest(restored.archive);
assert.equal(manifest.source, source);
assert.equal(manifest.mediaFormat, "webp");
assert.equal(manifest.assets.length, 31);
assert.equal(manifest.contributionTo, undefined);
assert.equal(manifest.mapPath, undefined);
assert.equal(manifest.serverAddress, undefined);
verifyArchiveSignature(restored.archive, "test-key");
assert.throws(() => parseUploadManifest(manifest), /Use map import/);
}
}
});
test("ZIP import rejects corruption, missing faces, unsafe files, and false ownership", async () => {
const { archive, files } = await fixture();
await assert.rejects(
readMapArchive(zipSync({ ...files, "p0/px.webp": strToU8("corrupt") })),
/checksum or size/,
);
const missing = { ...files };
delete missing["p0/px.webp"];
await assert.rejects(
readMapArchive(zipSync(missing)),
/unexpected files|checksum/,
);
await assert.rejects(
readMapArchive(zipSync({ ...files, "../secret": strToU8("file") })),
/unexpected/,
);
await assert.rejects(readMapArchive(strToU8("not a zip")), /ZIP/);
assert.throws(
() =>
verifyArchiveSignature(
{ ...archive, originalOwner: "Impersonator" },
"test-key",
),
/ownership/,
);
assert.throws(
() =>
verifyArchiveSignature({ ...archive, name: "Changed world" }, "test-key"),
/ownership/,
);
assert.throws(() =>
importManifest({ ...archive, contributionTo: randomUUID() }),
);
assert.throws(() =>
importManifest({
...archive,
viewpoints: archive.viewpoints.map((p) => ({
...p,
assetRoot: randomUUID(),
})),
}),
);
});
test("ZIP extraction rejects oversized entries before inflating them", async () => {
const { files } = await fixture();
const zip = zipSync(files, { level: 0 });
// The first local header advertises an enormous uncompressed map.
new DataView(zip.buffer).setUint32(22, 60 * 1024 * 1024, true);
await assert.rejects(readMapArchive(zip), /oversized/);
});
test("rendered server map imports are validated and processed without the terrain renderer", async () => {
const { archive, files } = await fixture();
const manifest = importManifest(archive);
const images = new Map<string, Buffer>();
await processFabricImages(manifest, {
signal: new AbortController().signal,
download: async (path) => Buffer.from(files[path]),
save: async (path, bytes) => {
images.set(path, bytes);
},
progress: async () => {},
});
assert.equal(images.size, 36);
assert.equal((await sharp(images.get("map.webp")).metadata()).format, "webp");
assert.equal(
(await sharp(images.get("p0/preview.webp")).metadata()).width,
512,
);
assert.equal(datasetSchema.parse(manifest).viewpoints.length, 5);
const corrupt = Buffer.from("not an image");
const bad = datasetSchema.parse({
...manifest,
assets: manifest.assets.map((a) =>
a.path === "map.webp"
? { ...a, bytes: corrupt.length, sha256: "0".repeat(64) }
: a,
),
});
await assert.rejects(
processFabricImages(bad, {
signal: new AbortController().signal,
download: async () => corrupt,
save: async () => {},
progress: async () => {},
}),
/checksum/,
);
});

38
tests/sql/map-sharing.sql Normal file
View File

@@ -0,0 +1,38 @@
begin;
do $$
declare owner uuid:=gen_random_uuid(); friend uuid:=gen_random_uuid(); other uuid:=gen_random_uuid(); original uuid; copy uuid; manifest jsonb; sid uuid; rid uuid; before_games integer;
begin
before_games:=(public.player_leaderboard()->>'total_games')::integer;
insert into auth.users(id) values(owner),(friend),(other);
insert into public.profiles(id) values(owner),(friend),(other) on conflict(id) do nothing;
manifest:='{"format":"minegeo-dataset/v1","source":"plugin","mediaFormat":"webp","name":"Shared fixture","description":"","serverName":"Shared fixture server","bounds":{"minX":0,"minZ":0,"maxX":64,"maxZ":64},"viewpoints":[{"id":"p0","x":0,"y":70,"z":0},{"id":"p1","x":1,"y":70,"z":1},{"id":"p2","x":2,"y":70,"z":2},{"id":"p3","x":3,"y":70,"z":3},{"id":"p4","x":4,"y":70,"z":4}],"assets":[]}';
original:=public.create_upload(owner,manifest);
update public.dataset_inputs set validated=true where dataset_id=original;
update public.datasets set state='draft',panorama_count=5 where id=original;
copy:=public.create_map_import(friend,manifest,original,'Original owner');
update public.dataset_inputs set validated=true where dataset_id=copy;
update public.datasets set state='draft',panorama_count=5 where id=copy;
sid:=public.create_game(copy,'normal',friend,'private-fixture');
if (select count(*) from public.game_rounds where session_id=sid)<>5 then raise exception 'Private game was not created'; end if;
rid:=public.create_map_room(copy,'easy','Friend',4,repeat('a',64),friend);
if (select count(*) from public.multiplayer_rounds where room_id=rid)<>5 then raise exception 'Private room was not created'; end if;
begin perform public.create_game(copy,'normal',other,'unauthorized'); raise exception 'Other account played private map';
exception when others then if sqlerrm<>'Map unavailable' then raise; end if; end;
begin perform public.create_game(copy,'normal',null,'guest'); raise exception 'Guest played private map';
exception when others then if sqlerrm<>'Map unavailable' then raise; end if; end;
begin perform public.create_map_room(copy,'easy','Other',4,repeat('b',64),other); raise exception 'Other account hosted private map';
exception when others then if sqlerrm<>'Map unavailable' then raise; end if; end;
begin perform public.submit_draft(copy,friend); raise exception 'Friend submitted imported map';
exception when others then if sqlerrm not like 'Owned by "Original owner".%' then raise; end if; end;
begin update public.datasets set state='published' where id=copy; raise exception 'Shared map published';
exception when others then if sqlerrm not like 'Owned by "Original owner".%' then raise; end if; end;
begin update public.datasets set imported_from_id=null,imported_owner_name=null where id=copy; raise exception 'Attribution removed';
exception when others then if sqlerrm<>'Shared map ownership cannot be changed' then raise; end if; end;
perform public.submit_draft(original,owner);
if (select state from public.datasets where id=original)<>'review' then raise exception 'Original owner cannot submit'; end if;
if (select state from public.datasets where id=copy)<>'draft' then raise exception 'Private copy changed'; end if;
update public.game_rounds set result='{"score":5000}' where session_id=sid;
update public.game_sessions set current_round=5 where id=sid;
if (public.player_leaderboard()->>'total_games')::integer<>before_games then raise exception 'Private scores reached public leaderboard'; end if;
end; $$;
rollback;

4
vercel.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"fluid": true
}

View File

@@ -5,6 +5,7 @@ import { mkdir, writeFile, readFile, readdir, rm } from "node:fs/promises";
import { gunzipSync } from "node:zlib";
import sharp from "sharp";
import { datasetSchema, faces, type TerrainSnapshot } from "../shared/protocol";
import { processFabricImages } from "../lib/fabric-images";
import { renderSnapshot } from "../renderer/render.mjs";
import type { Database } from "../shared/database";
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
@@ -109,7 +110,11 @@ async function processJob(job: {
(a) =>
!mapOnly ||
a.path ===
(manifest.source === "plugin" ? "terrain.json.gz" : "map.png"),
(manifest.mediaFormat
? "map.webp"
: manifest.source === "plugin"
? "terrain.json.gz"
: "map.png"),
);
await progress(0, `Downloading ${assets.length} export files`);
let downloaded = 0;
@@ -136,7 +141,22 @@ async function processJob(job: {
}
const output = `${directory}/media`;
await mkdir(output, { recursive: true });
if (manifest.source === "plugin") {
if (manifest.mediaFormat) {
await processFabricImages(manifest, {
mapOnly,
signal: abort.signal,
download: (path) => readFile(`${directory}/raw/${path}`),
async save(path, bytes) {
await mkdir(
`${output}/${path}`.slice(0, `${output}/${path}`.lastIndexOf("/")),
{ recursive: true },
);
await writeFile(`${output}/${path}`, bytes);
},
progress: (percent) =>
progress(20 + percent * 0.7, "Validating imported map images"),
});
} else if (manifest.source === "plugin") {
await progress(20, "Reading the terrain snapshot");
const compressed = await readFile(`${directory}/raw/terrain.json.gz`);
const terrain = JSON.parse(