Improve MineGeo exploration and mapping features
This commit is contained in:
@@ -28,6 +28,10 @@ Missing vanilla assets are also prepared automatically when a plugin export is r
|
||||
|
||||
## Install the capture tools
|
||||
|
||||
Maps support up to 24 locations and games choose five at random. Owners can opt into player contributions, with a cumulative 12-panorama limit per player and owner approval followed by moderator review. See [panorama contributions](docs/panorama-contributions.md) for both tool workflows and deployment instructions.
|
||||
|
||||
Contributions check the server address recorded during capture. Owners can correct or clear the map's expected address. Fabric records the connected server automatically; plugin admins must set `server-address` or run `/minegeo server <hostname[:port]>` before exporting.
|
||||
|
||||
Ready-to-install JARs are committed under `public/tools/plugin` and `public/tools/mod` and served by `/downloads`. After pulling an update, replace the Minecraft server's installed JAR and restart that server; pulling the website alone does not update an already installed plugin.
|
||||
|
||||
- **Server:** Java 21 and Bukkit/Paper 1.21.11. Follow [the plugin guide](docs/server-plugin.md).
|
||||
@@ -43,6 +47,8 @@ The build copies both JARs to their Git-visible download folders. Include those
|
||||
|
||||
## Checks
|
||||
|
||||
Accounts support display names, email/password login and password recovery. See [account settings and Supabase configuration](docs/account-settings.md).
|
||||
|
||||
```sh
|
||||
npm test
|
||||
npm run test:renderer
|
||||
|
||||
286
app/account.css
Normal file
286
app/account.css
Normal file
@@ -0,0 +1,286 @@
|
||||
/* Shared account and authentication surfaces, layered after the Minecraft theme. */
|
||||
.auth-page {
|
||||
padding-top: 48px;
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
.auth-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: clamp(32px, 7vw, 100px);
|
||||
align-items: center;
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.auth-story h1 {
|
||||
font-size: clamp(42px, 4.4vw, 64px);
|
||||
line-height: 1.05;
|
||||
margin: 18px 0;
|
||||
}
|
||||
.auth-story > p {
|
||||
color: var(--muted);
|
||||
max-width: 420px;
|
||||
line-height: 1.8;
|
||||
font-size: 15px;
|
||||
}
|
||||
.auth-story-map {
|
||||
position: relative;
|
||||
height: 210px;
|
||||
border: 1px solid #4b5c43;
|
||||
margin: 30px 0 22px;
|
||||
padding: 26px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
color: #c0e7a2;
|
||||
background:
|
||||
linear-gradient(90deg, #101a1cd9, #11192388),
|
||||
url("/images/example-map.webp") center/cover;
|
||||
box-shadow: inset 0 0 70px #09121977;
|
||||
}
|
||||
.auth-story-map span {
|
||||
font:
|
||||
24px MineGeoPixel,
|
||||
monospace;
|
||||
}
|
||||
.auth-benefits {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.auth-benefits span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.auth-benefits svg {
|
||||
color: var(--green);
|
||||
}
|
||||
.auth-layout .auth-card {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 32px;
|
||||
background: #121c28f5;
|
||||
border: 1px solid #3a4958;
|
||||
box-shadow: 0 20px 60px #0003;
|
||||
}
|
||||
.auth-card h2 {
|
||||
font-size: 38px;
|
||||
line-height: 1.1;
|
||||
margin: 14px 0 10px;
|
||||
}
|
||||
.auth-card .field {
|
||||
font-size: 13px;
|
||||
margin: 18px 0;
|
||||
}
|
||||
.field small {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.auth-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
background: #0a121c;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--line);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.auth-tabs button {
|
||||
padding: 10px 8px;
|
||||
border: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-tabs button[aria-pressed="true"] {
|
||||
color: var(--text);
|
||||
background: #223342;
|
||||
box-shadow: 0 2px 7px #0004;
|
||||
}
|
||||
.auth-recovery {
|
||||
display: block;
|
||||
margin: -4px 0 22px auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.auth-card .text-link {
|
||||
cursor: pointer;
|
||||
color: var(--green);
|
||||
}
|
||||
.auth-alternative {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 20px;
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.auth-footnote {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin: 24px 0 0;
|
||||
}
|
||||
.auth-back {
|
||||
display: block;
|
||||
margin: 22px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.auth-card input,
|
||||
.settings-card input {
|
||||
min-height: 46px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.reset-card {
|
||||
max-width: 520px;
|
||||
padding: 32px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
.reset-card h1 {
|
||||
font-size: 46px;
|
||||
}
|
||||
.account-page-heading {
|
||||
margin: 28px 0 36px;
|
||||
}
|
||||
.account-page-heading h1 {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.settings-card {
|
||||
border: 1px solid #3a4958;
|
||||
background: #121c28e8;
|
||||
padding: 28px;
|
||||
box-shadow: 0 12px 30px #0002;
|
||||
min-width: 0;
|
||||
}
|
||||
.settings-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.settings-heading > svg {
|
||||
margin-top: 6px;
|
||||
color: var(--green);
|
||||
flex: none;
|
||||
}
|
||||
.settings-heading h2 {
|
||||
font-size: 32px;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.settings-heading p {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
.account-email {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0d1520;
|
||||
min-width: 0;
|
||||
}
|
||||
.account-email svg {
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.account-email div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.account-email span,
|
||||
.account-email small {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.account-email strong {
|
||||
font-weight: 400;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.settings-recovery {
|
||||
margin-top: 28px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.settings-recovery h3 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.settings-recovery p {
|
||||
font-size: 12px;
|
||||
}
|
||||
.map-credit {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.dashboard-toolbar .inline-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@media (max-width: 850px) {
|
||||
.auth-layout {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 520px;
|
||||
gap: 24px;
|
||||
}
|
||||
.auth-story-map,
|
||||
.auth-benefits {
|
||||
display: none;
|
||||
}
|
||||
.auth-story h1 {
|
||||
font-size: 42px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.auth-story > p {
|
||||
margin-bottom: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.auth-page {
|
||||
padding-top: 28px;
|
||||
}
|
||||
.auth-layout .auth-card,
|
||||
.settings-card,
|
||||
.reset-card {
|
||||
padding: 22px;
|
||||
}
|
||||
.auth-story h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
.auth-card h2 {
|
||||
font-size: 32px;
|
||||
}
|
||||
.dashboard-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
.dashboard-toolbar .inline-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.preview-toolbar {
|
||||
padding: 10px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authClient } from "@/lib/supabase";
|
||||
import { requestOrigin } from "@/lib/request-origin";
|
||||
import { authDestination } from "@/shared/account";
|
||||
import { createRecoveryGrant, recoveryCookie } from "@/lib/password-recovery";
|
||||
import { accountProfile } from "@/lib/profile";
|
||||
export async function GET(request: NextRequest) {
|
||||
const origin = requestOrigin(request, process.env.MINEGEO_URL);
|
||||
const code = request.nextUrl.searchParams.get("code");
|
||||
if (code) {
|
||||
const tokenHash = request.nextUrl.searchParams.get("token_hash");
|
||||
const type = request.nextUrl.searchParams.get("type");
|
||||
const next = authDestination(request.nextUrl.searchParams.get("next"));
|
||||
if (
|
||||
code ||
|
||||
(tokenHash &&
|
||||
(type === "email" || type === "recovery" || type === "magiclink"))
|
||||
) {
|
||||
const client = await authClient();
|
||||
const { error } = await client.auth.exchangeCodeForSession(code);
|
||||
if (!error) return NextResponse.redirect(new URL("/dashboard", origin));
|
||||
const { data, error } = code
|
||||
? await client.auth.exchangeCodeForSession(code)
|
||||
: await client.auth.verifyOtp({
|
||||
token_hash: tokenHash!,
|
||||
type: type as "email" | "recovery" | "magiclink",
|
||||
});
|
||||
if (!error && data.user) {
|
||||
await accountProfile(data.user);
|
||||
const destination = type === "recovery" ? "/auth/reset-password" : next;
|
||||
const response = NextResponse.redirect(new URL(destination, origin));
|
||||
if (destination === "/auth/reset-password") {
|
||||
const { data: claims } = await client.auth.getClaims();
|
||||
const sessionId = claims?.claims.session_id;
|
||||
if (typeof sessionId === "string")
|
||||
response.cookies.set(
|
||||
recoveryCookie,
|
||||
createRecoveryGrant(
|
||||
data.user.id,
|
||||
sessionId,
|
||||
process.env.SUPABASE_SECRET_KEY!,
|
||||
),
|
||||
{
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: new URL(origin).protocol === "https:",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
},
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
return NextResponse.redirect(new URL("/dashboard?auth=failed", origin));
|
||||
return NextResponse.redirect(new URL("/log-in?auth=failed", origin));
|
||||
}
|
||||
|
||||
1
app/auth/confirm/route.ts
Normal file
1
app/auth/confirm/route.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GET } from "../callback/route";
|
||||
56
app/auth/reset-password/page.tsx
Normal file
56
app/auth/reset-password/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { cookies } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { Header, Footer } from "@/components/Shell";
|
||||
import { PasswordForm } from "@/components/PasswordForm";
|
||||
import { authClient, user } from "@/lib/supabase";
|
||||
import { recoveryCookie, verifyRecoveryGrant } from "@/lib/password-recovery";
|
||||
export const metadata = { title: "Reset password" };
|
||||
export default async function ResetPassword() {
|
||||
const [account, jar, client] = await Promise.all([
|
||||
user(),
|
||||
cookies(),
|
||||
authClient(),
|
||||
]);
|
||||
const { data } = account ? await client.auth.getClaims() : { data: null };
|
||||
const sessionId = data?.claims.session_id;
|
||||
const valid =
|
||||
account &&
|
||||
typeof sessionId === "string" &&
|
||||
verifyRecoveryGrant(
|
||||
jar.get(recoveryCookie)?.value,
|
||||
account.id,
|
||||
sessionId,
|
||||
process.env.SUPABASE_SECRET_KEY!,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="section page-content">
|
||||
<section className="auth-card reset-card">
|
||||
<span className="eyebrow">ACCOUNT RECOVERY</span>
|
||||
<h1>A fresh start.</h1>
|
||||
{valid ? (
|
||||
<>
|
||||
<p className="muted">Choose a new password for your account.</p>
|
||||
<PasswordForm recovery />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
This reset link has expired or has already been used. Request a
|
||||
fresh link to continue.
|
||||
</p>
|
||||
<Link
|
||||
className="button primary"
|
||||
href={account ? "/settings" : "/log-in"}
|
||||
>
|
||||
Request a new reset link →
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Header, Footer } from "@/components/Shell";
|
||||
import { DraftEditor } from "@/components/DraftEditor";
|
||||
import { user } from "@/lib/supabase";
|
||||
import { redirect } from "next/navigation";
|
||||
export default async function Draft({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
if (!(await user()))
|
||||
redirect(`/log-in?next=${encodeURIComponent("/dashboard/" + id)}`);
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="section page-content">
|
||||
<span className="eyebrow">YOUR WORLD, BEFORE THE WORLD SEES IT</span>
|
||||
<h1>At the drawing board.</h1>
|
||||
<DraftEditor id={(await params).id} />
|
||||
<DraftEditor id={id} />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -3,9 +3,11 @@ import { SignIn } from "@/components/SignIn";
|
||||
import { Dashboard } from "@/components/Dashboard";
|
||||
import { admin, user, required } from "@/lib/supabase";
|
||||
import Link from "next/link";
|
||||
import { accountProfile } from "@/lib/profile";
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function DashboardPage() {
|
||||
const account = await user();
|
||||
const profile = account ? await accountProfile(account) : null;
|
||||
const history = account
|
||||
? required(
|
||||
await admin()
|
||||
@@ -24,6 +26,7 @@ export default async function DashboardPage() {
|
||||
<>
|
||||
<span className="eyebrow">THE CARTOGRAPHER’S DESK</span>
|
||||
<h1>My expedition.</h1>
|
||||
<p className="lead">Welcome back, {profile?.display_name}.</p>
|
||||
<Dashboard />
|
||||
{history.length > 0 && (
|
||||
<section className="token-box">
|
||||
|
||||
@@ -31,8 +31,12 @@ export default function Downloads() {
|
||||
<li>Minecraft Java 1.21.11 + Fabric Loader + Fabric API</li>
|
||||
<li>Open the capture panel with G</li>
|
||||
<li>Link your account through a browser pairing code</li>
|
||||
<li>Capture at least five viewpoints, then upload</li>
|
||||
<li>Capture 5–24 viewpoints for a new map, then upload</li>
|
||||
<li>
|
||||
Paste a map ID into “Add to map” to contribute 1–12 panoramas
|
||||
</li>
|
||||
<li>Only terrain received by your client enters the map</li>
|
||||
<li>Contributions check the recorded server address</li>
|
||||
</ul>
|
||||
{fabric ? (
|
||||
<a
|
||||
@@ -61,8 +65,12 @@ export default function Downloads() {
|
||||
<ul>
|
||||
<li>Bukkit/Paper 1.21.11 · Java 21</li>
|
||||
<li>Select a bounded area and export existing chunks</li>
|
||||
<li>Automatically choose safe surface viewpoints</li>
|
||||
<li>Choose up to 24 random, safe surface viewpoints</li>
|
||||
<li>
|
||||
Use /minegeo contribute <map ID> to join an existing map
|
||||
</li>
|
||||
<li>Link a contributor token and upload resumably</li>
|
||||
<li>Set the public Minecraft server address before exporting</li>
|
||||
<li>Vanilla blocks/fluids, simplified lighting</li>
|
||||
</ul>
|
||||
{plugin ? (
|
||||
@@ -78,7 +86,7 @@ export default function Downloads() {
|
||||
)}
|
||||
<pre>
|
||||
{
|
||||
"/minegeo website https://your-minegeo-site.example\n/minegeo link <dashboard token>\n/minegeo area world -64 -64 64 64\n/minegeo export My Overworld\n/minegeo status\n# Wait for Export complete, then:\n/minegeo upload"
|
||||
"/minegeo website https://your-minegeo-site.example\n/minegeo server play.your-server.example\n/minegeo link <dashboard token>\n/minegeo area world -64 -64 64 64\n/minegeo export My Overworld\n/minegeo status\n# Wait for Export complete, then:\n/minegeo upload"
|
||||
}
|
||||
</pre>
|
||||
<p className="muted">
|
||||
@@ -88,9 +96,10 @@ export default function Downloads() {
|
||||
completed export; it does not capture the world.
|
||||
</p>
|
||||
<p className="muted" style={{ fontSize: 11 }}>
|
||||
Put the JAR in plugins/ and restart. Set website and server-name
|
||||
in plugins/MineGeo/config.yml, then run /minegeo reload. HTTP LAN
|
||||
addresses are supported for testing. Permission: minegeo.admin.
|
||||
Put the JAR in plugins/ and restart. Set website, server-name and
|
||||
server-address in plugins/MineGeo/config.yml, then run /minegeo
|
||||
reload. HTTP LAN addresses are supported for testing. Permission:
|
||||
minegeo.admin.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
@@ -98,8 +107,9 @@ export default function Downloads() {
|
||||
<div>
|
||||
<h2>Your map, your credit.</h2>
|
||||
<p>
|
||||
Uploaders are credited separately. Plugin exports and player
|
||||
captures stay clearly labeled.
|
||||
Map owners can allow player contributions. Each player may add up
|
||||
to 12 panoramas per map. Owners approve the selection, then
|
||||
moderators review the map update before it becomes playable.
|
||||
</p>
|
||||
</div>
|
||||
<Link className="button" href="/dashboard">
|
||||
|
||||
167
app/globals.css
167
app/globals.css
@@ -1457,32 +1457,173 @@ pre {
|
||||
}
|
||||
/* Contributor and moderator previews use the same projection as the game. */
|
||||
.panorama-preview {
|
||||
width: min(1100px, 96vw);
|
||||
max-width: 96vw;
|
||||
padding: 16px;
|
||||
background: #0b1119;
|
||||
color: #edf2ef;
|
||||
border: 1px solid #58684b;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: min(1180px, calc(100vw - 64px));
|
||||
height: min(820px, calc(100dvh - 64px));
|
||||
max-width: none;
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid #455663;
|
||||
box-shadow: 0 30px 100px #0009;
|
||||
}
|
||||
.panorama-preview[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.panorama-preview.is-expanded {
|
||||
width: calc(100vw - 24px);
|
||||
height: calc(100dvh - 24px);
|
||||
max-height: none;
|
||||
}
|
||||
.panorama-preview::backdrop {
|
||||
background: #000b;
|
||||
background: #03070edb;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: none;
|
||||
}
|
||||
.preview-header h2 {
|
||||
font-size: 34px;
|
||||
line-height: 1;
|
||||
margin: 5px 0 0;
|
||||
}
|
||||
.preview-header .eyebrow {
|
||||
font-size: 10px;
|
||||
}
|
||||
.preview-header-actions,
|
||||
.preview-navigation {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: none;
|
||||
border: 1px solid #40505d;
|
||||
background: #1b2834;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.icon-button:hover {
|
||||
background: #2b3d4a;
|
||||
}
|
||||
.icon-button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
:where(button, a, input, textarea, select):focus-visible {
|
||||
outline: 2px solid var(--green);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.preview-location {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.preview-counter {
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
font: 12px monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.preview-footer {
|
||||
padding: 15px 24px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
justify-content: space-between;
|
||||
flex: none;
|
||||
}
|
||||
.preview-footer span:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.preview-viewport .panorama-controls {
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
}
|
||||
.preview-viewport .panorama-controls button {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: #0d1724e8;
|
||||
}
|
||||
.preview-viewport .panorama {
|
||||
cursor: grab;
|
||||
}
|
||||
.preview-viewport .panorama:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.preview-status {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.panorama-preview {
|
||||
width: calc(100vw - 20px);
|
||||
height: calc(100dvh - 20px);
|
||||
max-height: none;
|
||||
}
|
||||
.preview-header {
|
||||
padding: 16px;
|
||||
}
|
||||
.preview-header h2 {
|
||||
font-size: 28px;
|
||||
}
|
||||
.preview-footer {
|
||||
padding: 12px 16px;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.preview-location {
|
||||
flex: 1;
|
||||
}
|
||||
.preview-location > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.preview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
gap: 14px;
|
||||
padding: 12px 24px;
|
||||
flex: none;
|
||||
}
|
||||
.preview-toolbar select {
|
||||
background: #141e27;
|
||||
color: inherit;
|
||||
padding: 8px;
|
||||
color: var(--text);
|
||||
border: 1px solid #40505d;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
max-width: 300px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.preview-viewport {
|
||||
position: relative;
|
||||
height: min(65vh, 650px);
|
||||
flex: 1;
|
||||
min-height: 140px;
|
||||
overflow: hidden;
|
||||
background: #080f18;
|
||||
}
|
||||
.preview-status {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import "./block-theme.css";
|
||||
import "./account.css";
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "MineGeo — Get lost in a familiar world",
|
||||
|
||||
32
app/log-in/page.tsx
Normal file
32
app/log-in/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Header, Footer } from "@/components/Shell";
|
||||
import { SignIn } from "@/components/SignIn";
|
||||
import { user } from "@/lib/supabase";
|
||||
import { authDestination } from "@/shared/account";
|
||||
export const metadata = { title: "Log in" };
|
||||
export default async function LogIn({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ next?: string; auth?: string }>;
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const next = authDestination(query.next);
|
||||
if (await user())
|
||||
redirect(next === "/auth/reset-password" ? "/settings" : next);
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="section page-content auth-page">
|
||||
<SignIn
|
||||
next={next}
|
||||
initialError={
|
||||
query.auth === "failed"
|
||||
? "This sign-in link expired or could not be verified. Request a new link below."
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,13 @@ export default async function Pair({
|
||||
<p className="muted">
|
||||
Sign in first, then reopen the pairing link from Minecraft.
|
||||
</p>
|
||||
<SignIn />
|
||||
<SignIn
|
||||
next={
|
||||
code && /^[A-F0-9]{8}$/.test(code)
|
||||
? `/pair?code=${code}`
|
||||
: "/dashboard"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : code && /^[A-F0-9]{8}$/.test(code) ? (
|
||||
<Pairing code={code} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import Image from "next/image";
|
||||
import { Header, Footer } from "@/components/Shell";
|
||||
import { StartGame } from "@/components/StartGame";
|
||||
import { getMap } from "@/lib/catalog";
|
||||
import { ContributionInstructions } from "@/components/ContributionInstructions";
|
||||
export default async function ServerPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -25,6 +26,10 @@ export default async function ServerPage({
|
||||
</span>
|
||||
<h1>{map.name}</h1>
|
||||
<p className="lead">{map.description}</p>
|
||||
<p className="map-credit">Created by {map.credit}</p>
|
||||
<p className="muted">
|
||||
Map ID: <code>{map.id}</code>
|
||||
</p>
|
||||
<div className="detail-grid">
|
||||
<div>
|
||||
<div className="detail-map">
|
||||
@@ -47,6 +52,13 @@ export default async function ServerPage({
|
||||
</div>
|
||||
<StartGame datasetId={id} />
|
||||
</div>
|
||||
{map.allow_contributions && (
|
||||
<ContributionInstructions
|
||||
id={id}
|
||||
bounds={map.bounds}
|
||||
serverAddress={map.contribution_server_address}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
40
app/settings/page.tsx
Normal file
40
app/settings/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Header, Footer } from "@/components/Shell";
|
||||
import { AccountSettings } from "@/components/AccountSettings";
|
||||
import { user } from "@/lib/supabase";
|
||||
import { accountProfile } from "@/lib/profile";
|
||||
export const metadata = { title: "Account settings" };
|
||||
export default async function Settings({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ password?: string }>;
|
||||
}) {
|
||||
const account = await user();
|
||||
if (!account) redirect("/log-in?next=%2Fsettings");
|
||||
const [profile, query] = await Promise.all([
|
||||
accountProfile(account),
|
||||
searchParams,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="section page-content">
|
||||
<Link className="text-link" href="/dashboard">
|
||||
← My account
|
||||
</Link>
|
||||
<div className="account-page-heading">
|
||||
<span className="eyebrow">YOUR CARTOGRAPHER ACCOUNT</span>
|
||||
<h1>Account settings.</h1>
|
||||
<p className="lead">Your name, your sign-in, your next expedition.</p>
|
||||
</div>
|
||||
<AccountSettings
|
||||
email={account.email ?? ""}
|
||||
displayName={profile.display_name}
|
||||
passwordUpdated={query.password === "updated"}
|
||||
/>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,14 @@
|
||||
"shared schema validation",
|
||||
"TUS interruption and byte-perfect resume",
|
||||
"repeated upload is idempotent",
|
||||
"cancellation preserves completed export"
|
||||
"cancellation preserves completed export",
|
||||
"24 random positions change on the next export",
|
||||
"contribution command caps at 12 and retries idempotently",
|
||||
"wrong server blocked before upload, including cached retry",
|
||||
"owner override/default port and cleared address supported"
|
||||
],
|
||||
"blocks": 10112,
|
||||
"chunks": 16,
|
||||
"viewpoints": 5,
|
||||
"directory": ".cache/plugin-regression-1791117070908"
|
||||
"viewpoints": 24,
|
||||
"directory": ".cache/plugin-regression-1791186051478"
|
||||
}
|
||||
152
components/AccountSettings.tsx
Normal file
152
components/AccountSettings.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { UserRound, LockKeyhole, Mail } from "lucide-react";
|
||||
import { api } from "./StartGame";
|
||||
import { PasswordForm } from "./PasswordForm";
|
||||
import { displayNameSchema } from "@/shared/account";
|
||||
export function AccountSettings({
|
||||
email,
|
||||
displayName,
|
||||
passwordUpdated = false,
|
||||
}: {
|
||||
email: string;
|
||||
displayName: string;
|
||||
passwordUpdated?: boolean;
|
||||
}) {
|
||||
const [name, setName] = useState(displayName),
|
||||
[message, setMessage] = useState(""),
|
||||
[error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
const [recoveryMessage, setRecoveryMessage] = useState(""),
|
||||
[recoveryError, setRecoveryError] = useState(""),
|
||||
[sending, setSending] = useState(false);
|
||||
const router = useRouter();
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
const valid = displayNameSchema.safeParse(name);
|
||||
if (!valid.success) {
|
||||
setError(valid.error.issues[0].message);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await api<{ profile: { display_name: string } }>(
|
||||
"profile",
|
||||
{ displayName: valid.data },
|
||||
"PATCH",
|
||||
);
|
||||
setName(data.profile.display_name);
|
||||
setMessage("Display name saved.");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function recover() {
|
||||
setSending(true);
|
||||
setRecoveryError("");
|
||||
try {
|
||||
const data = await api<{ message: string }>("auth", {
|
||||
action: "recover",
|
||||
email,
|
||||
});
|
||||
setRecoveryMessage(data.message);
|
||||
} catch (e) {
|
||||
setRecoveryError((e as Error).message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section className="settings-card">
|
||||
<div className="settings-heading">
|
||||
<UserRound size={22} />
|
||||
<div>
|
||||
<h2>Your public profile.</h2>
|
||||
<p className="muted">A name for the maps and places you share.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={save}>
|
||||
<label className="field">
|
||||
Display name
|
||||
<input
|
||||
name="displayName"
|
||||
autoComplete="nickname"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
minLength={2}
|
||||
maxLength={32}
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
<small>
|
||||
2–32 characters. Display names do not have to be unique.
|
||||
</small>
|
||||
</label>
|
||||
<div className="account-email">
|
||||
<Mail size={17} />
|
||||
<div>
|
||||
<span>Email address</span>
|
||||
<strong>{email}</strong>
|
||||
<small>Used for sign-in and recovery. Only you can see it.</small>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p role="status" className="success-message">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
<button className="button primary" disabled={busy}>
|
||||
{busy ? "Saving…" : "Save display name"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section className="settings-card">
|
||||
<div className="settings-heading">
|
||||
<LockKeyhole size={22} />
|
||||
<div>
|
||||
<h2>Password & security.</h2>
|
||||
<p className="muted">Keep your account yours.</p>
|
||||
</div>
|
||||
</div>
|
||||
{passwordUpdated && (
|
||||
<p role="status" className="success-message">
|
||||
Your password has been updated.
|
||||
</p>
|
||||
)}
|
||||
<PasswordForm />
|
||||
<div className="settings-recovery">
|
||||
<h3>Signed up with an email link?</h3>
|
||||
<p className="muted">
|
||||
Set your first password, or recover a forgotten one, using a link
|
||||
sent to your account email.
|
||||
</p>
|
||||
<button className="button" disabled={sending} onClick={recover}>
|
||||
{sending ? "Sending…" : "Email me a password reset link"}
|
||||
</button>
|
||||
{recoveryMessage && (
|
||||
<p role="status" className="success-message">
|
||||
{recoveryMessage}
|
||||
</p>
|
||||
)}
|
||||
{recoveryError && (
|
||||
<p role="alert" className="error-message">
|
||||
{recoveryError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
components/Brand.tsx
Normal file
13
components/Brand.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function Brand() {
|
||||
return (
|
||||
<Link href="/" className="brand" aria-label="MineGeo home">
|
||||
<span className="brand-cube">
|
||||
<span />
|
||||
</span>
|
||||
Mine<span>Geo</span>
|
||||
<sup>BETA</sup>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
54
components/ContributionInstructions.tsx
Normal file
54
components/ContributionInstructions.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link";
|
||||
export function ContributionInstructions({
|
||||
id,
|
||||
bounds,
|
||||
serverAddress,
|
||||
}: {
|
||||
id: string;
|
||||
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
|
||||
serverAddress: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="token-box">
|
||||
<h3>Add panoramas to this map</h3>
|
||||
{serverAddress ? (
|
||||
<div>
|
||||
<p>Contribution server:</p>
|
||||
<code>{serverAddress}</code>
|
||||
<p>
|
||||
Capture on this server. Uploads from a different address are
|
||||
blocked.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">
|
||||
Server check is off. The owner has not set a server address; captures
|
||||
from other servers can be submitted. Only submit panoramas from this
|
||||
map’s world.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Capture in the same Minecraft world, within X {bounds.minX} to{" "}
|
||||
{bounds.maxX} and Z {bounds.minZ} to {bounds.maxZ}. Each player may add
|
||||
up to 12 panoramas in total; the map holds at most 24, including pending
|
||||
uploads.
|
||||
</p>
|
||||
<p>
|
||||
In the Fabric mod, paste this map ID into “Add to map”. For the server
|
||||
plugin, export the area and run:
|
||||
</p>
|
||||
<code>/minegeo contribute {id}</code>
|
||||
<p>
|
||||
Map ID: <code>{id}</code>
|
||||
</p>
|
||||
<p>
|
||||
Open your dashboard after processing, inspect your panoramas, and submit
|
||||
them to the owner. The owner approves them first, then moderators review
|
||||
the map update.
|
||||
</p>
|
||||
<Link className="button" href="/downloads">
|
||||
Get the capture tools →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -74,9 +74,14 @@ export function Dashboard() {
|
||||
<p className="muted">
|
||||
Your maps start privately. You decide when they’re ready for review.
|
||||
</p>
|
||||
<button className="button" onClick={logout}>
|
||||
Sign out
|
||||
</button>
|
||||
<div className="inline-actions">
|
||||
<Link className="button" href="/settings">
|
||||
Account settings
|
||||
</Link>
|
||||
<button className="button" onClick={logout}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
@@ -102,7 +107,13 @@ export function Dashboard() {
|
||||
{m.source === "plugin" ? "SERVER EXPORT" : "PLAYER CAPTURE"}
|
||||
</span>
|
||||
<h3>{m.name}</h3>
|
||||
<span className="badge">{m.state}</span>{" "}
|
||||
<span className="badge">
|
||||
{m.contribution_status
|
||||
? `Contribution: ${m.contribution_status}`
|
||||
: m.update_state
|
||||
? `Update: ${m.update_state}`
|
||||
: m.state}
|
||||
</span>{" "}
|
||||
<span className="muted">{m.panorama_count} locations</span>
|
||||
<ProcessingProgress job={m.upload_jobs} />
|
||||
{m.rejection_reason && (
|
||||
@@ -113,7 +124,7 @@ export function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
<Link href={"/dashboard/" + m.id} className="button">
|
||||
Open draft →
|
||||
{m.parent_dataset_id ? "Open contribution →" : "Open map →"}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DatasetManifest } from "@/shared/protocol";
|
||||
import type { ProcessingJob } from "@/shared/processing-progress";
|
||||
import { ProcessingProgress } from "./ProcessingProgress";
|
||||
import { PanoramaPreview } from "./PanoramaPreview";
|
||||
import { OwnerContributions } from "./OwnerContributions";
|
||||
type Draft = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -15,8 +16,13 @@ type Draft = {
|
||||
rejection_reason: string | null;
|
||||
map: string | null;
|
||||
manifest: DatasetManifest;
|
||||
previews: { id: string; image: string | null }[];
|
||||
previews: { id: string; label: string; image: string | null }[];
|
||||
upload_jobs: ProcessingJob | null;
|
||||
parent_dataset_id: string | null;
|
||||
contribution_status: string | null;
|
||||
allow_contributions: boolean;
|
||||
contribution_server_address: string | null;
|
||||
update_state: string | null;
|
||||
};
|
||||
export function DraftEditor({ id }: { id: string }) {
|
||||
const [draft, setDraft] = useState<Draft | null>(null),
|
||||
@@ -27,6 +33,7 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
[message, setMessage] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [version, setVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -53,7 +60,7 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [id]);
|
||||
}, [id, version]);
|
||||
async function save(submit = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
@@ -66,8 +73,12 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
);
|
||||
if (submit) {
|
||||
await api("datasets/" + id + "/submit", {});
|
||||
setDraft((d) => (d ? { ...d, state: "review" } : d));
|
||||
setMessage("Submitted for moderator review.");
|
||||
setVersion((v) => v + 1);
|
||||
setMessage(
|
||||
draft?.parent_dataset_id
|
||||
? "Submitted for map owner approval."
|
||||
: "Submitted for moderator review.",
|
||||
);
|
||||
} else setMessage("Draft saved.");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -79,6 +90,11 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api("datasets/" + id + "/cancel", {});
|
||||
if (draft?.parent_dataset_id) {
|
||||
setVersion((v) => v + 1);
|
||||
setMessage("Contribution withdrawn. Its pending quota is released.");
|
||||
return;
|
||||
}
|
||||
setDraft((d) =>
|
||||
d
|
||||
? { ...d, state: "failed", rejection_reason: "Processing cancelled" }
|
||||
@@ -95,10 +111,40 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
return (
|
||||
<p role={error ? "alert" : undefined}>{error || "Loading your draft…"}</p>
|
||||
);
|
||||
const editable = ["draft", "rejected"].includes(draft.state);
|
||||
const editable =
|
||||
(["draft", "rejected"].includes(draft.state) ||
|
||||
draft.state === "published") &&
|
||||
draft.update_state !== "review" &&
|
||||
!["submitted", "accepted", "rejected"].includes(
|
||||
draft.contribution_status ?? "",
|
||||
);
|
||||
const minimum = draft.parent_dataset_id ? 1 : 5;
|
||||
return (
|
||||
<>
|
||||
<span className="badge">{draft.state}</span>
|
||||
{draft.update_state && (
|
||||
<span className="badge">Update: {draft.update_state}</span>
|
||||
)}
|
||||
{draft.contribution_status && (
|
||||
<p role="status">
|
||||
Contribution: {draft.contribution_status}.{" "}
|
||||
{draft.state === "failed"
|
||||
? "This contribution no longer reserves panorama slots. Upload a fresh capture to try again."
|
||||
: draft.contribution_status === "accepted"
|
||||
? "The owner included your panoramas in the map draft. Map updates become playable after moderator approval."
|
||||
: draft.contribution_status === "submitted"
|
||||
? "Waiting for the map owner's approval."
|
||||
: draft.contribution_status === "rejected"
|
||||
? "Upload a fresh capture if you want to try again."
|
||||
: "Choose your locations, then submit them to the map owner."}
|
||||
</p>
|
||||
)}
|
||||
{draft.state === "published" && (
|
||||
<p className="muted">
|
||||
The approved version stays playable while you edit and moderators
|
||||
review your update.
|
||||
</p>
|
||||
)}
|
||||
<ProcessingProgress job={draft.upload_jobs} />
|
||||
{["uploading", "processing"].includes(draft.state) && (
|
||||
<p role="status" className="success-message">
|
||||
@@ -146,7 +192,8 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
</label>
|
||||
<h2>Choose your locations.</h2>
|
||||
<p className="muted">
|
||||
Keep at least five clear, useful panoramas. Selected: {selected.length}
|
||||
Keep at least {minimum} clear, useful panorama{minimum === 1 ? "" : "s"}
|
||||
. Selected: {selected.length} / 24
|
||||
</p>
|
||||
<div className="preview-grid">
|
||||
{draft.previews.map((p) => (
|
||||
@@ -156,7 +203,7 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
type="checkbox"
|
||||
checked={selected.includes(p.id)}
|
||||
disabled={!editable || busy}
|
||||
aria-label={`Keep location ${p.id}`}
|
||||
aria-label={`Keep ${p.label}`}
|
||||
onChange={(e) =>
|
||||
setSelected((s) =>
|
||||
e.target.checked
|
||||
@@ -170,11 +217,11 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
src={p.image}
|
||||
width={512}
|
||||
height={512}
|
||||
alt={`Viewpoint ${p.id}`}
|
||||
alt={p.label}
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
<span>{p.id}</span>
|
||||
<span>{p.label}</span>
|
||||
</label>
|
||||
{p.image && (
|
||||
<button className="button full" onClick={() => setPreview(p.id)}>
|
||||
@@ -195,23 +242,41 @@ export function DraftEditor({ id }: { id: string }) {
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy || selected.length < 5}
|
||||
disabled={busy || selected.length < minimum}
|
||||
onClick={() => save()}
|
||||
>
|
||||
Save draft
|
||||
</button>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy || selected.length < 5}
|
||||
disabled={busy || selected.length < minimum}
|
||||
onClick={() => save(true)}
|
||||
>
|
||||
Submit for review →
|
||||
{draft.parent_dataset_id
|
||||
? "Submit to map owner →"
|
||||
: "Submit for review →"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{["uploading", "processing"].includes(draft.state) && (
|
||||
{!draft.parent_dataset_id && (
|
||||
<OwnerContributions
|
||||
id={id}
|
||||
allowed={draft.allow_contributions}
|
||||
serverAddress={draft.contribution_server_address}
|
||||
bounds={draft.manifest.bounds}
|
||||
locked={draft.state === "review" || draft.update_state === "review"}
|
||||
onChange={() => setVersion((v) => v + 1)}
|
||||
/>
|
||||
)}
|
||||
{(["uploading", "processing"].includes(draft.state) ||
|
||||
(draft.parent_dataset_id &&
|
||||
["pending", "submitted"].includes(
|
||||
draft.contribution_status ?? "",
|
||||
))) && (
|
||||
<button className="button" disabled={busy} onClick={cancel}>
|
||||
Cancel processing
|
||||
{draft.parent_dataset_id
|
||||
? "Withdraw contribution"
|
||||
: "Cancel processing"}
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Move,
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import { Brand } from "./Shell";
|
||||
import { Brand } from "./Brand";
|
||||
import { Panorama, preloadFaces } from "./Panorama";
|
||||
import { GuessMap, type Pin } from "./GuessMap";
|
||||
import { api } from "./StartGame";
|
||||
|
||||
@@ -8,6 +8,7 @@ export type CardMap = {
|
||||
source: string;
|
||||
panorama_count: number;
|
||||
image?: string | null;
|
||||
credit?: string;
|
||||
server?: { name: string } | undefined;
|
||||
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
|
||||
};
|
||||
@@ -34,6 +35,7 @@ export function MapCard({ map, index = 0 }: { map: CardMap; index?: number }) {
|
||||
<ArrowUpRight size={20} />
|
||||
</h3>
|
||||
<p>{map.description}</p>
|
||||
{map.credit && <p className="map-credit">By {map.credit}</p>}
|
||||
<div className="card-meta">
|
||||
<span>
|
||||
<MapPin size={13} />
|
||||
|
||||
@@ -8,6 +8,7 @@ type ReviewMap = {
|
||||
name: string;
|
||||
description: string;
|
||||
state: string;
|
||||
update_state: string | null;
|
||||
source: string;
|
||||
panorama_count: number;
|
||||
map: string | null;
|
||||
@@ -82,6 +83,9 @@ export function Moderation() {
|
||||
{maps.map((m) => (
|
||||
<article className="tool-card" key={m.id}>
|
||||
<span className="badge">{m.state}</span>
|
||||
{m.update_state === "review" && (
|
||||
<span className="badge">Map update awaiting review</span>
|
||||
)}
|
||||
<h2>{m.name}</h2>
|
||||
<p className="muted">
|
||||
{m.source} · {m.panorama_count} locations · {m.description}
|
||||
@@ -109,14 +113,16 @@ export function Moderation() {
|
||||
<button className="button" onClick={() => setPreview(m.id)}>
|
||||
Inspect 360° locations
|
||||
</button>
|
||||
{m.state === "review" ? (
|
||||
{m.state === "review" || m.update_state === "review" ? (
|
||||
<>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy}
|
||||
onClick={() => decide(m.id, "approve")}
|
||||
>
|
||||
Approve map
|
||||
{m.update_state === "review"
|
||||
? "Approve update"
|
||||
: "Approve map"}
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
@@ -126,7 +132,8 @@ export function Moderation() {
|
||||
Return with feedback
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
) : null}
|
||||
{m.state === "published" && (
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
|
||||
236
components/OwnerContributions.tsx
Normal file
236
components/OwnerContributions.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "./StartGame";
|
||||
import { PanoramaPreview } from "./PanoramaPreview";
|
||||
import { ContributionInstructions } from "./ContributionInstructions";
|
||||
type Contribution = {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
contributorName: string;
|
||||
state: string;
|
||||
contribution_status: string;
|
||||
panorama_count: number;
|
||||
contribution_count: number;
|
||||
rejection_reason: string | null;
|
||||
};
|
||||
export function OwnerContributions({
|
||||
id,
|
||||
allowed,
|
||||
serverAddress,
|
||||
bounds,
|
||||
locked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
allowed: boolean;
|
||||
serverAddress: string | null;
|
||||
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
|
||||
locked: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<Contribution[]>([]),
|
||||
[address, setAddress] = useState(serverAddress ?? ""),
|
||||
[saved, setSaved] = useState(""),
|
||||
[error, setError] = useState(""),
|
||||
[busy, setBusy] = useState(false),
|
||||
[preview, setPreview] = useState<string | null>(null),
|
||||
[reasons, setReasons] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
setAddress(serverAddress ?? "");
|
||||
}, [serverAddress]);
|
||||
async function saveAddress(value: string) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSaved("");
|
||||
try {
|
||||
await api(
|
||||
`datasets/${id}/settings`,
|
||||
{ contributionServerAddress: value.trim() || null },
|
||||
"PATCH",
|
||||
);
|
||||
setAddress(value);
|
||||
setSaved(
|
||||
value.trim()
|
||||
? "Server address saved."
|
||||
: "Server check turned off. Submissions still need your approval.",
|
||||
);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await api<{ contributions: Contribution[] }>(
|
||||
`contributions/${id}/inbox`,
|
||||
);
|
||||
setItems(result.contributions);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = setInterval(load, 10000);
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
async function toggle(value: boolean) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api(
|
||||
`datasets/${id}/settings`,
|
||||
{ allowContributions: value },
|
||||
"PATCH",
|
||||
);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function decide(child: string, decision: string) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api(`contributions/${id}/inbox/${child}`, {
|
||||
decision,
|
||||
reason: reasons[child],
|
||||
});
|
||||
await load();
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="token-box">
|
||||
<h2>Panorama settings</h2>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowed}
|
||||
disabled={busy}
|
||||
onChange={(e) => toggle(e.target.checked)}
|
||||
/>{" "}
|
||||
Allow players to add panoramas with you
|
||||
</label>
|
||||
<p className="muted">
|
||||
Five random locations are picked for each game. Add up to 24 locations
|
||||
to make repeat games more varied. You can use the map ID below to add
|
||||
your own panoramas too.
|
||||
</p>
|
||||
<label className="field">
|
||||
Contribution server address
|
||||
<input
|
||||
value={address}
|
||||
placeholder="play.example.com:25565"
|
||||
maxLength={255}
|
||||
onChange={(e) => {
|
||||
setAddress(e.target.value);
|
||||
setSaved("");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="muted">
|
||||
Use the Minecraft hostname or IP players connect to. This address is
|
||||
shown publicly when player contributions are enabled. The mod records
|
||||
the connected server; plugin admins set server-address before exporting.
|
||||
You can override the captured address here, or clear it to allow
|
||||
unchecked submissions.
|
||||
</p>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => saveAddress(address)}
|
||||
>
|
||||
Save server address
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy || !serverAddress}
|
||||
onClick={() => saveAddress("")}
|
||||
>
|
||||
Clear address and turn off check
|
||||
</button>
|
||||
</div>
|
||||
{saved && <p role="status">{saved}</p>}
|
||||
<ContributionInstructions
|
||||
id={id}
|
||||
bounds={bounds}
|
||||
serverAddress={serverAddress}
|
||||
/>
|
||||
<h3>Player submissions</h3>
|
||||
{locked && (
|
||||
<p className="muted">
|
||||
Wait for the current moderator review before accepting another
|
||||
submission.
|
||||
</p>
|
||||
)}
|
||||
{!items.length && <p className="muted">No contributions yet.</p>}
|
||||
{items.map((c) => (
|
||||
<article className="tool-card" key={c.id}>
|
||||
<h3>{c.name}</h3>
|
||||
<span className="badge">
|
||||
{c.state === "failed" ? "failed" : c.contribution_status}
|
||||
</span>
|
||||
<p>
|
||||
{c.panorama_count || c.contribution_count} panoramas · {c.state}
|
||||
</p>
|
||||
<p className="muted">Contributor: {c.contributorName}</p>
|
||||
{c.rejection_reason && <p>{c.rejection_reason}</p>}
|
||||
{c.contribution_status === "submitted" && (
|
||||
<>
|
||||
<button className="button" onClick={() => setPreview(c.id)}>
|
||||
Inspect submitted panoramas
|
||||
</button>
|
||||
<label className="field">
|
||||
Feedback
|
||||
<textarea
|
||||
value={reasons[c.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setReasons((r) => ({ ...r, [c.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy || locked}
|
||||
onClick={() => decide(c.id, "accept")}
|
||||
>
|
||||
Accept into map draft
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy || locked || !reasons[c.id]?.trim()}
|
||||
onClick={() => decide(c.id, "reject")}
|
||||
>
|
||||
Reject with feedback
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{preview && (
|
||||
<PanoramaPreview
|
||||
endpoint={`contributions/${id}/inbox/${preview}/panoramas`}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<p className="error-message" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Panorama } from "./Panorama";
|
||||
import { api } from "./StartGame";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
Move,
|
||||
Expand,
|
||||
Minimize,
|
||||
} from "lucide-react";
|
||||
|
||||
export function PanoramaPreview({
|
||||
endpoint,
|
||||
@@ -13,18 +21,34 @@ export function PanoramaPreview({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const closeButton = useRef<HTMLButtonElement>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [locations, setLocations] = useState<string[]>([]),
|
||||
[selected, setSelected] = useState(initialId ?? ""),
|
||||
[urls, setUrls] = useState<string[] | null>(null),
|
||||
[ready, setReady] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const [labels, setLabels] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
dialog.current?.showModal();
|
||||
const element = dialog.current;
|
||||
const previousFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const overflow = document.body.style.overflow;
|
||||
if (element && !element.open) element.showModal();
|
||||
document.body.style.overflow = "hidden";
|
||||
closeButton.current?.focus();
|
||||
let disposed = false;
|
||||
api<{ locations: string[] }>(endpoint)
|
||||
api<{ locations: string[]; labels?: Record<string, string> }>(endpoint)
|
||||
.then((d) => {
|
||||
if (disposed) return;
|
||||
if (!Array.isArray(d.locations))
|
||||
throw new Error(
|
||||
"Could not load the submitted locations. Please try again.",
|
||||
);
|
||||
setLocations(d.locations);
|
||||
setLabels(d.labels ?? {});
|
||||
setSelected((s) =>
|
||||
d.locations.includes(s) ? s : (d.locations[0] ?? ""),
|
||||
);
|
||||
@@ -34,6 +58,9 @@ export function PanoramaPreview({
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
element?.close();
|
||||
document.body.style.overflow = overflow;
|
||||
previousFocus?.focus();
|
||||
};
|
||||
}, [endpoint]);
|
||||
useEffect(() => {
|
||||
@@ -57,40 +84,84 @@ export function PanoramaPreview({
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="panorama-preview"
|
||||
aria-label="Inspect panorama"
|
||||
onCancel={onClose}
|
||||
className={`panorama-preview${expanded ? " is-expanded" : ""}`}
|
||||
aria-labelledby="panorama-preview-title"
|
||||
onCancel={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
const box = e.currentTarget.getBoundingClientRect();
|
||||
if (
|
||||
e.clientX < box.left ||
|
||||
e.clientX > box.right ||
|
||||
e.clientY < box.top ||
|
||||
e.clientY > box.bottom
|
||||
)
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="preview-header">
|
||||
<div>
|
||||
<span className="eyebrow">360° PANORAMA</span>
|
||||
<h2 id="panorama-preview-title">A closer look.</h2>
|
||||
</div>
|
||||
<div className="preview-header-actions">
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label={expanded ? "Restore preview size" : "Expand preview"}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <Minimize size={19} /> : <Expand size={19} />}
|
||||
</button>
|
||||
<button
|
||||
ref={closeButton}
|
||||
className="icon-button"
|
||||
aria-label="Close preview"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={21} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="preview-toolbar">
|
||||
<label>
|
||||
Location{" "}
|
||||
<label className="preview-location">
|
||||
<span>Location</span>
|
||||
<select
|
||||
aria-label="Preview location"
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
>
|
||||
{locations.map((p) => (
|
||||
<option key={p}>{p}</option>
|
||||
<option key={p} value={p}>
|
||||
{labels[p] ?? p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="button"
|
||||
disabled={index <= 0}
|
||||
onClick={() => setSelected(locations[index - 1])}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
disabled={index < 0 || index >= locations.length - 1}
|
||||
onClick={() => setSelected(locations[index + 1])}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button className="button" onClick={onClose}>
|
||||
Close preview
|
||||
</button>
|
||||
<span className="preview-counter">
|
||||
{index >= 0 ? index + 1 : 0} / {locations.length}
|
||||
</span>
|
||||
<div className="preview-navigation">
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="Previous panorama"
|
||||
disabled={index <= 0}
|
||||
onClick={() => setSelected(locations[index - 1])}
|
||||
>
|
||||
<ChevronLeft size={21} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label="Next panorama"
|
||||
disabled={index < 0 || index >= locations.length - 1}
|
||||
onClick={() => setSelected(locations[index + 1])}
|
||||
>
|
||||
<ChevronRight size={21} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-viewport">
|
||||
{urls && (
|
||||
@@ -113,10 +184,13 @@ export function PanoramaPreview({
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="muted">
|
||||
Drag to look around · Scroll to zoom · Check for blocked views and
|
||||
missing terrain.
|
||||
</p>
|
||||
<footer className="preview-footer">
|
||||
<span>
|
||||
<Move size={16} />
|
||||
Drag to look around · Scroll to zoom
|
||||
</span>
|
||||
<span>Check for blocked views and missing terrain.</span>
|
||||
</footer>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
148
components/PasswordForm.tsx
Normal file
148
components/PasswordForm.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { passwordUpdateSchema } from "@/shared/account";
|
||||
|
||||
export function PasswordForm({ recovery = false }: { recovery?: boolean }) {
|
||||
const [current, setCurrent] = useState(""),
|
||||
[password, setPassword] = useState(""),
|
||||
[confirmation, setConfirmation] = useState(""),
|
||||
[nonce, setNonce] = useState(""),
|
||||
[needsNonce, setNeedsNonce] = useState(false),
|
||||
[error, setError] = useState(""),
|
||||
[message, setMessage] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
const body = {
|
||||
password,
|
||||
...(!recovery ? { currentPassword: current } : {}),
|
||||
...(needsNonce ? { nonce } : {}),
|
||||
};
|
||||
const valid = passwordUpdateSchema.safeParse(body);
|
||||
if (!valid.success) {
|
||||
setError(valid.error.issues[0].message);
|
||||
return;
|
||||
}
|
||||
if (password !== confirmation) {
|
||||
setError("Your passwords do not match.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/v1/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(valid.data),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
if (data.needsReauthentication) {
|
||||
const client = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
const { error } = await client.auth.reauthenticate();
|
||||
if (error) throw error;
|
||||
setNeedsNonce(true);
|
||||
setMessage(
|
||||
"Check your email for a verification code, then enter it below to finish.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(data.error || "Could not update your password");
|
||||
}
|
||||
setCurrent("");
|
||||
setPassword("");
|
||||
setConfirmation("");
|
||||
setNonce("");
|
||||
setNeedsNonce(false);
|
||||
setMessage(data.message);
|
||||
if (recovery) {
|
||||
router.replace("/settings?password=updated");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form onSubmit={submit} aria-busy={busy} className="password-form">
|
||||
{!recovery && (
|
||||
<label className="field">
|
||||
Current password
|
||||
<input
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="field">
|
||||
New password
|
||||
<input
|
||||
name="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={12}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
<small>
|
||||
At least 12 characters. Use a unique password or passphrase.
|
||||
</small>
|
||||
</label>
|
||||
<label className="field">
|
||||
Confirm new password
|
||||
<input
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
{needsNonce && (
|
||||
<label className="field">
|
||||
Email verification code
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={nonce}
|
||||
onChange={(e) => setNonce(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p role="status" className="success-message">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
<button className="button primary" disabled={busy}>
|
||||
{busy ? "Updating…" : recovery ? "Set new password" : "Update password"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
import "server-only";
|
||||
import Link from "next/link";
|
||||
import { Compass, ArrowUpRight, Globe2 } from "lucide-react";
|
||||
export function Brand() {
|
||||
return (
|
||||
<Link href="/" className="brand" aria-label="MineGeo home">
|
||||
<span className="brand-cube">
|
||||
<span />
|
||||
</span>
|
||||
Mine<span>Geo</span>
|
||||
<sup>BETA</sup>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
export function Header() {
|
||||
import { user } from "@/lib/supabase";
|
||||
import { Brand } from "./Brand";
|
||||
export async function Header() {
|
||||
const account = await user();
|
||||
return (
|
||||
<header className="site-header">
|
||||
<div className="header-inner">
|
||||
@@ -21,8 +14,11 @@ export function Header() {
|
||||
<Link href="/downloads">Downloads</Link>
|
||||
<Link href="/about">Guide</Link>
|
||||
</nav>
|
||||
<Link href="/dashboard" className="header-account">
|
||||
My account <ArrowUpRight size={15} />
|
||||
<Link
|
||||
href={account ? "/dashboard" : "/log-in"}
|
||||
className="header-account"
|
||||
>
|
||||
{account ? "My account" : "Log in"} <ArrowUpRight size={15} />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,69 +1,267 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { Mail } from "lucide-react";
|
||||
export function SignIn() {
|
||||
const [email, setEmail] = useState(""),
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Compass, Mail, ShieldCheck } from "lucide-react";
|
||||
import { api } from "./StartGame";
|
||||
import { authActionSchema, authDestination } from "@/shared/account";
|
||||
|
||||
type Mode = "login" | "signup" | "recover" | "magic";
|
||||
export function SignIn({
|
||||
next = "/dashboard",
|
||||
initialError = "",
|
||||
}: {
|
||||
next?: string;
|
||||
initialError?: string;
|
||||
}) {
|
||||
const [mode, setMode] = useState<Mode>("login"),
|
||||
[email, setEmail] = useState(""),
|
||||
[name, setName] = useState(""),
|
||||
[password, setPassword] = useState(""),
|
||||
[confirmation, setConfirmation] = useState(""),
|
||||
[message, setMessage] = useState(""),
|
||||
[error, setError] = useState(""),
|
||||
[error, setError] = useState(initialError),
|
||||
[busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
function changeMode(value: Mode) {
|
||||
setMode(value);
|
||||
setError("");
|
||||
setMessage("");
|
||||
setPassword("");
|
||||
setConfirmation("");
|
||||
}
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
const body =
|
||||
mode === "signup"
|
||||
? { action: mode, email, password, displayName: name, next }
|
||||
: mode === "login"
|
||||
? { action: mode, email, password, next }
|
||||
: mode === "magic"
|
||||
? { action: mode, email, next }
|
||||
: { action: mode, email };
|
||||
const valid = authActionSchema.safeParse(body);
|
||||
if (!valid.success) {
|
||||
setError(valid.error.issues[0].message);
|
||||
return;
|
||||
}
|
||||
if (mode === "signup" && password !== confirmation) {
|
||||
setError("Your passwords do not match.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const client = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
const { error } = await client.auth.signInWithOtp({
|
||||
email,
|
||||
options: { emailRedirectTo: window.location.origin + "/auth/callback" },
|
||||
});
|
||||
if (error) throw error;
|
||||
setMessage(
|
||||
"Check your inbox for a sign-in link. You can return here after opening it.",
|
||||
const result = await api<{ next?: string; message?: string }>(
|
||||
"auth",
|
||||
valid.data,
|
||||
);
|
||||
setPassword("");
|
||||
setConfirmation("");
|
||||
if (result.next) {
|
||||
router.replace(authDestination(result.next));
|
||||
router.refresh();
|
||||
} else setMessage(result.message ?? "Check your email to continue.");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const title = {
|
||||
login: "Welcome back.",
|
||||
signup: "Make yourself at home.",
|
||||
recover: "Find your way back.",
|
||||
magic: "An email, a way in.",
|
||||
}[mode];
|
||||
return (
|
||||
<div className="auth-card">
|
||||
<span className="eyebrow">YOUR CARTOGRAPHER ACCOUNT</span>
|
||||
<h2>Welcome, explorer.</h2>
|
||||
<p className="muted">
|
||||
Sign in to share maps and keep your expeditions. No password needed.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<label className="field">
|
||||
Email address
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className="button primary full" disabled={busy}>
|
||||
<Mail size={16} />
|
||||
{busy ? "Sending…" : "Send me a sign-in link"}
|
||||
</button>
|
||||
</form>
|
||||
{message && (
|
||||
<p role="status" className="success-message">
|
||||
{message}
|
||||
<div className="auth-layout">
|
||||
<aside className="auth-story">
|
||||
<span className="eyebrow">YOUR NEXT EXPEDITION</span>
|
||||
<h1>
|
||||
A familiar world.
|
||||
<br />
|
||||
Your own perspective.
|
||||
</h1>
|
||||
<p>
|
||||
Keep your journeys, share the places you know, and help build the next
|
||||
map.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{error}
|
||||
<div className="auth-story-map" aria-hidden="true">
|
||||
<Compass size={52} />
|
||||
<span>Every world has a story.</span>
|
||||
</div>
|
||||
<div className="auth-benefits">
|
||||
<span>
|
||||
<Compass size={18} />
|
||||
Save your expeditions
|
||||
</span>
|
||||
<span>
|
||||
<ShieldCheck size={18} />
|
||||
Keep control of your maps
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="auth-card">
|
||||
<div className="auth-tabs" aria-label="Account options">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "login"}
|
||||
disabled={busy}
|
||||
onClick={() => changeMode("login")}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "signup"}
|
||||
disabled={busy}
|
||||
onClick={() => changeMode("signup")}
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</div>
|
||||
<span className="eyebrow">YOUR CARTOGRAPHER ACCOUNT</span>
|
||||
<h2>{title}</h2>
|
||||
<p className="muted">
|
||||
{mode === "signup"
|
||||
? "Choose the name people will see on your maps."
|
||||
: mode === "recover"
|
||||
? "We’ll email you a link to set a new password."
|
||||
: mode === "magic"
|
||||
? "For existing accounts. Open the link on this device to continue."
|
||||
: "Log in with your email and password."}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={submit} aria-busy={busy}>
|
||||
{mode === "signup" && (
|
||||
<label className="field">
|
||||
Display name
|
||||
<input
|
||||
name="displayName"
|
||||
autoComplete="nickname"
|
||||
minLength={2}
|
||||
maxLength={32}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="How should we call you?"
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
<small>2–32 characters. You can change it in settings.</small>
|
||||
</label>
|
||||
)}
|
||||
<label className="field">
|
||||
Email address
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
{(mode === "login" || mode === "signup") && (
|
||||
<label className="field">
|
||||
Password
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete={
|
||||
mode === "signup" ? "new-password" : "current-password"
|
||||
}
|
||||
minLength={mode === "signup" ? 12 : 1}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
{mode === "signup" && (
|
||||
<small>
|
||||
Use at least 12 characters. A unique passphrase works well.
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{mode === "signup" && (
|
||||
<label className="field">
|
||||
Confirm password
|
||||
<input
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{mode === "login" && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-link auth-recovery"
|
||||
disabled={busy}
|
||||
onClick={() => changeMode("recover")}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p role="status" className="success-message">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
<button className="button primary full" disabled={busy}>
|
||||
{mode === "recover" || mode === "magic" ? (
|
||||
<Mail size={17} />
|
||||
) : (
|
||||
<ArrowRight size={17} />
|
||||
)}{" "}
|
||||
{busy
|
||||
? "Please wait…"
|
||||
: {
|
||||
login: "Log in",
|
||||
signup: "Create account",
|
||||
recover: "Send reset link",
|
||||
magic: "Send sign-in link",
|
||||
}[mode]}
|
||||
</button>
|
||||
</form>
|
||||
{mode === "login" && (
|
||||
<div className="auth-alternative">
|
||||
<span>Already use email links?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-link"
|
||||
disabled={busy}
|
||||
onClick={() => changeMode("magic")}
|
||||
>
|
||||
Send me a sign-in link
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(mode === "recover" || mode === "magic") && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-link auth-back"
|
||||
disabled={busy}
|
||||
onClick={() => changeMode("login")}
|
||||
>
|
||||
← Back to log in
|
||||
</button>
|
||||
)}
|
||||
<p className="auth-footnote">
|
||||
Your email stays private. Your display name appears with your maps.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
23
docs/account-settings.md
Normal file
23
docs/account-settings.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Accounts and settings
|
||||
|
||||
Guests see **Log in** in the header. `/log-in` supports email/password login, account creation, password recovery, and email links for existing accounts. Signed-in users see **My account**; their dashboard links to `/settings`.
|
||||
|
||||
Display names are public map/contribution credits. They accept 2–32 characters, including Unicode, spaces and emoji, with control characters rejected. Names are not unique; login uses an email address. Changing a name does not change the account ID or moderator permissions. Existing names and roles are preserved when profiles are initialized.
|
||||
|
||||
New passwords require at least 12 characters and at most 72 UTF-8 bytes. Passwords are managed by Supabase Auth and are never stored in application profiles. Existing shorter passwords remain usable for login. Settings require the current password to change it; users who previously used email links can set their first password through email recovery. Successful changes revoke other refresh sessions; already issued access tokens can remain valid until expiry.
|
||||
|
||||
Recovery uses Supabase's verified code/token exchange and a ten-minute, HttpOnly grant tied to the authenticated user and session. Merely opening the reset page or supplying a destination URL does not authorize a password change. The grant cookie is cleared after a successful change. Redirect destinations are restricted to application routes. Account APIs enforce session authentication, origin checks, input validation and rate limits; contributor tokens cannot edit account identity or credentials.
|
||||
|
||||
## Supabase deployment configuration
|
||||
|
||||
No database migration is needed for these account changes: `profiles.display_name` already exists. Deploy the updated website, and configure `MINEGEO_URL` with its public HTTPS origin.
|
||||
|
||||
In Supabase Auth, set the Site URL to that origin and allow its `/auth/callback` URL (plus any development callback URLs you actually use). Keep email confirmation enabled and configure working email delivery. PKCE email redirects use `/auth/callback`; token-hash templates can use `/auth/confirm?token_hash={{ .TokenHash }}&type=email`, or `type=recovery` for password resets. Custom confirmation templates should preserve the appropriate destination when returning to a pairing flow.
|
||||
|
||||
Set the provider's minimum password length to 12 as well, since direct Auth requests do not pass through application validation. Consider enabling secure password changes and leaked-password protection where your plan supports it. Provider hardening and SMTP settings are configured in Supabase; this change does not alter them automatically. See [password security](https://supabase.com/docs/guides/auth/password-security) and [redirect URL configuration](https://supabase.com/docs/guides/auth/redirect-urls).
|
||||
|
||||
## Verification
|
||||
|
||||
The 43 Node tests, TypeScript check and Next.js production build passed. A disposable real Supabase account verified normal password login, profile editing, role-escalation rejection, required/current-password validation, password replacement and rejection of the old password. Its sessions and account were removed afterward. HTTP checks verified guest header labels, login form rendering, settings access, expired recovery and invalid callback handling.
|
||||
|
||||
Browser testing was skipped at the user's request. Email delivery and a full emailed confirmation/recovery journey were not exercised.
|
||||
@@ -24,6 +24,8 @@ Treat the website role separately from Supabase organization membership: moderat
|
||||
|
||||
# First beta map
|
||||
|
||||
Published-map updates now have their own review state. Owners approve player submissions first and submit the combined map update; **Approve update** publishes a new immutable revision. Returning an update with feedback keeps the previous version playable. See [panorama contributions](panorama-contributions.md).
|
||||
|
||||
After deploying the updated website and worker:
|
||||
|
||||
1. Open the private draft in `/dashboard`. Use **Look around** to inspect its complete panorama, including terrain near the edge of the export.
|
||||
|
||||
42
docs/panorama-contributions.md
Normal file
42
docs/panorama-contributions.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# More panoramas and player contributions
|
||||
|
||||
Maps hold 5–24 captured locations. Each game chooses five distinct locations in random order. The server plugin now picks random safe surface positions, with `viewpoints: 24` as the default for new installations; existing config files keep their value until edited and reloaded. The Fabric mod captures where the player is standing and permits up to 24 local captures for a new map.
|
||||
|
||||
## Owner workflow
|
||||
|
||||
Open the map in the dashboard. Under **Panorama settings**, enable **Allow players to add panoramas with you**. This is off by default. Share the map ID and its Minecraft X/Z bounds. Players must capture in the same world and inside those bounds. Owners can add their own captures using that map ID even when player contributions are disabled.
|
||||
|
||||
The map's limit includes all retained captures and pending uploads. Each non-owner can add at most 12 panoramas cumulatively to that map, across uploads and both tools. Accepted panoramas continue to count toward that player's limit. Rejected, withdrawn, and failed contributions release their reserved space. Deselecting an original map location keeps it available to restore and does not free a capture slot.
|
||||
|
||||
In **Player submissions**, inspect the submitted locations in 360°. Accept them into the map draft or reject with feedback. Acceptance adds the contributor's selected locations; unused captures stay private to the contributor. Save the combined selection and **Submit for review**. Moderators review both new maps and updates.
|
||||
|
||||
For an already published map, its approved name, description, panorama count, and revision stay live until a moderator approves the update. Rejecting an update leaves that version playable. Existing games continue using the revision they started with. Editing and accepting further submissions are locked while the update is under review.
|
||||
|
||||
## Player workflow
|
||||
|
||||
Install the updated capture tool and link your own MineGeo account. A new map needs at least five panoramas; a contribution may contain just one.
|
||||
|
||||
- **Fabric:** Press G, paste the owner's map ID into **Add to map ID**, capture within the original bounds, and upload. Leave the field blank to upload a new map.
|
||||
- **Plugin:** Set the website and your own contributor token, select an explored area within the map bounds, export it, then run `/minegeo contribute <map ID>`. Server permission `minegeo.admin` is still required for server exports. Players without that permission can use the Fabric mod.
|
||||
|
||||
The uploader keeps up to 12 in-bounds locations, or fewer when the map/player has fewer slots available. Its saved selection and upload ledger make retries idempotent. After processing, open **Open contribution** in the dashboard, inspect/select locations, and **Submit to map owner**. You may withdraw a pending submission to release its reserved quota. A completed accepted submission cannot be edited or overwritten; use a fresh capture to add more.
|
||||
|
||||
Both tools can contribute to either kind of map. The original surface map and bounds remain the basis for guessing; contributions append panoramas. Owners and moderators must check that submissions show the correct world, useful views, and acceptable visual quality.
|
||||
|
||||
## Contribution server check
|
||||
|
||||
Each map has a separate **Contribution server address** setting. New maps use the address recorded by their capture tool. Existing maps with a valid captured address are initialized from their own manifest. Maps whose old exports contain no address show **Server check is off** until the owner enters one.
|
||||
|
||||
When contributions are enabled, the map page shows that address. The owner can correct it in the dashboard, including a custom port. **Clear address and turn off check** removes the displayed address and permits submissions without matching a server. Clearing does not skip owner approval or moderator review.
|
||||
|
||||
The Fabric mod records the connected Minecraft server automatically and requires joining that capture's server before contributing. The plugin cannot infer a public address from its bind IP. Before exporting, its admin must set `server-address: 'play.example.com'` in `config.yml` and reload, or run `/minegeo server play.example.com`. Use the same connection hostname/IP that Fabric players enter. Existing exports must be recreated after changing this configuration; their recorded capture origin is retained on retry.
|
||||
|
||||
Both uploaders check the recorded capture address against the target map before sending assets, including cached retries. The website checks again when creating/signing/finalizing the upload, submitting it, and accepting it. A missing capture address is blocked when the map's check is enabled. Domain case, a trailing DNS dot, and the default `:25565` port are normalized. Different DNS aliases are not automatically treated as the same server.
|
||||
|
||||
This prevents accidental submissions from another server. Capture addresses are supplied by the tool; a modified client can forge them, and one address can host multiple worlds. Owner inspection and moderator review remain necessary. This is address matching, not authenticated proof of server membership.
|
||||
|
||||
## Deployment
|
||||
|
||||
The configured MineGeo Supabase project has all three October 5 migrations applied. For another database, apply `20261005063646_panorama_contributions.sql`, `20261005065029_withdraw_contribution.sql`, and `20261005073253_contribution_server_check.sql` after the existing migrations. Deploy the website and worker together. Replace both Minecraft JARs from `public/tools` and restart Minecraft/the server. An installed older tool does not acquire the new contribution controls when the website changes.
|
||||
|
||||
The new transactional functions are callable only by the server's service role. They verify map ownership, opt-in, bounds, processing validation and review state, and serialize uploads/edits/approvals using a map row lock. Media provenance is assigned by the server after acceptance; uploaders cannot choose another dataset's private media paths. Published revisions remain immutable.
|
||||
@@ -8,11 +8,14 @@
|
||||
|
||||
```text
|
||||
minegeo website https://your-minegeo-domain.example
|
||||
minegeo server play.your-minecraft-server.example
|
||||
minegeo link <token copied from the dashboard>
|
||||
```
|
||||
|
||||
For LAN testing the website may be `http://192.168.1.50:3000`; use your actual address. `localhost` means the computer running Minecraft, which may be different from the Mac running the website. HTTP is supported for testing; use HTTPS when transmitting contributor tokens over a public connection.
|
||||
|
||||
`minegeo server` sets the Minecraft connection hostname/IP, including a non-default port if needed. It is separate from the MineGeo website URL. Use the address players enter in Minecraft, or set `server-address` in `config.yml` and reload before exporting. The plugin records it in each export. Contributions to checked maps reject a missing or different capture address; old exports need to be recreated after configuring it. The map owner can override or clear the expected address in panorama settings. See [contribution server checks](panorama-contributions.md#contribution-server-check).
|
||||
|
||||
## Export before uploading
|
||||
|
||||
Choose a small, already generated area around your builds. Coordinates below are examples:
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Verification of the export/upload fixes
|
||||
|
||||
## Panorama contributions (October 5, 2026)
|
||||
|
||||
Maps now retain up to 24 captures. Server exports pick random safe positions, and games retain five distinct random rounds. Owners opt into contributions by map ID; non-owners have a cumulative 12-panorama quota. Contributions need owner acceptance and a subsequent moderator review of the combined map/update.
|
||||
|
||||
Verification against disposable fixtures in the configured Supabase project passed:
|
||||
|
||||
- Opt-in enforcement, non-owner approval denial, direct child-publication denial, the 24-map limit including reserved uploads, and the cumulative 12-player limit. Two concurrent seven-point uploads allowed only one when that contributor had already added one point.
|
||||
- Withdrawal released pending quota while accepted points continued to count. A one-point contribution completed the private processing/selection workflow.
|
||||
- Acceptance and draft edits left the published metadata/revision unchanged; rejected updates preserved it too. Approval created a new revision, and a pre-existing game retained its old revision. Repeated games selected five distinct locations in varying orders.
|
||||
- The shared Java client uploaded a one-point Fabric fixture through the real signed TUS/Storage flow. The worker generated its cubemap/preview, and retrying the completed upload reused its dataset. An initial undersized 128-pixel fixture was correctly rejected; the valid 256-pixel fixture passed.
|
||||
- Codex's built-in Browser verified owner submission previews, accepting into a published-map draft, submitting the update, moderator inspection of the added location, approval, the updated public count, and the permanent map ID. It caught an inbox-route prefix bug; the route was constrained and its list/cubemap responses rechecked.
|
||||
- A hidden, temporary Paper 1.21.11 server exported 24 random safe locations, a second export changed the positions, and `/minegeo contribute` sent 12 and retried without duplication. Interrupted normal uploads still resumed byte-for-byte. The server stopped automatically.
|
||||
- Both Minecraft tools compiled and the downloadable JARs were rebuilt. Fabric's live in-game capture panel was not launched in this pass; its shared upload protocol was exercised through Java and the real worker.
|
||||
- All 36 unit tests, TypeScript checking and the final Next.js production build passed. The temporary Paper server, website, worker, auth bridge and Browser tabs were stopped. Test media, maps, revisions, sessions, server entry and the newly created contributor account were removed; the user's published map was retained.
|
||||
|
||||
The new migrations are applied to MineGeo. All new workflow functions deny execution to anon/authenticated and run with invoker security through the website's service role. No existing capture exceeded the new 24-point limit. The revision immutability trigger remains enabled. The security advisor returned the previously documented authentication-setting warning and informational notices for deliberately server-only RLS tables.
|
||||
|
||||
Reports and screenshots are in `.cache/contribution-verification`, with the Paper regression report in `artifacts-plugin-regression.json`. Deployment requires the updated website/worker and replacing the installed Minecraft JARs; see [panorama contributions](panorama-contributions.md).
|
||||
|
||||
---
|
||||
|
||||
## First beta texture and usability fixes (October 4, 2026)
|
||||
|
||||
The renderer previously treated `waterlogged=false` on leaf blocks as water and tinted trees blue. Tint selection now uses the block identifier and exported biome. Animated vanilla sprite sheets use a single frame, grass overlays avoid depth flickering, partial block models use their derived face UVs, and element rotations also rotate lighting normals.
|
||||
@@ -59,3 +80,30 @@ After the user authorized tests again, the following checks passed on Windows us
|
||||
- A nonexistent block model failed with an explicit missing-assets error instead of completing an empty draft.
|
||||
|
||||
Both worker renders closed their Chromium processes automatically. No Minecraft server/client or website UI automation was started. Outputs and reports are under `.cache/renderer-regression`; `npm run test:renderer` reproduces the fixture, and a cached export folder can be supplied as an argument. The full 2,651,888-block dataset and Intel Metal/macOS behavior still require regeneration on the Mac. No speedup claim is inferred from the small Windows fixtures.
|
||||
## Contribution server check (October 5, 2026)
|
||||
|
||||
The map's expected connection address is separate from its permanent map ID and catalog server name. Owner settings can override it or explicitly clear it. Fabric records its connected server; the plugin has a public `server-address` configuration and `/minegeo server` command. Both use the shared uploader check, including cached retries. The website enforces the check at upload creation/signing/finalization, submission and acceptance.
|
||||
|
||||
Verification:
|
||||
|
||||
- All 39 Node tests, TypeScript checking and the final Next.js production build passed. Both Java projects built; both downloadable JARs were replaced. The shared Java address smoke check passed for matching, missing, mismatching, invalid, default-port, override and disabled-check cases.
|
||||
- A disposable hidden Paper server exported its configured address, rejected a mismatching map before creating an upload, accepted a normalized address, rechecked a cached retry and allowed an idempotent retry after clearing. Its previous export/resume/randomness/cancellation checks still passed; the server stopped automatically.
|
||||
- `tests/sql/contribution-server.sql` passed against the configured Supabase project and rolled back all fixture rows. It verified owner-only settings, default address initialization, mismatching/missing captures, changed settings during retries, submission and acceptance guards, clearing, and private RPC permissions.
|
||||
- The normal application API was tested with the existing fixture contributor token and a disposable private map. Override, normalization, invalid-input rejection, contribution-toggle preservation and clearing passed. No new account or credentials were created.
|
||||
- Codex's built-in Browser tested the actual settings component in a local mock-data preview: override/save, clear/warning, invalid address and restore. It observed no browser errors. Screenshot: `.cache/server-check-verification/settings.png`.
|
||||
|
||||
Automatic approval review rejected a temporary credential-backed browser sign-in bridge. It was not started. Browser verification therefore used mock data; authenticated dashboard integration was verified through the normal API and database checks, not through a signed-in browser session. A live in-game Fabric capture session was not launched.
|
||||
|
||||
Address checks prevent accidental wrong-server submissions, but client-supplied addresses do not prove server membership against a modified client. Owner inspection and moderator review are retained. The migration is applied to MineGeo; the website and installed Minecraft JARs still need deployment/replacement.
|
||||
|
||||
The disposable private map was deleted and its absence verified. The user's published map retains its original contribution opt-in state; its older plugin export has no captured address, so the owner must enter one to enable checking. No test storage objects or new authentication accounts were created. Temporary website/preview processes and Browser tabs were stopped; the Paper test shut itself down.
|
||||
|
||||
## Account settings and panorama preview (October 5, 2026)
|
||||
|
||||
Panorama previews now use a centered, responsive modal with expand/restore controls, location navigation, keyboard dismissal and focus restoration. Login and account settings have responsive layouts. The header checks the current session and shows Log in to guests. Profiles support display names; Supabase Auth handles password signup, login, password changes and recovery.
|
||||
|
||||
- All 43 Node tests, TypeScript checking and the final Next.js production build passed, including account validation, redirect restrictions and recovery-grant tampering/expiry checks. The build caught and resolved a server/client import boundary in the game's shared logo.
|
||||
- Real Supabase API checks passed for login, private profile access/editing, metadata/role injection rejection, missing/wrong current passwords, successful password replacement and old/new password login behavior. The disposable test account and its sessions were removed.
|
||||
- Browser-free HTTP checks passed for guest homepage/login headers, the password form, protected settings redirects, expired recovery and invalid callbacks.
|
||||
|
||||
No browser was opened for this pass, as requested. Visual behavior and email confirmation/recovery delivery remain unverified. Deployment setup is documented in `docs/account-settings.md`; no account schema migration was needed.
|
||||
|
||||
212
lib/account-api.ts
Normal file
212
lib/account-api.ts
Normal 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;
|
||||
}
|
||||
346
lib/api.ts
346
lib/api.ts
@@ -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" } },
|
||||
|
||||
@@ -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
6
lib/password-recovery.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import "server-only";
|
||||
export {
|
||||
recoveryCookie,
|
||||
createRecoveryGrant,
|
||||
verifyRecoveryGrant,
|
||||
} from "@/shared/password-recovery";
|
||||
27
lib/profile.ts
Normal file
27
lib/profile.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -5,18 +5,19 @@ import net.minecraft.client.gui.widget.*;
|
||||
import net.minecraft.text.Text;
|
||||
|
||||
public final class CaptureScreen extends Screen {
|
||||
private TextFieldWidget website,name;
|
||||
private TextFieldWidget website,name,target;
|
||||
public CaptureScreen(){super(Text.literal("MineGeo Capture"));}
|
||||
@Override protected void init(){var mod=MineGeoClient.INSTANCE;int x=width/2-130;
|
||||
website=new TextFieldWidget(textRenderer,x,50,260,20,Text.literal("MineGeo website"));website.setMaxLength(200);website.setText(mod.website);addDrawableChild(website);
|
||||
name=new TextFieldWidget(textRenderer,x,92,260,20,Text.literal("Map name"));name.setMaxLength(80);name.setText(mod.dataset==null?"My Minecraft map":mod.dataset.name);addDrawableChild(name);
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Capture panorama"),b->{mod.website=website.getText();mod.capture();}).dimensions(x,126,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Preview files"),b->{if(mod.dataset!=null)net.minecraft.util.Util.getOperatingSystem().open(mod.dataset.directory.toFile());}).dimensions(x+134,126,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Link account"),b->{mod.website=website.getText();mod.pair();}).dimensions(x,154,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Upload / resume"),b->{mod.website=website.getText();mod.upload(name.getText());}).dimensions(x+134,154,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("New map at this location"),b->{mod.newMap();init(width,height);}).dimensions(x,182,260,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Close"),b->close()).dimensions(x,210,260,20).build());
|
||||
target=new TextFieldWidget(textRenderer,x,126,260,20,Text.literal("Add to map ID (optional)"));target.setMaxLength(36);target.setText(mod.targetMap);addDrawableChild(target);
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Capture panorama"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.capture();}).dimensions(x,154,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Preview files"),b->{if(mod.dataset!=null)net.minecraft.util.Util.getOperatingSystem().open(mod.dataset.directory.toFile());}).dimensions(x+134,154,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Link account"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.pair();}).dimensions(x,182,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Upload / resume"),b->{mod.website=website.getText();mod.targetMap=target.getText().trim();mod.upload(name.getText());}).dimensions(x+134,182,126,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("New map at this location"),b->{mod.newMap();init(width,height);}).dimensions(x,210,260,20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Close"),b->close()).dimensions(x,238,260,20).build());
|
||||
}
|
||||
@Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website URL (HTTP for LAN testing)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,242,0xffffffff);int y=264;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
|
||||
@Override public void render(DrawContext context,int mouseX,int mouseY,float delta){super.render(context,mouseX,mouseY,delta);int x=width/2-130;context.drawCenteredTextWithShadow(textRenderer,title,width/2,20,0xff8cdf6b);context.drawTextWithShadow(textRenderer,"Website URL (HTTP for LAN testing)",x,38,0xffffffff);context.drawTextWithShadow(textRenderer,"Map name",x,80,0xffffffff);context.drawTextWithShadow(textRenderer,"Add to map ID (blank = new map)",x,114,0xffffffff);var mod=MineGeoClient.INSTANCE;String counts=mod.dataset==null?"Join an Overworld to begin":mod.dataset.points.size()+" panoramas · "+mod.dataset.coverage.size()+" explored chunks";context.drawCenteredTextWithShadow(textRenderer,Text.literal(counts),width/2,270,0xffffffff);int y=292;for(var line:textRenderer.wrapLines(Text.literal(mod.status),260)){context.drawTextWithShadow(textRenderer,line,x,y,0xffd5d5d5);y+=10;}}
|
||||
@Override public boolean shouldPause(){return false;}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,19 @@ public final class MineGeoClient implements ClientModInitializer {
|
||||
public final ExecutorService background=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"MineGeo upload");t.setDaemon(true);return t;});
|
||||
public final Gson gson=new GsonBuilder().setPrettyPrinting().create();
|
||||
public volatile String website="http://localhost:3000",token="",status="Explore normally to collect surface coverage.";
|
||||
public volatile String targetMap="";
|
||||
public volatile boolean uploading=false;public SurfaceDataset dataset;private SurfaceDataset.Point pending;
|
||||
private Path config;
|
||||
@Override public void onInitializeClient(){INSTANCE=this;config=MinecraftClient.getInstance().runDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();website=j.get("website").getAsString();token=j.get("token").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");}
|
||||
@Override public void onInitializeClient(){INSTANCE=this;config=MinecraftClient.getInstance().runDirectory.toPath().resolve("config/minegeo.json");try{if(Files.exists(config)){var j=JsonParser.parseString(Files.readString(config)).getAsJsonObject();website=j.get("website").getAsString();token=j.get("token").getAsString();if(j.has("targetMap"))targetMap=j.get("targetMap").getAsString();}}catch(Exception e){message("Could not read MineGeo configuration");}
|
||||
var key=KeyBindingHelper.registerKeyBinding(new KeyBinding("key.minegeo.open",InputUtil.Type.KEYSYM,GLFW.GLFW_KEY_G,KeyBinding.Category.create(Identifier.of("minegeo","capture"))));
|
||||
ClientTickEvents.END_CLIENT_TICK.register(c->{while(key.wasPressed()){if(Capture.active())Capture.cancel("Capture cancelled");else c.setScreen(new CaptureScreen());}if(c.world!=null&&c.player!=null&&c.world.getRegistryKey().getValue().toString().equals("minecraft:overworld")){try{if(dataset==null||!dataset.server.equals(server()))dataset=SurfaceDataset.open(server());if(!Capture.active()&&!uploading)dataset.scan(c);}catch(Exception e){message("Map collection: "+e.getMessage());}}CaptureFixture.tick(c);});
|
||||
}
|
||||
public String server(){var c=MinecraftClient.getInstance();return c.getCurrentServerEntry()!=null?c.getCurrentServerEntry().address:"Singleplayer";}
|
||||
public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("website",website);j.addProperty("token",token);Files.writeString(config,gson.toJson(j));}
|
||||
public void saveConfig()throws Exception{Files.createDirectories(config.getParent());var j=new JsonObject();j.addProperty("website",website);j.addProperty("token",token);j.addProperty("targetMap",targetMap);Files.writeString(config,gson.toJson(j));}
|
||||
public void message(String text){status=text;var c=MinecraftClient.getInstance();if(c.player!=null)c.player.sendMessage(Text.literal("[MineGeo] "+text),false);}
|
||||
public void capture(){try{if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=MinecraftClient.getInstance();if(!dataset.contains(c.player.getX(),c.player.getZ()))throw new IllegalStateException("Outside this map's 512-block boundary. Start a new map here.");if(dataset.points.size()>=100)throw new IllegalStateException("This map already contains 100 locations");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());c.setScreen(null);Capture.start(dataset.directory.resolve(pending.id()));message("Capturing six views. Stand still; press G to cancel.");}catch(Exception e){message(e.getMessage());}}
|
||||
public void capture(){try{if(dataset==null)throw new IllegalStateException("Join an Overworld and explore first");var c=MinecraftClient.getInstance();if(!dataset.contains(c.player.getX(),c.player.getZ()))throw new IllegalStateException("Outside this map's 512-block boundary. Start a new map here.");int limit=targetMap.isBlank()?24:12;if(dataset.points.size()>=limit)throw new IllegalStateException("This capture already contains "+limit+" locations");pending=new SurfaceDataset.Point("p"+java.util.UUID.randomUUID().toString().replace("-","").substring(0,12),c.player.getX(),c.player.getEyeY(),c.player.getZ());c.setScreen(null);Capture.start(dataset.directory.resolve(pending.id()));message("Capturing six views. Stand still; press G to cancel.");}catch(Exception e){message(e.getMessage());}}
|
||||
public void savedCapture(Path path){try{if(pending==null||!path.getFileName().toString().equals(pending.id()))return;dataset.points.add(pending);dataset.save();message("Panorama saved locally ("+dataset.points.size()+" total). Press G for preview or upload.");}catch(Exception e){message("Saving capture failed: "+e.getMessage());}finally{pending=null;}}
|
||||
public void newMap(){try{if(uploading||Capture.active())throw new IllegalStateException("Finish the current operation first");dataset=SurfaceDataset.create(server());message("New 512 × 512 map started. Older maps remain in .minecraft/minegeo.");}catch(Exception e){message(e.getMessage());}}
|
||||
public void pair(){background.execute(()->{try{saveConfig();var uploader=new UploadClient(website,"");var pair=uploader.beginPairing();String code=pair.get("deviceCode").getAsString();String link=pair.get("verificationUri").getAsString();MinecraftClient.getInstance().execute(()->{message("Approve code "+pair.get("userCode").getAsString()+" at "+link);net.minecraft.util.Util.getOperatingSystem().open(link);});for(int i=0;i<120;i++){Thread.sleep(5000);var response=uploader.pollPairing(code);if(response.get("status").getAsString().equals("approved")){token=response.get("token").getAsString();saveConfig();MinecraftClient.getInstance().execute(()->message("Account linked. You can upload maps now."));return;}}throw new IllegalStateException("Pairing expired. Try linking again.");}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Pairing: "+e.getMessage()));}});}
|
||||
public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;if(selected==null||selected.points.size()<5){message("Capture at least five locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(website,token).upload(selected.directory, progress -> status=progress, () -> false);MinecraftClient.getInstance().execute(()->message("Upload received. Preview and submit the private draft in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
|
||||
public void upload(String name){if(uploading)return;final SurfaceDataset selected=dataset;final String target=targetMap.trim();if(!target.isBlank()&&(MinecraftClient.getInstance().getCurrentServerEntry()==null||selected==null||!java.util.Objects.equals(dev.minegeo.plugin.ServerAddress.normalize(server()),dev.minegeo.plugin.ServerAddress.normalize(selected.server)))){message("Join the server this capture belongs to before contributing.");return;}int minimum=target.isBlank()?5:1;if(selected==null||selected.points.size()<minimum){message("Capture at least "+minimum+" locations before uploading.");return;}uploading=true;background.execute(()->{try{selected.name=name;selected.save();saveConfig();status="Uploading assets. Interrupted transfers resume when you retry.";new UploadClient(website,token).upload(selected.directory,target.isBlank()?null:target, progress -> status=progress, () -> false);MinecraftClient.getInstance().execute(()->message("Upload received. Preview your selection and submit it in your dashboard."));}catch(Exception e){MinecraftClient.getInstance().execute(()->message("Upload paused: "+e.getMessage()));}finally{uploading=false;}});}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,6 @@ public final class SurfaceDataset {
|
||||
}
|
||||
private JsonObject state(){var g=MineGeoClient.INSTANCE.gson;var j=new JsonObject();j.addProperty("server",server);j.addProperty("name",name);j.addProperty("minX",minX);j.addProperty("minZ",minZ);j.add("viewpoints",g.toJsonTree(points));j.add("coverage",g.toJsonTree(coverage));return j;}
|
||||
private void saveLocal()throws Exception{Files.createDirectories(directory);Files.writeString(directory.resolve("local.json"),MineGeoClient.INSTANCE.gson.toJson(state()));ImageIO.write(image,"png",directory.resolve("map.png").toFile());}
|
||||
public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion","1.21.11");j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",server);var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));}
|
||||
public void save()throws Exception{saveLocal();var j=new JsonObject();j.addProperty("format","minegeo-dataset/v1");j.addProperty("minecraftVersion","1.21.11");j.addProperty("dimension","minecraft:overworld");j.addProperty("north","-z");j.addProperty("source","fabric");j.addProperty("name",name);j.addProperty("description","Player-captured surface map. Checkerboard areas have not been explored.");j.addProperty("serverName",server);if(!server.equals("Singleplayer"))j.addProperty("serverAddress",dev.minegeo.plugin.ServerAddress.normalize(server));var b=new JsonObject();b.addProperty("minX",minX);b.addProperty("maxX",minX+512);b.addProperty("minZ",minZ);b.addProperty("maxZ",minZ+512);j.add("bounds",b);j.add("viewpoints",MineGeoClient.INSTANCE.gson.toJsonTree(points));var chunks=new JsonArray();for(String key:coverage){var v=new JsonArray();for(String number:key.split(","))v.add(Integer.parseInt(number));chunks.add(v);}j.add("coverage",chunks);var assets=new JsonArray();addAsset(assets,"map.png");for(Point p:points)for(String face:Capture.FACES)addAsset(assets,p.id()+"/"+face+".png");j.add("assets",assets);Files.writeString(directory.resolve("manifest.json"),MineGeoClient.INSTANCE.gson.toJson(j));}
|
||||
private void addAsset(JsonArray array,String file)throws Exception{Path p=directory.resolve(file);var j=new JsonObject();j.addProperty("path",file);j.addProperty("bytes",Files.size(p));j.addProperty("sha256",HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(p))));array.add(j);}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public final class MineGeoPlugin extends JavaPlugin {
|
||||
@Override public boolean onCommand(CommandSender sender,Command command,String label,String[] args){
|
||||
if(!sender.hasPermission("minegeo.admin")){sender.sendMessage("MineGeo: administrator permission required.");return true;}
|
||||
try {
|
||||
if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website <http(s)://address:port>","/minegeo link <dashboard token>","/minegeo area <world> <minX> <minZ> <maxX> <maxZ>","/minegeo export <map name> — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo status | cancel | reload"});return true;}
|
||||
if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website <http(s)://address:port>","/minegeo server <hostname[:port]> — capture server address","/minegeo link <dashboard token>","/minegeo area <world> <minX> <minZ> <maxX> <maxZ>","/minegeo export <map name> — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo contribute <map ID> — send up to 12 panoramas to its owner","/minegeo status | cancel | reload"});return true;}
|
||||
switch(args[0].toLowerCase(Locale.ROOT)) {
|
||||
case "area" -> {
|
||||
requireIdle();if(args.length!=6)throw new IllegalArgumentException("Usage: /minegeo area <world> <minX> <minZ> <maxX> <maxZ>");
|
||||
@@ -52,9 +52,11 @@ public final class MineGeoPlugin extends JavaPlugin {
|
||||
case "cancel" -> {cancelled.set(true);sender.sendMessage(busy?"MineGeo cancellation requested. Completed exports are kept.":"MineGeo: no operation is running.");}
|
||||
case "reload" -> {requireIdle();reloadConfig();sender.sendMessage("MineGeo configuration reloaded.");}
|
||||
case "website" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo website <http(s)://address:port>");new UploadClient(args[1],"");getConfig().set("website",args[1]);saveConfig();sender.sendMessage("MineGeo website saved: "+args[1]);}
|
||||
case "server" -> {requireIdle();if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo server <hostname[:port]>");String address=ServerAddress.normalize(args[1]);getConfig().set("server-address",address);saveConfig();sender.sendMessage("Capture server address saved: "+address+". Export again to record it.");}
|
||||
case "link" -> {requireIdle();if(args.length!=2||args[1].isBlank())throw new IllegalArgumentException("Usage: /minegeo link <dashboard token>");getConfig().set("token",args[1]);saveConfig();sender.sendMessage("MineGeo contributor token saved.");}
|
||||
case "export" -> export(sender,args.length>1?String.join(" ",Arrays.copyOfRange(args,1,args.length)):"Overworld Explorer");
|
||||
case "upload" -> upload(sender);
|
||||
case "upload" -> upload(sender,null);
|
||||
case "contribute" -> {if(args.length!=2)throw new IllegalArgumentException("Usage: /minegeo contribute <map ID>");upload(sender,UUID.fromString(args[1]).toString());}
|
||||
default -> throw new IllegalArgumentException("Unknown command. Use /minegeo help.");
|
||||
}
|
||||
}catch(Exception ex){sender.sendMessage("MineGeo: "+ex.getMessage());}return true;
|
||||
@@ -62,17 +64,18 @@ public final class MineGeoPlugin extends JavaPlugin {
|
||||
private void requireIdle(){if(busy)throw new IllegalArgumentException("An operation is running. Use /minegeo status or /minegeo cancel.");}
|
||||
private void report(CommandSender sender,String message){status=message;getLogger().info(message);if(sender instanceof Player&&isEnabled())Bukkit.getScheduler().runTask(this,()->sender.sendMessage("MineGeo: "+message));}
|
||||
private void fail(CommandSender sender,String phase,Exception ex){report(sender,cancelled.get()?"Cancelled. Completed exports are kept.":phase+" failed: "+ex.getMessage());if(!cancelled.get())getLogger().log(Level.WARNING,phase+" failure",ex);}
|
||||
private void upload(CommandSender sender)throws Exception {
|
||||
private void upload(CommandSender sender,String targetMap)throws Exception {
|
||||
requireIdle();if(latest==null||!Files.isRegularFile(latest.resolve("manifest.json")))throw new IOException("No completed export. Run /minegeo export My World, wait for 'Export complete', then /minegeo upload. Files: "+getDataFolder().toPath().resolve("exports").toAbsolutePath());
|
||||
String website=getConfig().getString("website"),token=getConfig().getString("token");if(token==null||token.isBlank())throw new IOException("Create a token in the website dashboard, then /minegeo link <token>.");
|
||||
UploadClient client=new UploadClient(website,token);Path directory=latest;busy=true;cancelled.set(false);status="Uploading "+directory.getFileName();
|
||||
Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{client.upload(directory,message->status=message,cancelled::get);report(sender,"Upload complete. Open "+website+"/dashboard to preview the private draft after processing.");}catch(Exception ex){fail(sender,"Upload",ex);}finally{busy=false;}});
|
||||
Bukkit.getScheduler().runTaskAsynchronously(this,()->{try{client.upload(directory,targetMap,message->status=message,cancelled::get);report(sender,"Upload complete. Open "+website+"/dashboard to preview and submit "+(targetMap==null?"the private map for moderator review.":"the panoramas to the map owner."));}catch(Exception ex){fail(sender,"Upload",ex);}finally{busy=false;}});
|
||||
sender.sendMessage("MineGeo upload started. /minegeo status shows progress; retry /minegeo upload after a connection failure.");
|
||||
}
|
||||
private record Captured(int cx,int cz,ChunkSnapshot snapshot){}
|
||||
private record Area(int minX,int minZ,int maxX,int maxZ){}
|
||||
private void export(CommandSender sender,String name)throws IOException {
|
||||
requireIdle();if(name.length()<3||name.length()>80)throw new IllegalArgumentException("Map name must contain 3–80 characters.");
|
||||
String serverAddress=ServerAddress.normalize(getConfig().getString("server-address",""));
|
||||
String serverName=getConfig().getString("server-name","My Minecraft server");if(serverName.length()<2||serverName.length()>80)throw new IllegalArgumentException("server-name in config.yml must contain 2–80 characters.");
|
||||
World world=selectedWorld!=null?selectedWorld:sender instanceof Player p?p.getWorld():Bukkit.getWorlds().stream().filter(w->w.getEnvironment()==World.Environment.NORMAL).findFirst().orElseThrow();
|
||||
if(world.getEnvironment()!=World.Environment.NORMAL)throw new IllegalArgumentException("Overworld only. Select it with /minegeo area.");
|
||||
@@ -80,11 +83,11 @@ public final class MineGeoPlugin extends JavaPlugin {
|
||||
int half=Math.max(8,Math.min(256,getConfig().getInt("max-area-size",2048)/2));
|
||||
Area area=selectedWorld==null?new Area(center.getBlockX()-half,center.getBlockZ()-half,center.getBlockX()+half,center.getBlockZ()+half):new Area(minX,minZ,maxX,maxZ);
|
||||
Path out=getDataFolder().toPath().resolve("exports").resolve("export-"+System.currentTimeMillis());Files.createDirectories(out);
|
||||
int lower=world.getMinHeight(),upper=world.getMaxHeight(),desired=Math.min(100,Math.max(5,getConfig().getInt("viewpoints",12))),batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1)));
|
||||
int lower=world.getMinHeight(),upper=world.getMaxHeight(),desired=Math.min(24,Math.max(5,getConfig().getInt("viewpoints",24))),batch=Math.max(1,Math.min(4,getConfig().getInt("chunks-per-tick",1)));
|
||||
ArrayBlockingQueue<Captured> queue=new ArrayBlockingQueue<>(4);AtomicBoolean done=new AtomicBoolean();AtomicReference<Exception> failure=new AtomicReference<>();
|
||||
busy=true;cancelled.set(false);status="Export started: "+out.toAbsolutePath();sender.sendMessage("MineGeo: "+status);
|
||||
Bukkit.getScheduler().runTaskAsynchronously(this,()->{
|
||||
try{encode(queue,done,failure,out,name,serverName,area,lower,upper,desired);Path root=getDataFolder().toPath(),pointer=root.resolve("latest-export.txt.tmp");Files.writeString(pointer,root.relativize(out).toString());Files.move(pointer,root.resolve("latest-export.txt"),StandardCopyOption.REPLACE_EXISTING);latest=out;report(sender,"Export complete: "+out.toAbsolutePath()+". Run /minegeo upload.");}
|
||||
try{encode(queue,done,failure,out,name,serverName,serverAddress,area,lower,upper,desired);Path root=getDataFolder().toPath(),pointer=root.resolve("latest-export.txt.tmp");Files.writeString(pointer,root.relativize(out).toString());Files.move(pointer,root.resolve("latest-export.txt"),StandardCopyOption.REPLACE_EXISTING);latest=out;report(sender,"Export complete: "+out.toAbsolutePath()+". Run /minegeo upload.");}
|
||||
catch(Exception ex){failure.set(ex);fail(sender,"Export",ex);}finally{done.set(true);busy=false;}
|
||||
});
|
||||
new BukkitRunnable(){int cx=Math.floorDiv(area.minX,16),cz=Math.floorDiv(area.minZ,16),scanned;final int firstX=cx,lastX=Math.floorDiv(area.maxX-1,16),lastZ=Math.floorDiv(area.maxZ-1,16),total=(lastX-firstX+1)*(lastZ-cz+1);
|
||||
@@ -98,7 +101,7 @@ public final class MineGeoPlugin extends JavaPlugin {
|
||||
}catch(Exception ex){failure.set(ex);done.set(true);cancel();}}
|
||||
}.runTaskTimer(this,1,1);
|
||||
}
|
||||
private void encode(BlockingQueue<Captured> queue,AtomicBoolean done,AtomicReference<Exception> failure,Path out,String name,String serverName,Area area,int lower,int upper,int desired)throws Exception {
|
||||
private void encode(BlockingQueue<Captured> queue,AtomicBoolean done,AtomicReference<Exception> failure,Path out,String name,String serverName,String serverAddress,Area area,int lower,int upper,int desired)throws Exception {
|
||||
JsonObject bounds=new JsonObject();bounds.addProperty("minX",area.minX);bounds.addProperty("minZ",area.minZ);bounds.addProperty("maxX",area.maxX);bounds.addProperty("maxZ",area.maxZ);
|
||||
JsonArray coverage=new JsonArray(),points=new JsonArray();List<int[]> candidates=new ArrayList<>();long blocks=0;Path terrain=out.resolve("terrain.json.gz");
|
||||
try(JsonWriter writer=new JsonWriter(new BufferedWriter(new OutputStreamWriter(new LimitedStream(new GZIPOutputStream(Files.newOutputStream(terrain))),StandardCharsets.UTF_8)))){
|
||||
@@ -123,14 +126,11 @@ public final class MineGeoPlugin extends JavaPlugin {
|
||||
}
|
||||
if(coverage.isEmpty())throw new IOException("No generated chunks here. Select an explored area with /minegeo area.");
|
||||
if(candidates.size()<5)throw new IOException("Need five safe, open surface positions. Select a larger land area (not ocean or treetops).");
|
||||
List<int[]> chosen=new ArrayList<>();chosen.add(candidates.remove(candidates.size()/2));
|
||||
while(chosen.size()<desired&&!candidates.isEmpty()){
|
||||
if(cancelled.get())throw new IOException("Cancelled");int best=0;double bestDistance=-1;
|
||||
for(int i=0;i<candidates.size();i++){int[] p=candidates.get(i);double distance=Double.MAX_VALUE;for(int[] q:chosen)distance=Math.min(distance,Math.pow(p[0]-q[0],2)+Math.pow(p[2]-q[2],2));if(distance>bestDistance){bestDistance=distance;best=i;}}chosen.add(candidates.remove(best));
|
||||
}
|
||||
Collections.shuffle(candidates);
|
||||
List<int[]> chosen=candidates.subList(0,Math.min(desired,candidates.size()));
|
||||
for(int i=0;i<chosen.size();i++){int[] p=chosen.get(i);JsonObject point=new JsonObject();point.addProperty("id","p"+i);point.addProperty("x",p[0]+.5);point.addProperty("y",p[1]+1.62);point.addProperty("z",p[2]+.5);points.add(point);}
|
||||
if(Files.size(terrain)>50L*1024*1024)throw new IOException("Compressed export exceeds 50 MiB. Select a smaller area.");
|
||||
JsonObject manifest=new JsonObject();manifest.addProperty("format","minegeo-dataset/v1");manifest.addProperty("minecraftVersion","1.21.11");manifest.addProperty("dimension","minecraft:overworld");manifest.addProperty("source","plugin");manifest.addProperty("name",name);manifest.addProperty("description","Vanilla surface terrain exported from "+serverName);manifest.addProperty("serverName",serverName);manifest.addProperty("north","-z");manifest.add("bounds",bounds);manifest.add("viewpoints",points);
|
||||
JsonObject manifest=new JsonObject();manifest.addProperty("format","minegeo-dataset/v1");manifest.addProperty("minecraftVersion","1.21.11");manifest.addProperty("dimension","minecraft:overworld");manifest.addProperty("source","plugin");manifest.addProperty("name",name);manifest.addProperty("description","Vanilla surface terrain exported from "+serverName);manifest.addProperty("serverName",serverName);if(serverAddress!=null)manifest.addProperty("serverAddress",serverAddress);manifest.addProperty("north","-z");manifest.add("bounds",bounds);manifest.add("viewpoints",points);
|
||||
JsonArray assets=new JsonArray();JsonObject asset=new JsonObject();asset.addProperty("path","terrain.json.gz");asset.addProperty("bytes",Files.size(terrain));MessageDigest digest=MessageDigest.getInstance("SHA-256");try(InputStream input=Files.newInputStream(terrain)){byte[] buffer=new byte[65536];int read;while((read=input.read(buffer))!=-1)digest.update(buffer,0,read);}asset.addProperty("sha256",HexFormat.of().formatHex(digest.digest()));assets.add(asset);manifest.add("assets",assets);
|
||||
// The manifest is the completion marker. Partial folders never become upload candidates.
|
||||
if(cancelled.get())throw new IOException("Cancelled");Files.writeString(out.resolve("manifest.json.tmp"),gson.toJson(manifest));Files.move(out.resolve("manifest.json.tmp"),out.resolve("manifest.json"),StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package dev.minegeo.plugin;
|
||||
import java.net.InetAddress;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
import java.io.IOException;
|
||||
|
||||
/** Minecraft connection addresses. No DNS lookup or inferred public IP. */
|
||||
public final class ServerAddress {
|
||||
private static final Pattern ADDRESS=Pattern.compile("^(\\[[0-9a-f:]+\\]|[a-z0-9.-]+)(?::([0-9]{1,5}))?$");
|
||||
public static String normalize(String value) {
|
||||
String input=value==null?"":value.trim().toLowerCase(Locale.ROOT);
|
||||
if(input.isEmpty())return null;
|
||||
var match=ADDRESS.matcher(input);
|
||||
if(input.length()>255||!match.matches())throw new IllegalArgumentException("Use a Minecraft hostname or IP, with an optional port.");
|
||||
String host=match.group(1);
|
||||
if(host.startsWith("[")) {
|
||||
try {if(!host.contains(":"))throw new IllegalArgumentException();InetAddress.getByName(host);}catch(Exception e){throw new IllegalArgumentException("Invalid IPv6 address.");}
|
||||
} else {
|
||||
host=host.replaceAll("\\.$","");
|
||||
if(host.length()>253)throw new IllegalArgumentException("Invalid server hostname.");
|
||||
for(String label:host.split("\\.",-1))if(!label.matches("[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"))throw new IllegalArgumentException("Invalid server hostname.");
|
||||
}
|
||||
int port=match.group(2)==null?25565:Integer.parseInt(match.group(2));
|
||||
if(port<1||port>65535)throw new IllegalArgumentException("Server port must be between 1 and 65535.");
|
||||
return host+(port==25565?"":":"+port);
|
||||
}
|
||||
public static void requireMatch(String expected,String captured) throws IOException {
|
||||
String server=normalize(expected);
|
||||
if(server==null)return;
|
||||
String actual=normalize(captured);
|
||||
if(!server.equals(actual))throw new IOException("Wrong capture server: this map requires "+server+", but the export records "+(actual==null?"no server address":actual)+". Join the correct server and capture again. Plugin admins: configure server-address before exporting. The map owner can correct or clear its server address in settings.");
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,37 @@ public final class UploadClient {
|
||||
private void save(Path ledger,JsonObject state)throws IOException{Path tmp=ledger.resolveSibling(ledger.getFileName()+".tmp");Files.writeString(tmp,gson.toJson(state));Files.move(tmp,ledger,StandardCopyOption.REPLACE_EXISTING);}
|
||||
private String hash(byte[] bytes)throws Exception{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));}
|
||||
public void upload(Path directory,Consumer<String> progress,BooleanSupplier cancelled)throws Exception{
|
||||
upload(directory,null,progress,cancelled);
|
||||
}
|
||||
public void upload(Path directory,String targetMap,Consumer<String> progress,BooleanSupplier cancelled)throws Exception{
|
||||
if(targetMap!=null)targetMap=UUID.fromString(targetMap).toString();
|
||||
if(token.isBlank())throw new IOException("Link a contributor account first.");
|
||||
directory=directory.toAbsolutePath().normalize();Path manifestFile=directory.resolve("manifest.json");
|
||||
if(!Files.isRegularFile(manifestFile))throw new IOException("No completed manifest in "+directory+". Export first, wait for completion, then upload.");
|
||||
JsonObject manifest=JsonParser.parseString(Files.readString(manifestFile)).getAsJsonObject();
|
||||
if(targetMap!=null){
|
||||
JsonObject serverInfo=api("/contributions/"+targetMap+"/server",null);
|
||||
ServerAddress.requireMatch(serverInfo.has("serverAddress")&&!serverInfo.get("serverAddress").isJsonNull()?serverInfo.get("serverAddress").getAsString():null,manifest.has("serverAddress")?manifest.get("serverAddress").getAsString():null);
|
||||
String originalHash=hash((website+"\n"+hash(token.getBytes(StandardCharsets.UTF_8))+"\n"+gson.toJson(manifest)).getBytes(StandardCharsets.UTF_8));
|
||||
Path selectionFile=directory.resolve(".contribution-"+targetMap+".json");
|
||||
JsonObject cached=Files.exists(selectionFile)?JsonParser.parseString(Files.readString(selectionFile)).getAsJsonObject():new JsonObject();
|
||||
if(cached.has("hash")&&cached.get("hash").getAsString().equals(originalHash))manifest=cached.getAsJsonObject("manifest");
|
||||
else {
|
||||
JsonObject capacity=api("/contributions/"+targetMap,null),bounds=capacity.getAsJsonObject("bounds");
|
||||
int room=Math.min(12,Math.min(capacity.get("remaining").getAsInt(),capacity.get("playerRemaining").getAsInt()));
|
||||
if(room<1)throw new IOException("This map or your player contribution limit is full. Wait for pending uploads to be reviewed or withdrawn.");
|
||||
manifest.addProperty("contributionTo",targetMap);
|
||||
// Export positions are already random. Keep a stable subset for resumable retries.
|
||||
JsonArray subset=new JsonArray();for(JsonElement point:manifest.getAsJsonArray("viewpoints")){if(subset.size()==room)break;JsonObject p=point.getAsJsonObject();double x=p.get("x").getAsDouble(),z=p.get("z").getAsDouble();if(x>=bounds.get("minX").getAsDouble()&&x<=bounds.get("maxX").getAsDouble()&&z>=bounds.get("minZ").getAsDouble()&&z<=bounds.get("maxZ").getAsDouble())subset.add(point);}
|
||||
if(subset.isEmpty())throw new IOException("Capture at least one panorama inside the original map's bounds, in the same world.");
|
||||
manifest.add("viewpoints",subset);
|
||||
if(manifest.get("source").getAsString().equals("fabric")){
|
||||
Set<String> ids=new HashSet<>();for(JsonElement p:subset)ids.add(p.getAsJsonObject().get("id").getAsString());
|
||||
JsonArray assets=new JsonArray();for(JsonElement a:manifest.getAsJsonArray("assets")){String path=a.getAsJsonObject().get("path").getAsString();if(path.equals("map.png")||ids.contains(path.split("/")[0]))assets.add(a);}manifest.add("assets",assets);
|
||||
}
|
||||
cached=new JsonObject();cached.addProperty("hash",originalHash);cached.add("manifest",manifest);save(selectionFile,cached);
|
||||
}
|
||||
}
|
||||
progress.accept("Checking export files");
|
||||
for(JsonElement element:manifest.getAsJsonArray("assets")){
|
||||
checkCancelled(cancelled);var asset=element.getAsJsonObject();String name=asset.get("path").getAsString();Path file=directory.resolve(name).normalize();
|
||||
@@ -48,7 +75,7 @@ public final class UploadClient {
|
||||
MessageDigest digest=MessageDigest.getInstance("SHA-256");try(InputStream in=Files.newInputStream(file)){byte[] buffer=new byte[65536];int n;while((n=in.read(buffer))!=-1){checkCancelled(cancelled);digest.update(buffer,0,n);}}
|
||||
if(!HexFormat.of().formatHex(digest.digest()).equals(asset.get("sha256").getAsString()))throw new IOException("Export asset checksum changed: "+name+". Export again.");
|
||||
}
|
||||
Path ledger=directory.resolve(".upload-state.json");JsonObject state=Files.exists(ledger)?JsonParser.parseString(Files.readString(ledger)).getAsJsonObject():new JsonObject();
|
||||
Path ledger=directory.resolve(targetMap==null?".upload-state.json":".upload-state-"+targetMap+".json");JsonObject state=Files.exists(ledger)?JsonParser.parseString(Files.readString(ledger)).getAsJsonObject():new JsonObject();
|
||||
String fingerprint=hash((website+"\n"+hash(token.getBytes(StandardCharsets.UTF_8))+"\n"+gson.toJson(manifest)).getBytes(StandardCharsets.UTF_8));
|
||||
if(!state.has("hash")||!state.get("hash").getAsString().equals(fingerprint)){
|
||||
state=new JsonObject();state.addProperty("hash",fingerprint);JsonObject body=new JsonObject();body.add("manifest",manifest);progress.accept("Creating private draft");checkCancelled(cancelled);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
website: 'http://localhost:3000'
|
||||
token: ''
|
||||
server-name: 'My Minecraft server'
|
||||
# Public Minecraft connection address, matching the address used by Fabric players.
|
||||
# Set this before exporting. A blank address cannot contribute to checked maps.
|
||||
server-address: ''
|
||||
max-area-size: 2048
|
||||
chunks-per-tick: 1
|
||||
viewpoints: 12
|
||||
viewpoints: 24
|
||||
|
||||
5
proxy.ts
5
proxy.ts
@@ -24,9 +24,6 @@ export async function proxy(request: NextRequest) {
|
||||
}
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/dashboard/:path*",
|
||||
"/moderation/:path*",
|
||||
"/pair",
|
||||
"/api/v1/:path*",
|
||||
"/((?!_next/static|_next/image|favicon.ico|tools/|fonts/|images/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|ttf|woff|woff2|jar)$).*)",
|
||||
],
|
||||
};
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -11,6 +11,7 @@ await new Promise((resolve, reject) => {
|
||||
cwd: "minecraft",
|
||||
stdio: "inherit",
|
||||
shell: windows,
|
||||
windowsHide: true,
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) =>
|
||||
|
||||
@@ -30,6 +30,7 @@ const dataset = check(
|
||||
if (
|
||||
dataset.source !== "plugin" ||
|
||||
dataset.revision_id ||
|
||||
dataset.parent_dataset_id ||
|
||||
!["draft", "rejected"].includes(dataset.state)
|
||||
)
|
||||
throw new Error(
|
||||
@@ -52,6 +53,14 @@ const input = check(
|
||||
.single(),
|
||||
);
|
||||
if (!input.validated) throw new Error("The export must be validated first.");
|
||||
if (
|
||||
(input.manifest.availableViewpoints ?? input.manifest.viewpoints).some(
|
||||
(p) => p.assetRoot,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Repair mixed-media drafts by uploading a new capture; accepted contribution files cannot be overwritten.",
|
||||
);
|
||||
const asset = input.manifest.assets.find((a) => a.path === "terrain.json.gz");
|
||||
if (!asset) throw new Error("Missing original terrain export");
|
||||
const raw = check(
|
||||
|
||||
@@ -29,10 +29,11 @@ const dataset = check(
|
||||
);
|
||||
if (
|
||||
dataset.revision_id ||
|
||||
dataset.parent_dataset_id ||
|
||||
!["draft", "review", "rejected", "failed"].includes(dataset.state)
|
||||
)
|
||||
throw new Error(
|
||||
"Only an unpublished, inactive draft can be reprocessed. Published revisions cannot be changed.",
|
||||
"Only an unpublished, inactive original capture can be reprocessed. Published revisions cannot be changed. Contributions cannot be overwritten; upload a new capture instead.",
|
||||
);
|
||||
const job = check(
|
||||
await client.from("upload_jobs").select("*").eq("dataset_id", id).single(),
|
||||
@@ -48,6 +49,14 @@ const input = check(
|
||||
.eq("dataset_id", id)
|
||||
.single(),
|
||||
);
|
||||
if (
|
||||
(input.manifest.availableViewpoints ?? input.manifest.viewpoints).some(
|
||||
(p) => p.assetRoot,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"This draft contains accepted contributions. Upload a new capture instead of reprocessing mixed media.",
|
||||
);
|
||||
if (dataset.source === "plugin") await prepareAssets();
|
||||
|
||||
// This maintenance command runs with the worker stopped. Compare-and-set
|
||||
|
||||
@@ -26,6 +26,7 @@ let manifest: any,
|
||||
creates = 0,
|
||||
finalizes = 0,
|
||||
heads = 0;
|
||||
let expectedServer: string | null = "play.regression.example.com";
|
||||
const api = createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
@@ -37,9 +38,24 @@ const api = createServer(async (req, res) => {
|
||||
const origin = `http://127.0.0.1:${(api.address() as any).port}`;
|
||||
if (req.url === "/api/v1/uploads") {
|
||||
creates++;
|
||||
stage = "uploading";
|
||||
manifest = JSON.parse(bytes.toString()).manifest;
|
||||
json({ datasetId: "fixture" });
|
||||
} else if (req.url === "/api/v1/datasets/fixture") json({ state: stage });
|
||||
} else if (
|
||||
req.url === "/api/v1/contributions/00000000-0000-4000-8000-000000000024"
|
||||
)
|
||||
json({
|
||||
remaining: 19,
|
||||
playerRemaining: 12,
|
||||
serverAddress: expectedServer,
|
||||
bounds: { minX: -32, minZ: -32, maxX: 32, maxZ: 32 },
|
||||
});
|
||||
else if (
|
||||
req.url ===
|
||||
"/api/v1/contributions/00000000-0000-4000-8000-000000000024/server"
|
||||
)
|
||||
json({ serverAddress: expectedServer });
|
||||
else if (req.url === "/api/v1/datasets/fixture") json({ state: stage });
|
||||
else if (req.url === "/api/v1/uploads/sign")
|
||||
json({
|
||||
assets: manifest.assets.map((a: any) => ({
|
||||
@@ -85,7 +101,7 @@ const api = createServer(async (req, res) => {
|
||||
await new Promise<void>((r) => api.listen(0, "127.0.0.1", r));
|
||||
await writeFile(
|
||||
`${directory}/plugins/MineGeo/config.yml`,
|
||||
`website: 'http://127.0.0.1:${(api.address() as any).port}'\ntoken: 'fixture-token'\nserver-name: 'Regression world'\nchunks-per-tick: 2\nviewpoints: 5\nmax-area-size: 2048\n`,
|
||||
`website: 'http://127.0.0.1:${(api.address() as any).port}'\ntoken: 'fixture-token'\nserver-name: 'Regression world'\nserver-address: 'play.regression.example.com'\nchunks-per-tick: 2\nviewpoints: 24\nmax-area-size: 2048\n`,
|
||||
);
|
||||
const child = spawn("java", ["-Xmx1536M", "-jar", "paper.jar", "--nogui"], {
|
||||
cwd: directory,
|
||||
@@ -163,14 +179,15 @@ try {
|
||||
child.stdin.write("forceload add -32 -32 31 31\n");
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
await command("minegeo export Regression World", /Export complete:/);
|
||||
const relative = (
|
||||
let relative = (
|
||||
await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8")
|
||||
).trim();
|
||||
const exportDir = `${directory}/plugins/MineGeo/${relative}`;
|
||||
const local = datasetSchema.parse(
|
||||
JSON.parse(await readFile(`${exportDir}/manifest.json`, "utf8")),
|
||||
);
|
||||
assert.equal(local.viewpoints.length, 5);
|
||||
assert.equal(local.viewpoints.length, 24);
|
||||
assert.equal(local.serverAddress, "play.regression.example.com");
|
||||
const terrain = JSON.parse(
|
||||
gunzipSync(await readFile(`${exportDir}/terrain.json.gz`)).toString(),
|
||||
);
|
||||
@@ -188,6 +205,51 @@ try {
|
||||
assert.equal(heads, 1);
|
||||
await command("minegeo upload", /Upload complete\. Open/);
|
||||
assert.equal(finalizes, 1);
|
||||
received = Buffer.alloc(0);
|
||||
expectedServer = "wrong.example.com";
|
||||
await command(
|
||||
"minegeo contribute 00000000-0000-4000-8000-000000000024",
|
||||
/Wrong capture server/,
|
||||
);
|
||||
assert.equal(creates, 1, "Mismatch must stop before creating a draft");
|
||||
expectedServer = "PLAY.REGRESSION.EXAMPLE.COM.:25565";
|
||||
await command(
|
||||
"minegeo contribute 00000000-0000-4000-8000-000000000024",
|
||||
/Upload complete\. Open/,
|
||||
);
|
||||
assert.equal(manifest.contributionTo, "00000000-0000-4000-8000-000000000024");
|
||||
assert.equal(manifest.viewpoints.length, 12);
|
||||
assert.equal(creates, 2);
|
||||
expectedServer = "wrong.example.com";
|
||||
await command(
|
||||
"minegeo contribute 00000000-0000-4000-8000-000000000024",
|
||||
/Wrong capture server/,
|
||||
);
|
||||
assert.equal(creates, 2, "A cached selection must recheck the server");
|
||||
expectedServer = null;
|
||||
await command(
|
||||
"minegeo contribute 00000000-0000-4000-8000-000000000024",
|
||||
/Upload complete\. Open/,
|
||||
);
|
||||
assert.equal(creates, 2, "Cleared server permits an idempotent retry");
|
||||
assert.equal(finalizes, 2);
|
||||
await command(
|
||||
"minegeo contribute 00000000-0000-4000-8000-000000000024",
|
||||
/Upload complete\. Open/,
|
||||
);
|
||||
assert.equal(creates, 2);
|
||||
await command("minegeo export Random Positions", /Export complete:/);
|
||||
relative = (
|
||||
await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8")
|
||||
).trim();
|
||||
const repeated = JSON.parse(
|
||||
await readFile(
|
||||
`${directory}/plugins/MineGeo/${relative}/manifest.json`,
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
assert.equal(repeated.viewpoints.length, 24);
|
||||
assert.notDeepEqual(repeated.viewpoints, local.viewpoints);
|
||||
await command("minegeo area world -256 -256 256 256", /area selected/);
|
||||
const cancelled = wait(/Cancelled\. Completed exports are kept/);
|
||||
child.stdin.write("minegeo export Cancel Test\nminegeo cancel\n");
|
||||
@@ -211,6 +273,10 @@ try {
|
||||
"TUS interruption and byte-perfect resume",
|
||||
"repeated upload is idempotent",
|
||||
"cancellation preserves completed export",
|
||||
"24 random positions change on the next export",
|
||||
"contribution command caps at 12 and retries idempotently",
|
||||
"wrong server blocked before upload, including cached retry",
|
||||
"owner override/default port and cleared address supported",
|
||||
],
|
||||
blocks: terrain.blocks.length,
|
||||
chunks: terrain.coverage.length,
|
||||
|
||||
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}`);
|
||||
}
|
||||
172
supabase/migrations/20261005063646_panorama_contributions.sql
Normal file
172
supabase/migrations/20261005063646_panorama_contributions.sql
Normal file
@@ -0,0 +1,172 @@
|
||||
-- Contributions reuse the private upload pipeline. Only root maps can be published.
|
||||
alter table public.datasets
|
||||
add column parent_dataset_id uuid references public.datasets(id),
|
||||
add column allow_contributions boolean not null default false,
|
||||
add column contribution_status text check (contribution_status in ('pending','submitted','accepted','rejected')),
|
||||
add column contribution_count integer not null default 0 check (contribution_count between 0 and 24),
|
||||
add column update_state text check (update_state in ('draft','review','rejected'));
|
||||
create index datasets_contribution_parent on public.datasets(parent_dataset_id,owner_id) where parent_dataset_id is not null;
|
||||
|
||||
create function public.contribution_capacity(p_dataset uuid,p_owner uuid) returns jsonb
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare d public.datasets; m jsonb; reserved integer; used integer; pool integer;
|
||||
begin
|
||||
select * into d from public.datasets where id=p_dataset for update;
|
||||
if not found or d.parent_dataset_id is not null or d.state not in ('draft','rejected','published') then raise exception 'Map does not accept contributions at this time'; end if;
|
||||
if d.owner_id<>p_owner and not d.allow_contributions then raise exception 'The owner has disabled player contributions'; end if;
|
||||
select manifest into m from public.dataset_inputs where dataset_id=p_dataset;
|
||||
pool:=jsonb_array_length(coalesce(m->'availableViewpoints',m->'viewpoints'));
|
||||
select coalesce(sum(contribution_count) filter(where contribution_status in ('pending','submitted') and state<>'failed'),0),
|
||||
coalesce(sum(contribution_count) filter(where owner_id=p_owner and (contribution_status='accepted' or (contribution_status in ('pending','submitted') and state<>'failed'))),0)
|
||||
into reserved,used from public.datasets where parent_dataset_id=p_dataset;
|
||||
return jsonb_build_object('id',d.id,'name',d.name,'bounds',d.bounds,'remaining',greatest(0,24-pool-reserved),
|
||||
'playerRemaining',greatest(0,case when d.owner_id=p_owner then 24 else 12 end-used),'isOwner',d.owner_id=p_owner);
|
||||
end; $$;
|
||||
|
||||
create function public.create_upload(p_owner uuid,p_manifest jsonb) returns uuid
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare target uuid; capacity jsonb; server uuid; result uuid; count integer; b jsonb;
|
||||
begin
|
||||
target:=(p_manifest->>'contributionTo')::uuid;
|
||||
count:=jsonb_array_length(p_manifest->'viewpoints');
|
||||
if count<1 or count>24 or (target is null and count<5) then raise exception 'Maps require 5–24 panoramas'; end if;
|
||||
if exists(select 1 from jsonb_array_elements(p_manifest->'viewpoints') p where p ?| array['assetRoot','assetId','contributorId','source']) or p_manifest ? 'availableViewpoints' then raise exception 'Upload contains server-only media provenance'; end if;
|
||||
if target is not null then
|
||||
capacity:=public.contribution_capacity(target,p_owner); b:=capacity->'bounds';
|
||||
if count>(capacity->>'remaining')::integer then raise exception 'Map limit is 24 panoramas, including pending uploads'; end if;
|
||||
if count>(capacity->>'playerRemaining')::integer then raise exception 'Each player may contribute at most 12 panoramas per map'; end if;
|
||||
if exists(select 1 from jsonb_array_elements(p_manifest->'viewpoints') p where
|
||||
(p->>'x')::numeric<(b->>'minX')::numeric or (p->>'x')::numeric>(b->>'maxX')::numeric or
|
||||
(p->>'z')::numeric<(b->>'minZ')::numeric or (p->>'z')::numeric>(b->>'maxZ')::numeric) then raise exception 'Contributed panoramas must be inside the original map bounds'; end if;
|
||||
select server_id into server from public.datasets where id=target;
|
||||
else
|
||||
select id into server from public.catalog_servers where name=p_manifest->>'serverName' limit 1;
|
||||
if server is null then insert into public.catalog_servers(name,address) values(p_manifest->>'serverName',p_manifest->>'serverAddress') returning id into server; end if;
|
||||
end if;
|
||||
insert into public.datasets(owner_id,server_id,name,description,source,bounds,parent_dataset_id,contribution_status,contribution_count)
|
||||
values(p_owner,server,p_manifest->>'name',coalesce(p_manifest->>'description',''),p_manifest->>'source',p_manifest->'bounds',target,
|
||||
case when target is not null then 'pending' end,case when target is not null then count else 0 end) returning id into result;
|
||||
insert into public.dataset_inputs(dataset_id,manifest) values(result,p_manifest);
|
||||
return result;
|
||||
end; $$;
|
||||
|
||||
create or replace function public.complete_job(p_job uuid,p_worker text,p_count integer,p_cover text) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare j public.upload_jobs; d public.datasets;
|
||||
begin
|
||||
select * into j from public.upload_jobs where id=p_job and lease_owner=p_worker and state='running' and lease_until>now() for update;
|
||||
if not found then raise exception 'Worker lease lost'; end if;
|
||||
select * into d from public.datasets where id=j.dataset_id for update;
|
||||
if p_count<(case when d.parent_dataset_id is null then 5 else 1 end) or p_count>24 then raise exception 'Invalid panorama count'; end if;
|
||||
update public.datasets set state='draft',panorama_count=p_count,map_path=j.dataset_id::text||'/map.webp',cover_path=j.dataset_id::text||'/'||p_cover||'/preview.webp',updated_at=now() where id=j.dataset_id and state='processing';
|
||||
if not found then raise exception 'Dataset processing cancelled'; end if;
|
||||
update public.dataset_inputs set validated=true where dataset_id=j.dataset_id;
|
||||
update public.upload_jobs set state='complete',progress=100,lease_until=null,error=null,updated_at=now() where id=p_job;
|
||||
end; $$;
|
||||
|
||||
-- Lock before selecting locations, so acceptance cannot race with owner edits.
|
||||
create function public.save_draft(p_dataset uuid,p_owner uuid,p_name text,p_description text,p_ids jsonb) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare d public.datasets; m jsonb; available jsonb; selected jsonb; count integer; first jsonb;
|
||||
begin
|
||||
select * into d from public.datasets where id=p_dataset and owner_id=p_owner for update;
|
||||
if not found then raise exception 'Dataset not found'; end if;
|
||||
if d.state not in ('draft','rejected','published') or d.update_state='review' or d.contribution_status in ('submitted','accepted','rejected') then raise exception 'Draft cannot be edited while awaiting approval'; end if;
|
||||
if length(p_name)<3 or length(p_name)>80 or length(p_description)>2000 then raise exception 'Invalid map details'; end if;
|
||||
select manifest into m from public.dataset_inputs where dataset_id=p_dataset and validated;
|
||||
if m is null then raise exception 'Dataset not validated'; end if;
|
||||
available:=coalesce(m->'availableViewpoints',m->'viewpoints');
|
||||
select coalesce(jsonb_agg(p order by n),'[]') into selected from jsonb_array_elements(available) with ordinality as pts(p,n) where p_ids ? (p->>'id');
|
||||
count:=jsonb_array_length(selected);
|
||||
if count<>jsonb_array_length(p_ids) or count<(case when d.parent_dataset_id is null then 5 else 1 end) or count>24 then raise exception 'Choose unique captured locations within the panorama limit'; end if;
|
||||
m:=m||jsonb_build_object('name',p_name,'description',p_description,'availableViewpoints',available,'viewpoints',selected);
|
||||
update public.dataset_inputs set manifest=m where dataset_id=p_dataset;
|
||||
first:=selected->0;
|
||||
update public.datasets set name=case when state='published' then name else p_name end,
|
||||
description=case when state='published' then description else p_description end,
|
||||
panorama_count=case when state='published' then panorama_count else count end,
|
||||
cover_path=case when state='published' then cover_path else coalesce(first->>'assetRoot',id::text)||'/'||coalesce(first->>'assetId',first->>'id')||'/preview.webp' end,
|
||||
state=case when state='published' then state else 'draft' end,
|
||||
update_state=case when state='published' then 'draft' else null end,rejection_reason=null,updated_at=now() where id=p_dataset;
|
||||
end; $$;
|
||||
|
||||
create function public.submit_draft(p_dataset uuid,p_owner uuid) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare d public.datasets; count integer;
|
||||
begin
|
||||
select * into d from public.datasets where id=p_dataset and owner_id=p_owner for update;
|
||||
if not found then raise exception 'Dataset not found'; end if;
|
||||
if d.state not in ('draft','rejected','published') or (d.state='published' and coalesce(d.update_state,'') not in ('draft','rejected')) or d.contribution_status in ('submitted','accepted','rejected') then raise exception 'Draft is not ready for submission'; end if;
|
||||
select jsonb_array_length(manifest->'viewpoints') into count from public.dataset_inputs where dataset_id=p_dataset and validated;
|
||||
if count is null or count<(case when d.parent_dataset_id is null then 5 else 1 end) or count>24 then raise exception 'Invalid panorama count'; end if;
|
||||
update public.datasets set state=case when state='published' then state else 'review' end,
|
||||
update_state=case when state='published' then 'review' end,
|
||||
contribution_status=case when parent_dataset_id is not null then 'submitted' end,updated_at=now() where id=p_dataset;
|
||||
end; $$;
|
||||
|
||||
create function public.set_contributions(p_dataset uuid,p_owner uuid,p_allowed boolean) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
begin
|
||||
perform 1 from public.datasets where id=p_dataset and owner_id=p_owner and parent_dataset_id is null for update;
|
||||
if not found then raise exception 'Map owner permission required'; end if;
|
||||
update public.datasets set allow_contributions=p_allowed,updated_at=now() where id=p_dataset;
|
||||
end; $$;
|
||||
|
||||
create function public.review_contribution(p_dataset uuid,p_contribution uuid,p_owner uuid,p_decision text,p_reason text default null) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare d public.datasets; c public.datasets; m jsonb; cm jsonb; added jsonb; pool jsonb; chosen jsonb;
|
||||
begin
|
||||
select * into d from public.datasets where id=p_dataset and owner_id=p_owner and parent_dataset_id is null for update;
|
||||
if not found then raise exception 'Map owner permission required'; end if;
|
||||
if d.state not in ('draft','rejected','published') or d.update_state='review' then raise exception 'Wait until the current map review finishes'; end if;
|
||||
select * into c from public.datasets where id=p_contribution and parent_dataset_id=p_dataset for update;
|
||||
if not found or c.contribution_status<>'submitted' or c.state<>'review' then raise exception 'Contribution is not awaiting owner approval'; end if;
|
||||
if p_decision='reject' then
|
||||
if coalesce(length(trim(p_reason)),0)=0 then raise exception 'Provide feedback for the contributor'; end if;
|
||||
update public.datasets set state='rejected',contribution_status='rejected',rejection_reason=p_reason,updated_at=now() where id=c.id;
|
||||
return;
|
||||
end if;
|
||||
if p_decision<>'accept' then raise exception 'Invalid contribution decision'; end if;
|
||||
select manifest into cm from public.dataset_inputs where dataset_id=c.id and validated;
|
||||
select manifest into m from public.dataset_inputs where dataset_id=d.id and validated;
|
||||
if cm is null or m is null then raise exception 'Dataset not validated'; end if;
|
||||
select jsonb_agg(p||jsonb_build_object('id','c'||replace(c.id::text,'-','')||'_'||n,'assetRoot',c.id,'assetId',p->>'id','contributorId',c.owner_id,'source',c.source) order by n)
|
||||
into added from jsonb_array_elements(cm->'viewpoints') with ordinality as pts(p,n);
|
||||
pool:=coalesce(m->'availableViewpoints',m->'viewpoints')||added;
|
||||
if jsonb_array_length(pool)>24 then raise exception 'Map limit is 24 panoramas'; end if;
|
||||
if c.owner_id<>d.owner_id and (select coalesce(sum(contribution_count),0) from public.datasets where parent_dataset_id=d.id and owner_id=c.owner_id and contribution_status='accepted')+jsonb_array_length(added)>12 then raise exception 'Each player may contribute at most 12 panoramas per map'; end if;
|
||||
chosen:=(m->'viewpoints')||added;
|
||||
update public.dataset_inputs set manifest=m||jsonb_build_object('availableViewpoints',pool,'viewpoints',chosen) where dataset_id=d.id;
|
||||
update public.datasets set state=case when state='published' then state else 'draft' end,
|
||||
update_state=case when state='published' then 'draft' end,
|
||||
panorama_count=case when state='published' then panorama_count else jsonb_array_length(chosen) end,rejection_reason=null,updated_at=now() where id=d.id;
|
||||
update public.datasets set contribution_status='accepted',contribution_count=jsonb_array_length(added),updated_at=now() where id=c.id;
|
||||
end; $$;
|
||||
|
||||
create or replace function public.publish_dataset(p_dataset uuid,p_reviewer uuid,p_decision text,p_reason text default null) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare d public.datasets; m jsonb; rev uuid; first jsonb; count integer;
|
||||
begin
|
||||
if not exists(select 1 from public.profiles where id=p_reviewer and role='moderator') then raise exception 'Moderator permission required'; end if;
|
||||
select * into d from public.datasets where id=p_dataset and parent_dataset_id is null for update;
|
||||
if not found then raise exception 'Map not found'; end if;
|
||||
if p_decision='approve' then
|
||||
if not(d.state='review' or (d.state='published' and coalesce(d.update_state,'')='review')) then raise exception 'Map is not ready for publication'; end if;
|
||||
select manifest into m from public.dataset_inputs where dataset_id=p_dataset and validated;
|
||||
count:=jsonb_array_length(m->'viewpoints'); first:=m->'viewpoints'->0;
|
||||
if m is null or count<5 or count>24 then raise exception 'Map requires 5–24 validated panoramas'; end if;
|
||||
insert into public.revisions(dataset_id,manifest,asset_root) values(p_dataset,m,p_dataset::text) returning id into rev;
|
||||
update public.datasets set state='published',revision_id=rev,update_state=null,name=m->>'name',description=m->>'description',panorama_count=count,
|
||||
cover_path=coalesce(first->>'assetRoot',id::text)||'/'||coalesce(first->>'assetId',first->>'id')||'/preview.webp',rejection_reason=null,updated_at=now() where id=p_dataset;
|
||||
elsif p_decision='reject' and (d.state='review' or (d.state='published' and d.update_state='review')) then
|
||||
if coalesce(length(trim(p_reason)),0)=0 then raise exception 'Provide a rejection reason'; end if;
|
||||
update public.datasets set state=case when state='published' then state else 'rejected' end,update_state=case when state='published' then 'rejected' end,rejection_reason=p_reason,updated_at=now() where id=p_dataset;
|
||||
elsif p_decision='unpublish' and d.state='published' then
|
||||
update public.datasets set state='unpublished',update_state=null,updated_at=now() where id=p_dataset;
|
||||
else raise exception 'Invalid review transition'; end if;
|
||||
insert into public.review_decisions(dataset_id,reviewer_id,decision,reason) values(p_dataset,p_reviewer,p_decision,p_reason);
|
||||
end; $$;
|
||||
|
||||
revoke execute on function public.contribution_capacity(uuid,uuid),public.create_upload(uuid,jsonb),public.save_draft(uuid,uuid,text,text,jsonb),public.submit_draft(uuid,uuid),public.set_contributions(uuid,uuid,boolean),public.review_contribution(uuid,uuid,uuid,text,text) from public,anon,authenticated;
|
||||
grant execute on function public.contribution_capacity(uuid,uuid),public.create_upload(uuid,jsonb),public.save_draft(uuid,uuid,text,text,jsonb),public.submit_draft(uuid,uuid),public.set_contributions(uuid,uuid,boolean),public.review_contribution(uuid,uuid,uuid,text,text) to service_role;
|
||||
|
||||
16
supabase/migrations/20261005065029_withdraw_contribution.sql
Normal file
16
supabase/migrations/20261005065029_withdraw_contribution.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
create function public.withdraw_contribution(p_dataset uuid,p_owner uuid) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare parent uuid; d public.datasets;
|
||||
begin
|
||||
select parent_dataset_id into parent from public.datasets where id=p_dataset and owner_id=p_owner;
|
||||
if parent is null then raise exception 'Contribution not found'; end if;
|
||||
perform 1 from public.datasets where id=parent for update;
|
||||
-- Match the worker's job-before-dataset lock order.
|
||||
perform 1 from public.upload_jobs where dataset_id=p_dataset for update;
|
||||
select * into d from public.datasets where id=p_dataset and owner_id=p_owner for update;
|
||||
if d.contribution_status not in ('pending','submitted') then raise exception 'Only pending contributions can be withdrawn'; end if;
|
||||
update public.upload_jobs set state='cancelled',updated_at=now() where dataset_id=p_dataset and state in ('queued','running');
|
||||
update public.datasets set state='failed',contribution_status='rejected',rejection_reason='Withdrawn by contributor',updated_at=now() where id=p_dataset;
|
||||
end; $$;
|
||||
revoke execute on function public.withdraw_contribution(uuid,uuid) from public,anon,authenticated;
|
||||
grant execute on function public.withdraw_contribution(uuid,uuid) to service_role;
|
||||
187
supabase/migrations/20261005073253_contribution_server_check.sql
Normal file
187
supabase/migrations/20261005073253_contribution_server_check.sql
Normal file
@@ -0,0 +1,187 @@
|
||||
-- Per-map connection address; NULL explicitly disables the capture server check.
|
||||
alter table public.datasets add column contribution_server_address text;
|
||||
|
||||
create function public.normalize_server_address(p_address text) returns text
|
||||
language plpgsql immutable security invoker set search_path='' as $$
|
||||
declare address text:=lower(btrim(p_address)); parts text[]; hostname text; label text; port integer;
|
||||
begin
|
||||
if address is null or address='' then return null; end if;
|
||||
if length(address)>255 then raise exception 'Server address is too long'; end if;
|
||||
parts:=regexp_match(address,'^(\[[0-9a-f:]+\]|[a-z0-9.-]+)(:([0-9]{1,5}))?$');
|
||||
if parts is null then raise exception 'Use a Minecraft hostname or IP, with an optional port'; end if;
|
||||
hostname:=parts[1]; port:=coalesce(parts[3]::integer,25565);
|
||||
if left(hostname,1)='[' then
|
||||
if position(':' in hostname)=0 then raise exception 'Invalid IPv6 address'; end if;
|
||||
perform trim(both '[]' from hostname)::inet;
|
||||
else
|
||||
hostname:=regexp_replace(hostname,'\.$','');
|
||||
if length(hostname)>253 then raise exception 'Invalid server hostname'; end if;
|
||||
foreach label in array string_to_array(hostname,'.') loop
|
||||
if label !~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' then raise exception 'Invalid server hostname'; end if;
|
||||
end loop;
|
||||
end if;
|
||||
if port<1 or port>65535 then raise exception 'Server port must be between 1 and 65535'; end if;
|
||||
return hostname||case when port=25565 then '' else ':'||port::text end;
|
||||
end; $$;
|
||||
|
||||
create function public.assert_capture_server(p_expected text,p_actual text) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare expected text:=public.normalize_server_address(p_expected); actual text;
|
||||
begin
|
||||
if expected is null then return; end if;
|
||||
actual:=public.normalize_server_address(p_actual);
|
||||
if actual is distinct from expected then
|
||||
raise exception 'Wrong capture server: this map requires %, but the export records %. Capture on the correct server, or ask the owner to correct or clear the server address in map settings.', expected,coalesce(actual,'no server address');
|
||||
end if;
|
||||
end; $$;
|
||||
|
||||
-- Use each map's own captured address, never another map sharing its server name.
|
||||
do $$ declare d record; begin
|
||||
for d in select dataset_id,manifest->>'serverAddress' as address from public.dataset_inputs where manifest ? 'serverAddress' loop
|
||||
begin
|
||||
update public.datasets set contribution_server_address=public.normalize_server_address(d.address) where id=d.dataset_id and parent_dataset_id is null;
|
||||
exception when others then null; -- Legacy free-text addresses require owner correction.
|
||||
end;
|
||||
end loop;
|
||||
end; $$;
|
||||
|
||||
create function public.set_contribution_server(p_dataset uuid,p_owner uuid,p_address text) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
begin
|
||||
perform 1 from public.datasets where id=p_dataset and owner_id=p_owner and parent_dataset_id is null for update;
|
||||
if not found then raise exception 'Map owner permission required'; end if;
|
||||
update public.datasets set contribution_server_address=public.normalize_server_address(p_address),updated_at=now() where id=p_dataset;
|
||||
end; $$;
|
||||
|
||||
-- Available during resumable retries without reserving more panorama slots.
|
||||
create function public.contribution_server(p_dataset uuid,p_owner uuid) returns jsonb
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare d public.datasets;
|
||||
begin
|
||||
select * into d from public.datasets where id=p_dataset and parent_dataset_id is null;
|
||||
if not found then raise exception 'Map not found'; end if;
|
||||
if d.owner_id<>p_owner and not d.allow_contributions then raise exception 'The owner has disabled player contributions'; end if;
|
||||
return jsonb_build_object('serverAddress',d.contribution_server_address);
|
||||
end; $$;
|
||||
|
||||
-- Used by signing and finalizing retries as well as new uploads.
|
||||
create function public.check_contribution_server(p_dataset uuid,p_owner uuid) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare target uuid; expected text; manifest jsonb;
|
||||
begin
|
||||
select parent_dataset_id into target from public.datasets where id=p_dataset and owner_id=p_owner;
|
||||
if not found then raise exception 'Dataset not found'; end if;
|
||||
if target is null then return; end if;
|
||||
select contribution_server_address into expected from public.datasets where id=target for update;
|
||||
select i.manifest into manifest from public.dataset_inputs i where dataset_id=p_dataset;
|
||||
perform public.assert_capture_server(expected,manifest->>'serverAddress');
|
||||
end; $$;
|
||||
|
||||
create or replace function public.contribution_capacity(p_dataset uuid,p_owner uuid) returns jsonb
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare d public.datasets; m jsonb; reserved integer; used integer; pool integer;
|
||||
begin
|
||||
select * into d from public.datasets where id=p_dataset for update;
|
||||
if not found or d.parent_dataset_id is not null or d.state not in ('draft','rejected','published') then raise exception 'Map does not accept contributions at this time'; end if;
|
||||
if d.owner_id<>p_owner and not d.allow_contributions then raise exception 'The owner has disabled player contributions'; end if;
|
||||
select manifest into m from public.dataset_inputs where dataset_id=p_dataset;
|
||||
pool:=jsonb_array_length(coalesce(m->'availableViewpoints',m->'viewpoints'));
|
||||
select coalesce(sum(contribution_count) filter(where contribution_status in ('pending','submitted') and state<>'failed'),0),
|
||||
coalesce(sum(contribution_count) filter(where owner_id=p_owner and (contribution_status='accepted' or (contribution_status in ('pending','submitted') and state<>'failed'))),0)
|
||||
into reserved,used from public.datasets where parent_dataset_id=p_dataset;
|
||||
return jsonb_build_object('serverAddress',d.contribution_server_address,'id',d.id,'name',d.name,'bounds',d.bounds,'remaining',greatest(0,24-pool-reserved),
|
||||
'playerRemaining',greatest(0,case when d.owner_id=p_owner then 24 else 12 end-used),'isOwner',d.owner_id=p_owner);
|
||||
end; $$;
|
||||
|
||||
|
||||
create or replace function public.create_upload(p_owner uuid,p_manifest jsonb) returns uuid
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare target uuid; capacity jsonb; server uuid; result uuid; count integer; b jsonb;
|
||||
begin
|
||||
target:=(p_manifest->>'contributionTo')::uuid;
|
||||
count:=jsonb_array_length(p_manifest->'viewpoints');
|
||||
if count<1 or count>24 or (target is null and count<5) then raise exception 'Maps require 5–24 panoramas'; end if;
|
||||
if exists(select 1 from jsonb_array_elements(p_manifest->'viewpoints') p where p ?| array['assetRoot','assetId','contributorId','source']) or p_manifest ? 'availableViewpoints' then raise exception 'Upload contains server-only media provenance'; end if;
|
||||
if target is not null then
|
||||
capacity:=public.contribution_capacity(target,p_owner); b:=capacity->'bounds';
|
||||
perform public.assert_capture_server(capacity->>'serverAddress',p_manifest->>'serverAddress');
|
||||
if count>(capacity->>'remaining')::integer then raise exception 'Map limit is 24 panoramas, including pending uploads'; end if;
|
||||
if count>(capacity->>'playerRemaining')::integer then raise exception 'Each player may contribute at most 12 panoramas per map'; end if;
|
||||
if exists(select 1 from jsonb_array_elements(p_manifest->'viewpoints') p where
|
||||
(p->>'x')::numeric<(b->>'minX')::numeric or (p->>'x')::numeric>(b->>'maxX')::numeric or
|
||||
(p->>'z')::numeric<(b->>'minZ')::numeric or (p->>'z')::numeric>(b->>'maxZ')::numeric) then raise exception 'Contributed panoramas must be inside the original map bounds'; end if;
|
||||
select server_id into server from public.datasets where id=target;
|
||||
else
|
||||
select id into server from public.catalog_servers where name=p_manifest->>'serverName' limit 1;
|
||||
if server is null then insert into public.catalog_servers(name,address) values(p_manifest->>'serverName',p_manifest->>'serverAddress') returning id into server; end if;
|
||||
end if;
|
||||
insert into public.datasets(owner_id,server_id,name,description,source,bounds,parent_dataset_id,contribution_status,contribution_count,contribution_server_address)
|
||||
values(p_owner,server,p_manifest->>'name',coalesce(p_manifest->>'description',''),p_manifest->>'source',p_manifest->'bounds',target,
|
||||
case when target is not null then 'pending' end,case when target is not null then count else 0 end,case when target is null then public.normalize_server_address(p_manifest->>'serverAddress') end) returning id into result;
|
||||
insert into public.dataset_inputs(dataset_id,manifest) values(result,p_manifest);
|
||||
return result;
|
||||
end; $$;
|
||||
|
||||
|
||||
create or replace function public.review_contribution(p_dataset uuid,p_contribution uuid,p_owner uuid,p_decision text,p_reason text default null) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare d public.datasets; c public.datasets; m jsonb; cm jsonb; added jsonb; pool jsonb; chosen jsonb;
|
||||
begin
|
||||
select * into d from public.datasets where id=p_dataset and owner_id=p_owner and parent_dataset_id is null for update;
|
||||
if not found then raise exception 'Map owner permission required'; end if;
|
||||
if d.state not in ('draft','rejected','published') or d.update_state='review' then raise exception 'Wait until the current map review finishes'; end if;
|
||||
select * into c from public.datasets where id=p_contribution and parent_dataset_id=p_dataset for update;
|
||||
if not found or c.contribution_status<>'submitted' or c.state<>'review' then raise exception 'Contribution is not awaiting owner approval'; end if;
|
||||
if p_decision='reject' then
|
||||
if coalesce(length(trim(p_reason)),0)=0 then raise exception 'Provide feedback for the contributor'; end if;
|
||||
update public.datasets set state='rejected',contribution_status='rejected',rejection_reason=p_reason,updated_at=now() where id=c.id;
|
||||
return;
|
||||
end if;
|
||||
if p_decision<>'accept' then raise exception 'Invalid contribution decision'; end if;
|
||||
select manifest into cm from public.dataset_inputs where dataset_id=c.id and validated;
|
||||
select manifest into m from public.dataset_inputs where dataset_id=d.id and validated;
|
||||
if cm is null or m is null then raise exception 'Dataset not validated'; end if;
|
||||
perform public.assert_capture_server(d.contribution_server_address,cm->>'serverAddress');
|
||||
select jsonb_agg(p||jsonb_build_object('id','c'||replace(c.id::text,'-','')||'_'||n,'assetRoot',c.id,'assetId',p->>'id','contributorId',c.owner_id,'source',c.source) order by n)
|
||||
into added from jsonb_array_elements(cm->'viewpoints') with ordinality as pts(p,n);
|
||||
pool:=coalesce(m->'availableViewpoints',m->'viewpoints')||added;
|
||||
if jsonb_array_length(pool)>24 then raise exception 'Map limit is 24 panoramas'; end if;
|
||||
if c.owner_id<>d.owner_id and (select coalesce(sum(contribution_count),0) from public.datasets where parent_dataset_id=d.id and owner_id=c.owner_id and contribution_status='accepted')+jsonb_array_length(added)>12 then raise exception 'Each player may contribute at most 12 panoramas per map'; end if;
|
||||
chosen:=(m->'viewpoints')||added;
|
||||
update public.dataset_inputs set manifest=m||jsonb_build_object('availableViewpoints',pool,'viewpoints',chosen) where dataset_id=d.id;
|
||||
update public.datasets set state=case when state='published' then state else 'draft' end,
|
||||
update_state=case when state='published' then 'draft' end,
|
||||
panorama_count=case when state='published' then panorama_count else jsonb_array_length(chosen) end,rejection_reason=null,updated_at=now() where id=d.id;
|
||||
update public.datasets set contribution_status='accepted',contribution_count=jsonb_array_length(added),updated_at=now() where id=c.id;
|
||||
end; $$;
|
||||
|
||||
|
||||
create or replace function public.submit_draft(p_dataset uuid,p_owner uuid) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare d public.datasets; count integer;
|
||||
begin
|
||||
perform public.check_contribution_server(p_dataset,p_owner);
|
||||
select * into d from public.datasets where id=p_dataset and owner_id=p_owner for update;
|
||||
if not found then raise exception 'Dataset not found'; end if;
|
||||
if d.state not in ('draft','rejected','published') or (d.state='published' and coalesce(d.update_state,'') not in ('draft','rejected')) or d.contribution_status in ('submitted','accepted','rejected') then raise exception 'Draft is not ready for submission'; end if;
|
||||
select jsonb_array_length(manifest->'viewpoints') into count from public.dataset_inputs where dataset_id=p_dataset and validated;
|
||||
if count is null or count<(case when d.parent_dataset_id is null then 5 else 1 end) or count>24 then raise exception 'Invalid panorama count'; end if;
|
||||
update public.datasets set state=case when state='published' then state else 'review' end,
|
||||
update_state=case when state='published' then 'review' end,
|
||||
contribution_status=case when parent_dataset_id is not null then 'submitted' end,updated_at=now() where id=p_dataset;
|
||||
end; $$;
|
||||
|
||||
|
||||
create or replace function public.finalize_upload(p_dataset uuid,p_owner uuid) returns text language plpgsql security invoker set search_path='' as $$
|
||||
declare d public.datasets; begin
|
||||
perform public.check_contribution_server(p_dataset,p_owner);
|
||||
select * into d from public.datasets where id=p_dataset and owner_id=p_owner for update;
|
||||
if not found then raise exception 'Dataset not found'; end if;
|
||||
if d.state in ('processing','draft','review','published') then return d.state; end if;
|
||||
if d.state<>'uploading' then raise exception 'Dataset cannot be finalized'; end if;
|
||||
insert into public.upload_jobs(dataset_id) values(p_dataset) on conflict(dataset_id) do nothing;
|
||||
update public.datasets set state='processing',updated_at=now() where id=p_dataset;
|
||||
return 'processing'; end; $$;
|
||||
|
||||
revoke execute on function public.normalize_server_address(text),public.assert_capture_server(text,text),public.set_contribution_server(uuid,uuid,text),public.contribution_server(uuid,uuid),public.check_contribution_server(uuid,uuid) from public,anon,authenticated;
|
||||
grant execute on function public.normalize_server_address(text),public.assert_capture_server(text,text),public.set_contribution_server(uuid,uuid,text),public.contribution_server(uuid,uuid),public.check_contribution_server(uuid,uuid) to service_role;
|
||||
116
tests/account.test.ts
Normal file
116
tests/account.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
authActionSchema,
|
||||
authDestination,
|
||||
displayNameSchema,
|
||||
newPasswordSchema,
|
||||
profileUpdateSchema,
|
||||
} from "../shared/account";
|
||||
import {
|
||||
createRecoveryGrant,
|
||||
verifyRecoveryGrant,
|
||||
} from "../shared/password-recovery";
|
||||
test("display names allow Unicode and spaces, normalize surrounding whitespace, reject hidden controls", () => {
|
||||
assert.equal(displayNameSchema.parse(" Pavel (: "), "Pavel (:");
|
||||
assert.equal(displayNameSchema.parse("Исследователь"), "Исследователь");
|
||||
for (const bad of [
|
||||
"A",
|
||||
"a".repeat(33),
|
||||
"Name\nAdmin",
|
||||
"Name\u202eadmin",
|
||||
"\u0000Explorer",
|
||||
])
|
||||
assert.equal(displayNameSchema.safeParse(bad).success, false);
|
||||
assert.equal(
|
||||
profileUpdateSchema.safeParse({
|
||||
displayName: "Explorer",
|
||||
role: "moderator",
|
||||
}).success,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
profileUpdateSchema.safeParse({ displayName: "Explorer", id: "other-user" })
|
||||
.success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("new passwords require 12 characters and fit bcrypt UTF-8 input without trimming", () => {
|
||||
assert.equal(newPasswordSchema.safeParse("short").success, false);
|
||||
assert.equal(
|
||||
newPasswordSchema.parse(" a passphrase with spaces "),
|
||||
" a passphrase with spaces ",
|
||||
);
|
||||
assert.equal(newPasswordSchema.safeParse("x".repeat(72)).success, true);
|
||||
assert.equal(newPasswordSchema.safeParse("x".repeat(73)).success, false);
|
||||
assert.equal(newPasswordSchema.safeParse("🌍".repeat(20)).success, false);
|
||||
// Existing accounts can log in using their original password policy.
|
||||
assert.equal(
|
||||
authActionSchema.safeParse({
|
||||
action: "login",
|
||||
email: "user@example.com",
|
||||
password: "oldpass",
|
||||
}).success,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
authActionSchema.safeParse({
|
||||
action: "signup",
|
||||
email: "user@example.com",
|
||||
password: "oldpass",
|
||||
displayName: "Explorer",
|
||||
}).success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("authentication redirects accept supported local routes and reject open redirects", () => {
|
||||
for (const next of [
|
||||
"https://evil.example",
|
||||
"//evil.example",
|
||||
"/\\evil.example",
|
||||
"/%2f%2fevil.example",
|
||||
"/dashboard?next=https://evil.example",
|
||||
"javascript:alert(1)",
|
||||
])
|
||||
assert.equal(authDestination(next), "/dashboard");
|
||||
assert.equal(authDestination("/settings"), "/settings");
|
||||
assert.equal(authDestination("/pair?code=1234ABCD"), "/pair?code=1234ABCD");
|
||||
assert.equal(authDestination("/auth/reset-password"), "/auth/reset-password");
|
||||
});
|
||||
test("recovery grants bind account, session, signature and a ten-minute expiry", () => {
|
||||
const now = 1700000000000,
|
||||
key = "test-only-secret",
|
||||
token = createRecoveryGrant("account", "session", key, now);
|
||||
assert.equal(
|
||||
verifyRecoveryGrant(token, "account", "session", key, now + 1000),
|
||||
true,
|
||||
);
|
||||
for (const [user, session, secret, time] of [
|
||||
["other", "session", key, now],
|
||||
["account", "other", key, now],
|
||||
["account", "session", "wrong", now],
|
||||
["account", "session", key, now + 600000],
|
||||
] as const)
|
||||
assert.equal(
|
||||
verifyRecoveryGrant(token, user, session, secret, time),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
verifyRecoveryGrant(
|
||||
token.replace("1700000600", "1700009999"),
|
||||
"account",
|
||||
"session",
|
||||
key,
|
||||
now,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
verifyRecoveryGrant(undefined, "account", "session", key, now),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
verifyRecoveryGrant("malformed", "account", "session", key, now),
|
||||
false,
|
||||
);
|
||||
});
|
||||
103
tests/contributions.test.ts
Normal file
103
tests/contributions.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
datasetSchema,
|
||||
parseUploadManifest,
|
||||
panoramaPath,
|
||||
} from "../shared/protocol";
|
||||
import { selectDraftLocations } from "../shared/draft-selection";
|
||||
const root = "11111111-1111-4111-8111-111111111111",
|
||||
child = "22222222-2222-4222-8222-222222222222";
|
||||
const point = (i: number) => ({ id: `p${i}`, x: i, y: 65, z: i });
|
||||
const manifest = {
|
||||
format: "minegeo-dataset/v1",
|
||||
minecraftVersion: "1.21.11",
|
||||
dimension: "minecraft:overworld",
|
||||
source: "plugin",
|
||||
name: "Random world",
|
||||
serverName: "Test server",
|
||||
north: "-z",
|
||||
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
|
||||
viewpoints: Array.from({ length: 24 }, (_, i) => point(i)),
|
||||
assets: [{ path: "terrain.json.gz", bytes: 10, sha256: "a".repeat(64) }],
|
||||
};
|
||||
test("maps permit 24 captures and reject a 25th, including deselected locations", () => {
|
||||
assert.equal(parseUploadManifest(manifest).viewpoints.length, 24);
|
||||
assert.throws(() =>
|
||||
parseUploadManifest({
|
||||
...manifest,
|
||||
viewpoints: [...manifest.viewpoints, point(24)],
|
||||
}),
|
||||
);
|
||||
assert.throws(() =>
|
||||
datasetSchema.parse({
|
||||
...manifest,
|
||||
viewpoints: manifest.viewpoints.slice(0, 5),
|
||||
availableViewpoints: [...manifest.viewpoints, point(24)],
|
||||
}),
|
||||
);
|
||||
});
|
||||
test("contributions can contain one panorama, while new maps still need five", () => {
|
||||
const contribution = parseUploadManifest({
|
||||
...manifest,
|
||||
contributionTo: root,
|
||||
viewpoints: [point(0)],
|
||||
});
|
||||
assert.equal(selectDraftLocations(contribution, ["p0"]).viewpoints.length, 1);
|
||||
assert.throws(() =>
|
||||
parseUploadManifest({ ...manifest, viewpoints: [point(0)] }),
|
||||
);
|
||||
});
|
||||
test("clients cannot choose other uploads' media or claim server-assigned provenance", () => {
|
||||
for (const field of ["assetRoot", "assetId", "contributorId", "source"])
|
||||
assert.throws(() =>
|
||||
parseUploadManifest({
|
||||
...manifest,
|
||||
viewpoints: manifest.viewpoints.map((p) => ({
|
||||
...p,
|
||||
[field]:
|
||||
field === "assetId" ? "p0" : field === "source" ? "fabric" : child,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
assert.throws(() =>
|
||||
parseUploadManifest({
|
||||
...manifest,
|
||||
availableViewpoints: manifest.viewpoints,
|
||||
}),
|
||||
);
|
||||
});
|
||||
test("merged Fabric maps preserve external cubemaps without requiring them in the original capture", () => {
|
||||
const external = {
|
||||
...point(5),
|
||||
id: "c_child_1",
|
||||
assetRoot: child,
|
||||
assetId: "p0",
|
||||
source: "plugin" as const,
|
||||
};
|
||||
const local = manifest.viewpoints.slice(0, 5);
|
||||
const merged = datasetSchema.parse({
|
||||
...manifest,
|
||||
source: "fabric",
|
||||
viewpoints: [...local, external],
|
||||
assets: [
|
||||
"map.png",
|
||||
...local.flatMap((p) =>
|
||||
["px", "nx", "py", "ny", "pz", "nz"].map((f) => `${p.id}/${f}.png`),
|
||||
),
|
||||
].map((path) => ({ path, bytes: 10, sha256: "a".repeat(64) })),
|
||||
});
|
||||
assert.equal(
|
||||
panoramaPath(root, merged.viewpoints[5], "nz.webp"),
|
||||
`${child}/p0/nz.webp`,
|
||||
);
|
||||
assert.equal(
|
||||
panoramaPath(root, merged.viewpoints[0], "preview.webp"),
|
||||
`${root}/p0/preview.webp`,
|
||||
);
|
||||
const selection = selectDraftLocations(
|
||||
merged,
|
||||
local.map((p) => p.id),
|
||||
);
|
||||
assert.equal(selection.availableViewpoints?.length, 6);
|
||||
});
|
||||
17
tests/java/ServerAddressSmoke.java
Normal file
17
tests/java/ServerAddressSmoke.java
Normal file
@@ -0,0 +1,17 @@
|
||||
import dev.minegeo.plugin.ServerAddress;
|
||||
public class ServerAddressSmoke {
|
||||
public static void main(String[] args) throws Exception {
|
||||
ServerAddress.requireMatch(" PLAY.EXAMPLE.COM.:25565 ","play.example.com");
|
||||
ServerAddress.requireMatch("[2001:DB8::1]","[2001:db8::1]:25565");
|
||||
ServerAddress.requireMatch(null,"other.example.com");
|
||||
for(String capture:new String[]{"other.example.com","play.example.com:25566",""}) {
|
||||
try {ServerAddress.requireMatch("play.example.com",capture);throw new AssertionError("Wrong server accepted");}
|
||||
catch(java.io.IOException expected) {}
|
||||
}
|
||||
for(String invalid:new String[]{"https://host","host:0","host:65536","a..b","[:::]"}) {
|
||||
try {ServerAddress.normalize(invalid);throw new AssertionError("Invalid address accepted");}
|
||||
catch(IllegalArgumentException expected) {}
|
||||
}
|
||||
System.out.println("Server address checks passed for the shared mod/plugin client");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@ import java.nio.file.*;
|
||||
public class UploadSmoke {
|
||||
public static void main(String[] args) throws Exception {
|
||||
String token=JsonParser.parseString(Files.readString(Path.of(args[2]))).getAsJsonObject().get("token").getAsString();
|
||||
new UploadClient(args[0],token).upload(Path.of(args[1]),message->System.out.println(message),()->false);
|
||||
new UploadClient(args[0],token).upload(Path.of(args[1]),args.length>3?args[3]:null,message->System.out.println(message),()->false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,14 +32,22 @@ async function run(
|
||||
published?: boolean;
|
||||
active?: boolean;
|
||||
jobConflict?: boolean;
|
||||
contribution?: boolean;
|
||||
mixed?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const dataset = {
|
||||
...baseDataset,
|
||||
...(options.published ? { state: "published", revision_id: id } : {}),
|
||||
...(options.contribution ? { parent_dataset_id: id } : {}),
|
||||
};
|
||||
const job = { ...baseJob, ...(options.active ? { state: "running" } : {}) };
|
||||
const input = { validated: true, manifest: {} };
|
||||
const input = {
|
||||
validated: true,
|
||||
manifest: {
|
||||
viewpoints: [{ id: "p0", ...(options.mixed ? { assetRoot: id } : {}) }],
|
||||
},
|
||||
};
|
||||
const patches: { table: string; body: Record<string, unknown> }[] = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, "http://localhost");
|
||||
@@ -124,6 +132,17 @@ test("reprocessing cannot mutate published revisions", async (t) => {
|
||||
assert.match(result.output, /Published revisions cannot be changed/);
|
||||
assert.deepEqual(result.patches, []);
|
||||
});
|
||||
test("accepted contribution media cannot be overwritten through reprocessing", async (t) => {
|
||||
for (const options of [{ contribution: true }, { mixed: true }]) {
|
||||
const result = await run(t, options);
|
||||
assert.notEqual(result.code, 0);
|
||||
assert.match(
|
||||
result.output,
|
||||
/Contributions cannot be overwritten|accepted contributions/,
|
||||
);
|
||||
assert.deepEqual(result.patches, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("reprocessing cannot take over an active worker job", async (t) => {
|
||||
const result = await run(t, { active: true });
|
||||
|
||||
43
tests/server-address.test.ts
Normal file
43
tests/server-address.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizeServerAddress } from "../shared/server-address";
|
||||
test("Minecraft addresses compare case, trailing DNS dot and default port consistently", () => {
|
||||
for (const value of [
|
||||
"play.example.com",
|
||||
" PLAY.EXAMPLE.COM.:25565 ",
|
||||
"play.example.com:25565",
|
||||
])
|
||||
assert.equal(normalizeServerAddress(value), "play.example.com");
|
||||
assert.equal(
|
||||
normalizeServerAddress("192.168.0.120:25566"),
|
||||
"192.168.0.120:25566",
|
||||
);
|
||||
assert.equal(normalizeServerAddress("[2001:DB8::1]:25565"), "[2001:db8::1]");
|
||||
assert.equal(normalizeServerAddress("[::1]:25566"), "[::1]:25566");
|
||||
assert.equal(
|
||||
normalizeServerAddress("play.example.com:00080"),
|
||||
"play.example.com:80",
|
||||
);
|
||||
});
|
||||
test("clearing the server address disables its check", () => {
|
||||
for (const value of [null, undefined, "", " "])
|
||||
assert.equal(normalizeServerAddress(value), null);
|
||||
});
|
||||
test("addresses reject URLs, paths, credentials, invalid hosts and ports", () => {
|
||||
for (const value of [
|
||||
"https://play.example.com",
|
||||
"player@host",
|
||||
"host/path",
|
||||
"host?query",
|
||||
"host:0",
|
||||
"host:65536",
|
||||
"host:abcdef",
|
||||
"a..b",
|
||||
"-host",
|
||||
"host-",
|
||||
"[abc]",
|
||||
"[:::]",
|
||||
"a".repeat(64),
|
||||
])
|
||||
assert.throws(() => normalizeServerAddress(value), value);
|
||||
});
|
||||
61
tests/sql/contribution-server.sql
Normal file
61
tests/sql/contribution-server.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- Integration checks against a database with two existing test/seed accounts.
|
||||
-- All fixture rows and edits are rolled back; no storage uploads or game revisions.
|
||||
begin;
|
||||
do $$
|
||||
declare owner uuid; player uuid; root uuid; child uuid; m jsonb; cm jsonb; info jsonb;
|
||||
begin
|
||||
select id into owner from public.profiles where role='moderator' limit 1;
|
||||
select id into player from public.profiles where id<>owner limit 1;
|
||||
if owner is null or player is null then raise exception 'Seed two accounts before running this test'; end if;
|
||||
m:=jsonb_build_object('format','minegeo-dataset/v1','minecraftVersion','1.21.11','dimension','minecraft:overworld',
|
||||
'source','plugin','name','Server check rollback fixture','serverName','Server check rollback fixture','serverAddress',' PLAY.EXAMPLE.COM.:25565 ',
|
||||
'north','-z','bounds',jsonb_build_object('minX',0,'minZ',0,'maxX',64,'maxZ',64),
|
||||
'viewpoints',(select jsonb_agg(jsonb_build_object('id','p'||n,'x',n,'y',65,'z',n)) from generate_series(1,5) n),
|
||||
'assets',jsonb_build_array(jsonb_build_object('path','terrain.json.gz','bytes',1,'sha256',repeat('a',64))));
|
||||
root:=public.create_upload(owner,m);
|
||||
update public.datasets set state='draft' where id=root;
|
||||
update public.dataset_inputs set validated=true where dataset_id=root;
|
||||
perform public.set_contributions(root,owner,true);
|
||||
info:=public.contribution_capacity(root,player);
|
||||
if info->>'serverAddress'<>'play.example.com' then raise exception 'Initial address not canonical'; end if;
|
||||
cm:=m||jsonb_build_object('contributionTo',root,'viewpoints',jsonb_build_array(m->'viewpoints'->0));
|
||||
begin
|
||||
perform public.create_upload(player,cm||jsonb_build_object('serverAddress','other.example.com'));
|
||||
raise exception 'Mismatch accepted';
|
||||
exception when others then if sqlerrm not like 'Wrong capture server:%' then raise; end if; end;
|
||||
begin
|
||||
perform public.create_upload(player,cm-'serverAddress');
|
||||
raise exception 'Missing capture address accepted';
|
||||
exception when others then if sqlerrm not like 'Wrong capture server:%' then raise; end if; end;
|
||||
begin
|
||||
perform public.set_contribution_server(root,player,null);
|
||||
raise exception 'Non-owner changed address';
|
||||
exception when others then if sqlerrm<>'Map owner permission required' then raise; end if; end;
|
||||
child:=public.create_upload(player,cm||jsonb_build_object('serverAddress','play.example.com'));
|
||||
perform public.set_contribution_server(root,owner,'other.example.com:25566');
|
||||
begin
|
||||
perform public.finalize_upload(child,player);
|
||||
raise exception 'Retry finalized after the server changed';
|
||||
exception when others then if sqlerrm not like 'Wrong capture server:%' then raise; end if; end;
|
||||
update public.datasets set state='draft',panorama_count=1 where id=child;
|
||||
update public.dataset_inputs set validated=true where dataset_id=child;
|
||||
begin
|
||||
perform public.submit_draft(child,player);
|
||||
raise exception 'Mismatching submission accepted';
|
||||
exception when others then if sqlerrm not like 'Wrong capture server:%' then raise; end if; end;
|
||||
perform public.set_contribution_server(root,owner,'');
|
||||
perform public.submit_draft(child,player);
|
||||
perform public.set_contribution_server(root,owner,'other.example.com');
|
||||
begin
|
||||
perform public.review_contribution(root,child,owner,'accept');
|
||||
raise exception 'Owner accepted mismatching contribution while check enabled';
|
||||
exception when others then if sqlerrm not like 'Wrong capture server:%' then raise; end if; end;
|
||||
perform public.set_contribution_server(root,owner,null);
|
||||
perform public.review_contribution(root,child,owner,'accept');
|
||||
if (select contribution_status from public.datasets where id=child)<>'accepted' then raise exception 'Cleared check did not allow owner approval'; end if;
|
||||
if public.normalize_server_address('[2001:DB8::1]:25565')<>'[2001:db8::1]' then raise exception 'IPv6 normalization differs'; end if;
|
||||
if has_function_privilege('anon','public.set_contribution_server(uuid,uuid,text)','execute') or
|
||||
has_function_privilege('authenticated','public.check_contribution_server(uuid,uuid)','execute') then raise exception 'Private RPC exposed'; end if;
|
||||
end; $$;
|
||||
select 'server address enforcement, overrides, opt-out, retries, approvals and permissions passed' as result;
|
||||
rollback;
|
||||
@@ -313,7 +313,12 @@ async function processJob(job: {
|
||||
} finally {
|
||||
if (activeAbort === abort) activeAbort = null;
|
||||
clearInterval(heartbeat);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await rm(directory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 300,
|
||||
});
|
||||
}
|
||||
}
|
||||
let housekeeping = 0;
|
||||
|
||||
Reference in New Issue
Block a user