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

212
lib/account-api.ts Normal file
View File

@@ -0,0 +1,212 @@
import "server-only";
import { createClient } from "@supabase/supabase-js";
import { NextRequest, NextResponse } from "next/server";
import { admin, authClient, required, user } from "./supabase";
import { accountProfile } from "./profile";
import {
authActionSchema,
authDestination,
passwordUpdateSchema,
profileUpdateSchema,
} from "@/shared/account";
import { createHash } from "node:crypto";
import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery";
import { requestOrigin } from "./request-origin";
type Context = {
jsonBody: (r: NextRequest) => Promise<unknown>;
limit: (key: string, count: number, seconds: number) => Promise<void>;
};
const json = (body: unknown, status = 200) =>
NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } });
export async function accountApi(
request: NextRequest,
path: string[],
context: Context,
) {
if (path[0] === "profile" && path.length === 1) {
const account = await user();
if (!account) return json({ error: "Log in to manage your account" }, 401);
if (request.method === "GET")
return json({
profile: await accountProfile(account),
email: account.email,
});
if (request.method === "PATCH") {
const body = profileUpdateSchema.parse(await context.jsonBody(request));
await accountProfile(account);
const profile = required(
await admin()
.from("profiles")
.update({ display_name: body.displayName })
.eq("id", account.id)
.select("id,display_name,role")
.single(),
);
return json({ profile });
}
return json({ error: "Method not allowed" }, 405);
}
if (path[0] !== "auth" || request.method !== "POST" || path.length > 2)
return json({ error: "Not found" }, 404);
const client = await authClient();
if (path.length === 1) {
const body = authActionSchema.parse(await context.jsonBody(request));
const identity = createHash("sha256")
.update(body.email.toLowerCase())
.digest("hex");
await context.limit(
`auth:${body.action}:${identity}`,
body.action === "login" ? 15 : 5,
600,
);
const origin = requestOrigin(request, process.env.MINEGEO_URL);
const next = authDestination("next" in body ? body.next : null);
const callback = new URL("/auth/callback", origin);
callback.searchParams.set(
"next",
body.action === "recover"
? "/auth/reset-password"
: next === "/auth/reset-password"
? "/dashboard"
: next,
);
if (body.action === "login") {
const { error } = await client.auth.signInWithPassword({
email: body.email,
password: body.password,
});
if (error)
return json(
{
error:
error.code === "email_not_confirmed"
? "Confirm your email address before logging in."
: "Email or password is incorrect. Try again or reset your password.",
},
401,
);
return json({ next });
}
if (body.action === "signup") {
const { data, error } = await client.auth.signUp({
email: body.email,
password: body.password,
options: {
data: { display_name: body.displayName },
emailRedirectTo: callback.toString(),
},
});
if (error) throw new Error(error.message);
if (data.session && data.user) {
await accountProfile(data.user);
return json({ next });
}
return json({
message:
"Check your email to confirm your account. If you already have an account, log in or reset your password.",
});
}
const { error } =
body.action === "recover"
? await client.auth.resetPasswordForEmail(body.email, {
redirectTo: callback.toString(),
})
: await client.auth.signInWithOtp({
email: body.email,
options: {
shouldCreateUser: false,
emailRedirectTo: callback.toString(),
},
});
if (
error &&
!["user_not_found", "signup_disabled"].includes(error.code ?? "")
)
throw new Error(error.message);
return json({
message:
body.action === "recover"
? "If an account exists for this email, a password reset link is on its way. Check your inbox."
: "If an account exists for this email, a sign-in link is on its way. Check your inbox.",
});
}
if (path[1] !== "password") return json({ error: "Not found" }, 404);
const account = await user();
if (!account?.email)
return json({ error: "Log in before changing your password" }, 401);
const body = passwordUpdateSchema.parse(await context.jsonBody(request));
await context.limit(`password:${account.id}`, 10, 600);
const { data: claimData, error: claimError } = await client.auth.getClaims();
const sessionId =
typeof claimData?.claims.session_id === "string"
? claimData.claims.session_id
: "";
if (claimError || !sessionId)
return json({ error: "Your session expired. Log in again." }, 401);
if (body.currentPassword) {
// Verify through Auth without replacing this browser's session.
const verifier = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
const { error } = await verifier.auth.signInWithPassword({
email: account.email,
password: body.currentPassword,
});
if (error)
return json(
{
error:
"Your current password is incorrect. Use an email reset link if you have not set a password yet.",
},
403,
);
await verifier.auth.signOut({ scope: "local" });
} else if (
!verifyRecoveryGrant(
request.cookies.get(recoveryCookie)?.value,
account.id,
sessionId,
process.env.SUPABASE_SECRET_KEY!,
)
) {
return json(
{
error:
"Enter your current password or open a fresh password reset link from your email.",
},
403,
);
}
const { error } = await client.auth.updateUser({
password: body.password,
...(body.currentPassword ? { current_password: body.currentPassword } : {}),
...(body.nonce ? { nonce: body.nonce } : {}),
});
if (error)
return json(
{
error: error.message,
needsReauthentication: error.code === "reauthentication_needed",
},
400,
);
const { error: signOutError } = await client.auth.signOut({
scope: "others",
});
const response = json({
ok: true,
message: signOutError
? "Password updated. Other sessions could not be signed out; please try signing out other devices again."
: "Password updated. Other devices have been signed out.",
});
response.cookies.set(recoveryCookie, "", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 0,
});
return response;
}

