Improve MineGeo exploration and mapping features

This commit is contained in:
2026-10-05 13:41:36 +05:00
parent aec0641f87
commit c5368fa8cc
69 changed files with 3581 additions and 333 deletions

69
shared/account.ts Normal file
View File

@@ -0,0 +1,69 @@
import { z } from "zod";
export const displayNameSchema = z
.string()
.trim()
.min(2, "Use at least 2 characters")
.max(32, "Use at most 32 characters")
.refine(
(value) => !/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/u.test(value),
"Use a single line without hidden control characters",
);
export const newPasswordSchema = z
.string()
.min(12, "Use at least 12 characters")
.refine(
(value) => new TextEncoder().encode(value).length <= 72,
"Use at most 72 UTF-8 bytes for your password",
);
const email = z.string().trim().email().max(254);
export const authActionSchema = z.discriminatedUnion("action", [
z
.object({
action: z.literal("login"),
email,
password: z.string().min(1).max(1024),
next: z.string().optional(),
})
.strict(),
z
.object({
action: z.literal("signup"),
email,
password: newPasswordSchema,
displayName: displayNameSchema,
next: z.string().optional(),
})
.strict(),
z.object({ action: z.literal("recover"), email }).strict(),
z
.object({ action: z.literal("magic"), email, next: z.string().optional() })
.strict(),
]);
export const profileUpdateSchema = z
.object({ displayName: displayNameSchema })
.strict();
export const passwordUpdateSchema = z
.object({
password: newPasswordSchema,
currentPassword: z.string().min(1).max(1024).optional(),
nonce: z
.string()
.trim()
.regex(/^[0-9]{6,10}$/)
.optional(),
})
.strict();
/** Only supported product destinations can follow an authentication link. */
export function authDestination(value: string | null | undefined): string {
if (
value === "/settings" ||
value === "/dashboard" ||
value === "/auth/reset-password"
)
return value;
if (value && /^\/dashboard\/[0-9a-f-]{36}$/i.test(value)) return value;
if (value && /^\/pair\?code=[A-F0-9]{8}$/.test(value)) return value;
return "/dashboard";
}

View File

