Add map import and export with durable refresh progress

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

View File

@@ -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