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