@@ -90,7 +90,11 @@ export type Database = {
};
datasets: {
Row: {
allow_contributions: boolean;
bounds: Json;
contribution_count: number;
contribution_server_address: string | null;
contribution_status: string | null;
cover_path: string | null;
created_at: string;
description: string;
@@ -99,15 +103,21 @@ export type Database = {
name: string;
owner_id: string;
panorama_count: number;
parent_dataset_id: string | null;
rejection_reason: string | null;
revision_id: string | null;
server_id: string | null;
source: string;
state: string;
update_state: string | null;
updated_at: string;
};
Insert: {
allow_contributions?: boolean;
bounds: Json;
contribution_count?: number;
contribution_server_address?: string | null;
contribution_status?: string | null;
cover_path?: string | null;
created_at?: string;
description?: string;
@@ -116,15 +126,21 @@ export type Database = {
name: string;
owner_id: string;
panorama_count?: number;
parent_dataset_id?: string | null;
rejection_reason?: string | null;
revision_id?: string | null;
server_id?: string | null;
source: string;
state?: string;
update_state?: string | null;
updated_at?: string;
};
Update: {
allow_contributions?: boolean;
bounds?: Json;
contribution_count?: number;
contribution_server_address?: string | null;
contribution_status?: string | null;
cover_path?: string | null;
created_at?: string;
description?: string;
@@ -133,14 +149,23 @@ export type Database = {
name?: string;
owner_id?: string;
panorama_count?: number;
parent_dataset_id?: string | null;
rejection_reason?: string | null;
revision_id?: string | null;
server_id?: string | null;
source?: string;
state?: string;
update_state?: string | null;
updated_at?: string;
};
Relationships: [
{
foreignKeyName: "datasets_parent_dataset_id_fkey";
columns: ["parent_dataset_id"];
isOneToOne: false;
referencedRelation: "datasets";
referencedColumns: ["id"];
},
{
foreignKeyName: "datasets_revision_fk";
columns: ["revision_id"];
@@ -450,31 +475,26 @@ export type Database = {
[_ in never]: never;
};
Functions: {
finalize_upload: {
Args: { p_dataset: string; p_owner: string };
activate_round: {
Args: { p_access_hash: string; p_round: number; p_session: string };
Returns: string;
};
complete_job: {
Args: {
p_job: string;
p_worker: string;
p_count: number;
p_cover: string;
};
Returns: undefined;
};
approve_pairing: {
Args: {
p_code: string;
p_hash: string;
p_owner: string;
p_token: string;
p_hash: string;
};
Returns: undefined;
};
activate_round: {
Args: { p_access_hash: string; p_round: number; p_session: string };
Returns: string;
assert_capture_server: {
Args: { p_actual: string; p_expected: string };
Returns: undefined;
};
check_contribution_server: {
Args: { p_dataset: string; p_owner: string };
Returns: undefined;
};
check_rate: {
Args: { p_key: string; p_limit: number; p_seconds: number };
@@ -501,6 +521,23 @@ export type Database = {
isSetofReturn: true;
};
};
complete_job: {
Args: {
p_count: number;
p_cover: string;
p_job: string;
p_worker: string;
};
Returns: undefined;
};
contribution_capacity: {
Args: { p_dataset: string; p_owner: string };
Returns: Json;
};
contribution_server: {
Args: { p_dataset: string; p_owner: string };
Returns: Json;
};
create_game: {
Args: {
p_access_hash: string;
@@ -510,6 +547,18 @@ export type Database = {
};
Returns: string;
};
create_upload: {
Args: { p_manifest: Json; p_owner: string };
Returns: string;
};
finalize_upload: {
Args: { p_dataset: string; p_owner: string };
Returns: string;
};
normalize_server_address: {
Args: { p_address: string };
Returns: string;
};
publish_dataset: {
Args: {
p_dataset: string;
@@ -519,6 +568,38 @@ export type Database = {
};
Returns: undefined;
};
review_contribution: {
Args: {
p_contribution: string;
p_dataset: string;
p_decision: string;
p_owner: string;
p_reason?: string | null;
};
Returns: undefined;
};
save_draft: {
Args: {
p_dataset: string;
p_description: string;
p_ids: Json;
p_name: string;
p_owner: string;
};
Returns: undefined;
};
set_contribution_server: {
Args: { p_address: string | null; p_dataset: string; p_owner: string };
Returns: undefined;
};
set_contributions: {
Args: { p_allowed: boolean; p_dataset: string; p_owner: string };
Returns: undefined;
};
submit_draft: {
Args: { p_dataset: string; p_owner: string };
Returns: undefined;
};
submit_guess: {
Args: {
p_access_hash: string;
@@ -529,6 +610,10 @@ export type Database = {
};
Returns: Json;
};
withdraw_contribution: {
Args: { p_dataset: string; p_owner: string };
Returns: undefined;
};
};
Enums: {
[_ in never]: never;

View File

@@ -0,0 +1,41 @@
import { createHmac, timingSafeEqual } from "node:crypto";
export const recoveryCookie = "mg_password_recovery";
function signature(value: string, secret: string) {
return createHmac("sha256", secret)
.update("minegeo-password-recovery:" + value)
.digest("hex");
}
export function createRecoveryGrant(
userId: string,
sessionId: string,
secret: string,
now = Date.now(),
) {
const value = `${userId}:${sessionId}:${Math.floor(now / 1000) + 600}`;
return `${value}:${signature(value, secret)}`;
}
export function verifyRecoveryGrant(
token: string | undefined,
userId: string,
sessionId: string,
secret: string,
now = Date.now(),
) {
const parts = token?.split(":");
if (
!parts ||
parts.length !== 4 ||
parts[0] !== userId ||
parts[1] !== sessionId ||
!/^\d+$/.test(parts[2]) ||
!/^[0-9a-f]{64}$/.test(parts[3])
)
return false;
const expires = Number(parts[2]),
time = Math.floor(now / 1000);
if (expires <= time || expires > time + 600) return false;
return timingSafeEqual(
Buffer.from(parts[3], "hex"),
Buffer.from(signature(parts.slice(0, 3).join(":"), secret), "hex"),
);
}

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { normalizeServerAddress } from "./server-address";
export const boundsSchema = z
.object({
minX: z.number().int(),
@@ -20,6 +21,23 @@ export const pointSchema = z.object({
z: z.number().finite(),
});
export const faces = ["px", "nx", "py", "ny", "pz", "nz"] as const;
export const viewpointSchema = pointSchema.extend({
id: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/),
assetRoot: z.string().uuid().optional(),
assetId: z
.string()
.regex(/^[a-zA-Z0-9_-]{1,64}$/)
.optional(),
contributorId: z.string().uuid().optional(),
source: z.enum(["plugin", "fabric"]).optional(),
});
export function panoramaPath(
root: string,
point: z.infer<typeof viewpointSchema>,
file: string,
) {
return `${point.assetRoot ?? root}/${point.assetId ?? point.id}/${file}`;
}
export const datasetSchema = z
.object({
format: z.literal("minegeo-dataset/v1"),
@@ -32,20 +50,10 @@ export const datasetSchema = z
serverAddress: z.string().max(255).optional(),
bounds: boundsSchema,
north: z.literal("-z"),
viewpoints: z
.array(
pointSchema.extend({ id: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/) }),
)
.min(5)
.max(100),
contributionTo: z.string().uuid().optional(),
viewpoints: z.array(viewpointSchema).min(1).max(24),
// Retain the capture's locations while viewpoints holds the saved selection.
availableViewpoints: z
.array(
pointSchema.extend({ id: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/) }),
)
.min(5)
.max(100)
.optional(),
availableViewpoints: z.array(viewpointSchema).min(1).max(24).optional(),
assets: z
.array(
z.object({
@@ -64,15 +72,22 @@ export const datasetSchema = z
.max(4096),
})
.superRefine((d, ctx) => {
if (!d.contributionTo && d.viewpoints.length < 5)
ctx.addIssue({
code: "custom",
message: "Maps need at least five panoramas",
});
const paths = new Set(d.assets.map((asset) => asset.path));
const requiredPaths =
d.source === "plugin"
? ["terrain.json.gz"]
: [
"map.png",
...(d.availableViewpoints ?? d.viewpoints).flatMap((point) =>
faces.map((face) => `${point.id}/${face}.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))
@@ -108,6 +123,23 @@ export const datasetSchema = z
message: "Viewpoint outside map bounds",
});
});
// Media provenance is assigned by the server after the owner accepts a contribution.
export function parseUploadManifest(value: unknown) {
const manifest = datasetSchema.parse(value);
manifest.serverAddress =
normalizeServerAddress(manifest.serverAddress) ?? undefined;
for (const p of [
...manifest.viewpoints,
...(manifest.availableViewpoints ?? []),
])
if (p.assetRoot || p.assetId || p.contributorId || p.source)
throw new Error("Uploads cannot specify server media provenance");
if (manifest.availableViewpoints)
throw new Error(
"Upload all captured viewpoints; selection happens in the dashboard",
);
return manifest;
}
export type DatasetManifest = z.infer<typeof datasetSchema>;
export type Bounds = z.infer<typeof boundsSchema>;
export type Point = z.infer<typeof pointSchema>;

32
shared/server-address.ts Normal file
View File

@@ -0,0 +1,32 @@
/** Compare the address players enter in Minecraft; never resolve DNS aliases. */
export function normalizeServerAddress(
value: string | null | undefined,
): string | null {
const input = value?.trim().toLowerCase();
if (!input) return null;
if (input.length > 255) throw new Error("Server address is too long");
const match = /^(\[[0-9a-f:]+\]|[a-z0-9.-]+)(?::([0-9]{1,5}))?$/.exec(input);
if (!match)
throw new Error("Use a Minecraft hostname or IP, with an optional port");
let host = match[1];
if (host.startsWith("[")) {
try {
new URL(`http://${host}`);
} catch {
throw new Error("Invalid IPv6 address");
}
} else {
host = host.replace(/\.$/, "");
if (
host.length > 253 ||
host
.split(".")
.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
)
throw new Error("Invalid server hostname");
}
const port = match[2] ? Number(match[2]) : 25565;
if (port < 1 || port > 65535)
throw new Error("Server port must be between 1 and 65535");
return host + (port === 25565 ? "" : `:${port}`);
}