View File

@@ -3,10 +3,17 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { admin, required, user } from "./supabase";
import { datasetSchema, faces, difficulties } from "../shared/protocol";
import {
datasetSchema,
faces,
difficulties,
parseUploadManifest,
panoramaPath,
viewpointSchema,
} from "../shared/protocol";
import { mediaUrl } from "./catalog";
import { requestOrigin, verifyOrigin } from "./request-origin";
import { selectDraftLocations } from "../shared/draft-selection";
import { accountApi } from "./account-api";
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex");
const idSchema = z.string().uuid();
@@ -96,6 +103,8 @@ async function jsonBody(request: NextRequest) {
export async function handle(request: NextRequest, path: string[]) {
try {
verifyOrigin(request);
if (path[0] === "auth" || path[0] === "profile")
return await accountApi(request, path, { jsonBody, limit });
const client = admin(),
method = request.method;
if (path[0] === "health") {
@@ -183,7 +192,11 @@ export async function handle(request: NextRequest, path: string[]) {
faces: await Promise.all(
faces.map((f) =>
mediaUrl(
`${revision.asset_root}/${z.object({ id: z.string() }).parse(r.viewpoint).id}/${f}.webp`,
panoramaPath(
revision.asset_root,
viewpointSchema.parse(r.viewpoint),
`${f}.webp`,
),
),
),
),
@@ -231,7 +244,7 @@ export async function handle(request: NextRequest, path: string[]) {
const owner = await contributor(request);
if (method === "POST" && path.length === 1) {
await limit("upload:" + owner, 10, 3600);
const manifest = datasetSchema.parse(
const manifest = parseUploadManifest(
(await jsonBody(request)).manifest,
);
if (
@@ -239,47 +252,15 @@ export async function handle(request: NextRequest, path: string[]) {
Number(process.env.MINEGEO_MAX_UPLOAD_BYTES || 268435456)
)
throw new Error("Dataset exceeds upload limit");
const { data: existing } = await client
.from("catalog_servers")
.select("id")
.eq("name", manifest.serverName)
.limit(1)
.maybeSingle();
const serverId =
existing?.id ??
required(
await client
.from("catalog_servers")
.insert({
name: manifest.serverName,
address: manifest.serverAddress ?? null,
})
.select("id")
.single(),
).id;
const dataset = required(
await client
.from("datasets")
.insert({
owner_id: owner,
server_id: serverId,
name: manifest.name,
description: manifest.description,
source: manifest.source,
bounds: manifest.bounds,
})
.select("id")
.single(),
const datasetId = required(
await client.rpc("create_upload", {
p_owner: owner,
p_manifest: manifest,
}),
);
required(
await client
.from("dataset_inputs")
.insert({ dataset_id: dataset.id, manifest })
.select("dataset_id")
.single(),
);
return NextResponse.json({ datasetId: dataset.id });
return NextResponse.json({ datasetId });
}
const body = z
.object({ datasetId: idSchema })
.parse(await jsonBody(request));
@@ -287,6 +268,11 @@ export async function handle(request: NextRequest, path: string[]) {
if (method === "POST" && path[1] === "sign") {
if (d.state !== "uploading")
throw new Error("Dataset no longer accepts uploads");
const serverCheck = await client.rpc("check_contribution_server", {
p_dataset: d.id,
p_owner: owner,
});
if (serverCheck.error) throw new Error(serverCheck.error.message);
const input = required(
await client
.from("dataset_inputs")
@@ -389,88 +375,89 @@ export async function handle(request: NextRequest, path: string[]) {
return NextResponse.json({
...d,
upload_jobs: jobResult.data,
name: manifest.name,
description: manifest.description,
panorama_count: manifest.viewpoints.length,
manifest,
map: await mediaUrl(d.map_path),
previews: await Promise.all(
(manifest.availableViewpoints ?? manifest.viewpoints).map(
async (p: { id: string }) => ({
async (p, index) => ({
id: p.id,
label: `Location ${index + 1} · ${p.source ?? manifest.source}`,
image: d.map_path
? await mediaUrl(`${d.id}/${p.id}/preview.webp`)
? await mediaUrl(panoramaPath(d.id, p, "preview.webp"))
: null,
}),
),
),
});
}
if (method === "PATCH" && path[2] === "settings") {
const body = z
.object({
allowContributions: z.boolean().optional(),
contributionServerAddress: z
.string()
.max(255)
.nullable()
.optional(),
})
.refine(
(b) => Object.keys(b).length === 1,
"Change one setting at a time",
)
.parse(await jsonBody(request));
const { error } =
body.allowContributions !== undefined
? await client.rpc("set_contributions", {
p_dataset: d.id,
p_owner: d.owner_id,
p_allowed: body.allowContributions,
})
: await client.rpc("set_contribution_server", {
p_dataset: d.id,
p_owner: d.owner_id,
p_address: body.contributionServerAddress ?? null,
});
if (error) throw new Error(error.message);
return NextResponse.json({ ok: true });
}
if (method === "PATCH") {
if (!["draft", "rejected"].includes(d.state))
throw new Error("Only private drafts can be edited");
const body = z
.object({
name: z.string().min(3).max(80),
description: z.string().max(2000),
selectedIds: z.array(z.string()).min(5).optional(),
selectedIds: z.array(z.string()).min(1).max(24),
})
.parse(await jsonBody(request));
const input = required(
await client
.from("dataset_inputs")
.select("manifest")
.eq("dataset_id", d.id)
.single(),
);
let manifest = {
...datasetSchema.parse(input.manifest),
name: body.name,
description: body.description,
};
if (body.selectedIds)
manifest = selectDraftLocations(manifest, body.selectedIds);
manifest = datasetSchema.parse(manifest);
required(
await client
.from("dataset_inputs")
.update({ manifest })
.eq("dataset_id", d.id)
.select("dataset_id")
.single(),
);
required(
await client
.from("datasets")
.update({
name: body.name,
description: body.description,
panorama_count: manifest.viewpoints.length,
cover_path: `${d.id}/${manifest.viewpoints[0].id}/preview.webp`,
state: "draft",
rejection_reason: null,
updated_at: new Date().toISOString(),
})
.eq("id", d.id)
.select("id")
.single(),
);
const { error } = await client.rpc("save_draft", {
p_dataset: d.id,
p_owner: d.owner_id,
p_name: body.name,
p_description: body.description,
p_ids: body.selectedIds,
});
if (error) throw new Error(error.message);
return NextResponse.json({ ok: true });
}
if (method === "POST" && path[2] === "submit") {
if (!["draft", "rejected"].includes(d.state) || d.panorama_count < 5)
throw new Error(
"Need a processed draft with at least five panoramas",
);
required(
await client
.from("datasets")
.update({ state: "review" })
.eq("id", d.id)
.in("state", ["draft", "rejected"])
.select("id")
.single(),
);
const { error } = await client.rpc("submit_draft", {
p_dataset: d.id,
p_owner: d.owner_id,
});
if (error) throw new Error(error.message);
return NextResponse.json({ ok: true });
}
if (method === "POST" && path[2] === "cancel") {
if (d.parent_dataset_id) {
const { error } = await client.rpc("withdraw_contribution", {
p_dataset: d.id,
p_owner: d.owner_id,
});
if (error) throw new Error(error.message);
return NextResponse.json({ ok: true });
}
if (!["uploading", "processing"].includes(d.state))
throw new Error("This dataset is not uploading or processing");
const cancelled = await client
@@ -496,6 +483,98 @@ export async function handle(request: NextRequest, path: string[]) {
return NextResponse.json({ ok: true });
}
}
if (path[0] === "contributions") {
const owner = await contributor(request),
target = uuid(path[1]);
if (method === "GET" && path[2] === "server" && path.length === 3) {
return NextResponse.json(
required(
await client.rpc("contribution_server", {
p_dataset: target,
p_owner: owner,
}),
),
);
}
if (method === "GET" && path.length === 2) {
const info = required(
await client.rpc("contribution_capacity", {
p_dataset: target,
p_owner: owner,
}),
);
return NextResponse.json(info);
}
const parent = await ownedDataset(request, target);
if (parent.parent_dataset_id) throw new Error("Choose the original map");
if (method === "GET" && path[2] === "inbox" && path.length === 3) {
const rows = required(
await client
.from("datasets")
.select("*")
.eq("parent_dataset_id", target)
.order("created_at", { ascending: false }),
);
const profiles = rows.length
? required(
await client
.from("profiles")
.select("id,display_name")
.in("id", [...new Set(rows.map((c) => c.owner_id))]),
)
: [];
return NextResponse.json({
contributions: await Promise.all(
rows.map(async (c) => ({
...c,
contributorName:
profiles.find((p) => p.id === c.owner_id)?.display_name ??
"Explorer",
image: await mediaUrl(c.cover_path),
})),
),
});
}
const child = required(
await client
.from("datasets")
.select("*")
.eq("id", uuid(path[3]))
.eq("parent_dataset_id", target)
.single(),
);
if (method === "GET" && path[4] === "panoramas") {
const input = required(
await client
.from("dataset_inputs")
.select("manifest")
.eq("dataset_id", child.id)
.single(),
);
return panoramaResponse(
child.id,
datasetSchema.parse(input.manifest).viewpoints,
path[5],
);
}
if (method === "POST" && path[2] === "inbox") {
const body = z
.object({
decision: z.enum(["accept", "reject"]),
reason: z.string().max(2000).optional(),
})
.parse(await jsonBody(request));
const { error } = await client.rpc("review_contribution", {
p_dataset: target,
p_contribution: child.id,
p_owner: owner,
p_decision: body.decision,
p_reason: body.reason ?? undefined,
});
if (error) throw new Error(error.message);
return NextResponse.json({ ok: true });
}
}
if (path[0] === "tokens") {
const owner = await contributor(request);
if (method === "GET")
@@ -618,18 +697,26 @@ export async function handle(request: NextRequest, path: string[]) {
const d = required(
await client
.from("datasets")
.select("id,state")
.select("*")
.eq("id", uuid(path[1]))
.single(),
);
if (!["review", "published"].includes(d.state))
throw new Error("Map is not awaiting review or published");
if (d.parent_dataset_id)
throw new Error("Contributions require owner approval first");
const input = required(
await client
.from("dataset_inputs")
.select("manifest")
.eq("dataset_id", d.id)
.single(),
d.state === "published" && d.update_state !== "review"
? await client
.from("revisions")
.select("manifest")
.eq("id", d.revision_id!)
.single()
: await client
.from("dataset_inputs")
.select("manifest")
.eq("dataset_id", d.id)
.single(),
);
return panoramaResponse(
d.id,
@@ -642,6 +729,7 @@ export async function handle(request: NextRequest, path: string[]) {
await client
.from("datasets")
.select("*")
.is("parent_dataset_id", null)
.in("state", ["review", "published"])
.order("created_at"),
);
@@ -650,11 +738,29 @@ 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) => {
const input =
m.update_state === "review"
? required(
await client
.from("dataset_inputs")
.select("manifest")
.eq("dataset_id", m.id)
.single(),
)
: null;
const pending = input
? datasetSchema.parse(input.manifest)
: null;
return {
...m,
name: pending?.name ?? m.name,
description: pending?.description ?? m.description,
panorama_count: pending?.viewpoints.length ?? m.panorama_count,
image: await mediaUrl(m.cover_path),
map: await mediaUrl(m.map_path),
};
}),
),
reports,
});
@@ -734,17 +840,25 @@ export async function handle(request: NextRequest, path: string[]) {
async function panoramaResponse(
datasetId: string,
locations: { id: string }[],
locations: z.infer<typeof viewpointSchema>[],
pointId?: string,
) {
if (!pointId)
return NextResponse.json({ locations: locations.map((p) => p.id) });
if (!locations.some((p) => p.id === pointId))
throw new Error("Location not found");
return NextResponse.json({
locations: locations.map((p) => p.id),
labels: Object.fromEntries(
locations.map((p, index) => [
p.id,
`Location ${index + 1}${p.source ? ` · ${p.source}` : ""}`,
]),
),
});
const point = locations.find((p) => p.id === pointId);
if (!point) throw new Error("Location not found");
return NextResponse.json(
{
faces: await Promise.all(
faces.map((f) => mediaUrl(`${datasetId}/${pointId}/${f}.webp`)),
faces.map((f) => mediaUrl(panoramaPath(datasetId, point, `${f}.webp`))),
),
},
{ headers: { "Cache-Control": "no-store" } },

View File

@@ -6,6 +6,11 @@ export type MapListing = {
description: string;
source: "plugin" | "fabric";
state: string;
parent_dataset_id: string | null;
contribution_status: string | null;
allow_contributions: boolean;
contribution_server_address: string | null;
update_state: string | null;
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
panorama_count: number;
server_id: string;
@@ -37,9 +42,19 @@ export async function catalog() {
const servers = required(
await client.from("catalog_servers").select("*"),
) as { id: string; name: string; address: string | null }[];
const owners = maps.length
? required(
await client
.from("profiles")
.select("id,display_name")
.in("id", [...new Set(maps.map((m) => m.owner_id))]),
)
: [];
return Promise.all(
maps.map(async (m) => ({
...m,
credit:
owners.find((p) => p.id === m.owner_id)?.display_name ?? "Explorer",
image: (await mediaUrl(m.map_path || m.cover_path)) ?? undefined,
mapImage: (await mediaUrl(m.map_path)) ?? undefined,
server: servers.find((s) => s.id === m.server_id),
@@ -55,8 +70,14 @@ export async function getMap(id: string) {
.eq("state", "published")
.single(),
) as MapListing;
const owner = await admin()
.from("profiles")
.select("display_name")
.eq("id", map.owner_id)
.maybeSingle();
return {
...map,
credit: owner.data?.display_name ?? "Explorer",
image: await mediaUrl(map.cover_path),
mapImage: await mediaUrl(map.map_path),
};

6
lib/password-recovery.ts Normal file
View File

@@ -0,0 +1,6 @@
import "server-only";
export {
recoveryCookie,
createRecoveryGrant,
verifyRecoveryGrant,
} from "@/shared/password-recovery";

27
lib/profile.ts Normal file
View File

@@ -0,0 +1,27 @@
import "server-only";
import type { User } from "@supabase/supabase-js";
import { admin, required } from "./supabase";
import { displayNameSchema } from "@/shared/account";
export async function accountProfile(account: User) {
const name = displayNameSchema.safeParse(account.user_metadata?.display_name);
required(
await admin()
.from("profiles")
.upsert(
{
id: account.id,
display_name: name.success ? name.data : "Explorer",
},
{ onConflict: "id", ignoreDuplicates: true },
)
.select("id"),
);
return required(
await admin()
.from("profiles")
.select("id,display_name,role")
.eq("id", account.id)
.single(),
);
}

View File

@@ -2,6 +2,7 @@ import "server-only";
import { createClient } from "@supabase/supabase-js";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { cache } from "react";
import type { Database } from "@/shared/database";
let singleton: ReturnType<typeof createClient<Database>> | undefined;
export function admin() {
@@ -36,13 +37,18 @@ export async function authClient() {
},
);
}
export async function user() {
export const user = cache(async function user() {
if (
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
)
return null;
const client = await authClient();
const {
data: { user },
} = await client.auth.getUser();
return user;
}
});
export function required<T>(result: {
data: T;
error: { message: string } | null;