Improve MineGeo exploration and mapping features
This commit is contained in:
69
shared/account.ts
Normal file
69
shared/account.ts
Normal 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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
41
shared/password-recovery.ts
Normal file
41
shared/password-recovery.ts
Normal 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"),
|
||||
);
|
||||
}
|
||||
@@ -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
32
shared/server-address.ts
Normal 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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user