200 lines
6.2 KiB
TypeScript
200 lines
6.2 KiB
TypeScript
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 };
|
|
}
|