Add map import and export with durable refresh progress
This commit is contained in:
@@ -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[] }> },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
136
components/MapTransfer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
26
docs/map-sharing.md
Normal 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`.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
150
lib/api.ts
150
lib/api.ts
@@ -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
98
lib/fabric-images.ts
Normal 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
198
lib/fabric-jobs.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
23
lib/map-archive-signature.ts
Normal file
23
lib/map-archive-signature.ts
Normal 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
133
lib/map-transfer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
22
lib/process-uploads.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
259
scripts/test-map-sharing-browser.ts
Normal file
259
scripts/test-map-sharing-browser.ts
Normal 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.");
|
||||
}
|
||||
22
scripts/test-map-sharing-sql.mjs
Normal file
22
scripts/test-map-sharing-sql.mjs
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
199
shared/map-archive.ts
Normal 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
17
shared/map-refresh.ts
Normal 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 ?? ""
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export type ProcessingJob = {
|
||||
id?: string;
|
||||
source_dataset_id?: string | null;
|
||||
kind?: string;
|
||||
state: string;
|
||||
progress: number;
|
||||
|
||||
@@ -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 [
|
||||
|
||||
155
supabase/migrations/20261007145000_map_sharing.sql
Normal file
155
supabase/migrations/20261007145000_map_sharing.sql
Normal 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;
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
228
tests/fabric-processing.test.ts
Normal file
228
tests/fabric-processing.test.ts
Normal 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
27
tests/map-refresh.test.ts
Normal 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
170
tests/map-sharing.test.ts
Normal 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
38
tests/sql/map-sharing.sql
Normal 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
4
vercel.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"fluid": true
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user