Add map import and export with durable refresh progress
This commit is contained in:
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
|
||||
|
||||
Reference in New Issue
Block a user