diff --git a/.gitignore b/.gitignore index 54e93bf..5bd7bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ .data/ artifacts/ *.log +*.hprof **/.gradle/ **/build/ playwright-report/ diff --git a/README.md b/README.md index 6323b1f..4581576 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ 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. +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 guest 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 ` before exporting. diff --git a/components/DraftEditor.tsx b/components/DraftEditor.tsx index 599e29a..7c8542d 100644 --- a/components/DraftEditor.tsx +++ b/components/DraftEditor.tsx @@ -136,7 +136,7 @@ export function DraftEditor({ id }: { id: string }) {
← Your maps Choose locations - Map tools + {!draft.parent_dataset_id && Map tools}
{draft.state} {draft.update_state && ( @@ -299,18 +299,20 @@ export function DraftEditor({ id }: { id: string }) { )} -
- { - setMessage( - "Map refresh queued. Keep this page open to follow progress.", - ); - setVersion((v) => v + 1); - }} - /> -
+ {!draft.parent_dataset_id && ( +
+ { + setMessage( + "Map refresh queued. Keep this page open to follow progress.", + ); + setVersion((v) => v + 1); + }} + /> +
+ )} {!draft.parent_dataset_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. +The uploader keeps up to the available slots (24 for the map owner, 12 per guest), or fewer when the map has less room. 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. @@ -37,6 +41,6 @@ This prevents accidental submissions from another server. Capture addresses are ## 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 configured MineGeo Supabase project has the first four October 5 migrations applied. Apply `20261005200000_map_refresh_fixes.sql` before deploying this website/worker pair. For another database, apply `20261005063646_panorama_contributions.sql`, `20261005065029_withdraw_contribution.sql`, `20261005073253_contribution_server_check.sql`, `20261005141142_map_refresh.sql`, and `20261005200000_map_refresh_fixes.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. diff --git a/docs/verification.md b/docs/verification.md index 6afbf9c..3665942 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,5 +1,15 @@ # Verification of the export/upload fixes +## Map refresh completion (October 5, 2026) + +The unfinished "map refresh" commit was completed. A follow-up migration (`20261005200000_map_refresh_fixes.sql`) restores the contribution server-address check in `submit_draft` that the map-refresh migration had accidentally dropped, and rejects map-refresh jobs both when queued (`refresh_map`) and completed (`complete_job`) for contribution datasets. The map page now receives the job `kind`, so refresh progress, the refreshing notice and the failure alert render; refresh controls and the Map tools anchor are hidden on contributions; map-only worker downloads report progress against the filtered asset list; the plugin help text describes the owner/guest limits. + +The 743 MiB `minecraft/java_pid42664.hprof` heap dump, tracked since the first commit, was purged from all Git history with `git filter-branch`, expired reflogs and `git gc`; `*.hprof` is now ignored. The local repository's `.git` directory shrank from about 170 MiB to 1.2 MiB. The rewritten history has not been pushed; because every commit hash changed, the remote needs a force-push before further work is pulled elsewhere. + +Verification at the user's request was limited to local checks: all 65 Node tests and TypeScript checking passed under Node 22, and both Minecraft JARs were rebuilt with the updated help text. `tests/sql/map-refresh.sql` now includes contribution-refresh guard cases. The new migration has not been applied to the configured Supabase project, and the SQL, plugin, renderer and browser regressions were not rerun in this pass. The user had already run the plugin regression with Codex before these 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. diff --git a/lib/api.ts b/lib/api.ts index b48c9c1..4e0ee08 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -332,7 +332,7 @@ export async function handle(request: NextRequest, path: string[]) { await client .from("datasets") .select( - "*, upload_jobs(state,progress,error,updated_at,lease_until,kind)", + "*, upload_jobs!dataset_id(state,progress,error,updated_at,lease_until,kind)", ) .eq("owner_id", owner) .order("created_at", { ascending: false }), @@ -349,6 +349,8 @@ export async function handle(request: NextRequest, path: string[]) { } const d = await ownedDataset(request, path[1]); if (path[2] === "refresh-map") { + if (d.parent_dataset_id) + throw new Error("Only maps can refresh their guessing map"); if (method === "POST") { const body = z .object({ sourceId: idSchema }) @@ -424,7 +426,7 @@ export async function handle(request: NextRequest, path: string[]) { const manifest = datasetSchema.parse(input.manifest); const jobResult = await client .from("upload_jobs") - .select("state,progress,error,updated_at,lease_until") + .select("state,progress,error,updated_at,lease_until,kind") .eq("dataset_id", d.id) .maybeSingle(); checkServiceError(jobResult.error); diff --git a/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java b/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java index 1b66c05..b32a41a 100644 --- a/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java +++ b/minecraft/plugin/src/main/java/dev/minegeo/plugin/MineGeoPlugin.java @@ -42,7 +42,7 @@ public final class MineGeoPlugin extends JavaPlugin { // every rejected command receives a visible explanation. if(!(sender instanceof ConsoleCommandSender||sender instanceof RemoteConsoleCommandSender||sender instanceof Player&&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 ","/minegeo server — capture server address","/minegeo link ","/minegeo area ","/minegeo export — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo contribute — send up to 12 panoramas to its owner","/minegeo status | cancel | reload"});return true;} + if(args.length==0||args[0].equalsIgnoreCase("help")) {sender.sendMessage(new String[]{"MineGeo commands:","/minegeo website ","/minegeo server — capture server address","/minegeo link ","/minegeo area ","/minegeo export — saves a local export","/minegeo upload — uploads the latest completed export","/minegeo contribute — add panoramas (24 owner, 12 guests)","/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 "); diff --git a/public/tools/plugin/minegeo-plugin-1.21.11.jar b/public/tools/plugin/minegeo-plugin-1.21.11.jar index 3844918..14b34c3 100644 Binary files a/public/tools/plugin/minegeo-plugin-1.21.11.jar and b/public/tools/plugin/minegeo-plugin-1.21.11.jar differ diff --git a/supabase/migrations/20261005200000_map_refresh_fixes.sql b/supabase/migrations/20261005200000_map_refresh_fixes.sql new file mode 100644 index 0000000..048fca1 --- /dev/null +++ b/supabase/migrations/20261005200000_map_refresh_fixes.sql @@ -0,0 +1,64 @@ +-- Follow-up to 20261005141142_map_refresh.sql. The original migration was +-- already applied, and its submit_draft accidentally dropped the shared +-- contribution server-address check. It also allowed map-refresh jobs on +-- contribution datasets. This migration restores and hardens all three +-- functions without changing the queue format. + +create or replace function public.refresh_map(p_dataset uuid,p_owner uuid,p_source uuid) returns void +language plpgsql security invoker set search_path='' as $$ +declare d public.datasets; s public.datasets; +begin + select * into d from public.datasets where id=p_dataset and owner_id=p_owner for update; + if not found then raise exception 'Map owner permission required'; end if; + if d.parent_dataset_id is not null then raise exception 'Only a map can refresh its guessing map'; 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 'Wait until the current review finishes'; end if; + if exists(select 1 from public.upload_jobs where dataset_id=d.id and state in ('queued','running')) then raise exception 'A render is already in progress'; end if; + select * into s from public.datasets where id=p_source; + if not found or not (s.owner_id=p_owner or (s.parent_dataset_id=d.id and s.contribution_status='accepted')) then raise exception 'Capture permission required'; end if; + if s.bounds<>d.bounds then raise exception 'Capture boundaries must match the map exactly'; end if; + if s.map_path is null or s.state not in ('draft','rejected','review','published') or not exists(select 1 from public.dataset_inputs where dataset_id=s.id and validated) then raise exception 'Wait for the capture to finish processing'; end if; + perform public.assert_capture_server(d.contribution_server_address, (select manifest->>'serverAddress' from public.dataset_inputs where dataset_id=s.id)); + insert into public.upload_jobs(dataset_id,kind,source_dataset_id) values(d.id,'map',s.id) + on conflict(dataset_id) do update set id=gen_random_uuid(),kind='map',source_dataset_id=s.id,state='queued',attempts=0,progress=0,error=null,lease_owner=null,lease_until=null,created_at=now(),updated_at=now(); +end; $$; +revoke execute on function public.refresh_map(uuid,uuid,uuid) from public,anon,authenticated; +grant execute on function public.refresh_map(uuid,uuid,uuid) to service_role; + +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 j.kind='map' then + if d.parent_dataset_id is not null then raise exception 'Contributions cannot refresh a guessing map'; 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 'Map cannot change during review'; end if; + update public.dataset_inputs set manifest=manifest||jsonb_build_object('mapPath',d.id::text||'/maps/'||j.id::text||'.webp') where dataset_id=d.id and validated; + if not found then raise exception 'Dataset not validated'; end if; + update public.datasets set update_state=case when state='published' then 'draft' else null end,state=case when state='published' then state else 'draft' end,rejection_reason=null,updated_at=now() where id=d.id; + update public.upload_jobs set state='complete',progress=100,lease_until=null,error=null,updated_at=now() where id=j.id; + return; + end if; + 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; $$; + +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; + if exists(select 1 from public.upload_jobs where dataset_id=d.id and state in ('queued','running')) then raise exception 'Wait for map rendering to finish before submitting'; 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; $$; diff --git a/tests/sql/map-refresh.sql b/tests/sql/map-refresh.sql index 259916c..a7c8a69 100644 --- a/tests/sql/map-refresh.sql +++ b/tests/sql/map-refresh.sql @@ -1,7 +1,7 @@ -- Run against the configured project; all fixture changes roll back. begin; do $$ -declare original public.datasets; fixture uuid; job uuid; owner uuid; moderator uuid; before_path text; before_revision uuid; +declare original public.datasets; fixture uuid; fixture_child uuid; job uuid; child_job uuid; owner uuid; moderator uuid; before_path text; before_revision uuid; begin select * into original from public.datasets where state='published' and parent_dataset_id is null limit 1; if not found then raise exception 'A published fixture source is required'; end if; @@ -53,5 +53,21 @@ begin exception when others then if sqlerrm<>'Capture boundaries must match the map exactly' then raise; end if; end; + insert into public.datasets(owner_id,server_id,name,source,bounds,state,parent_dataset_id,contribution_status,contribution_count) + values(owner,original.server_id,'Refresh guard contribution',original.source,original.bounds,'draft',fixture,'pending',1) returning id into fixture_child; + begin + perform public.refresh_map(fixture_child,owner,original.id); + raise exception 'Contribution refresh succeeded'; + exception when others then + if sqlerrm<>'Only a map can refresh its guessing map' then raise; end if; + end; + insert into public.upload_jobs(dataset_id,kind,source_dataset_id,state,lease_owner,lease_until) + values(fixture_child,'map',original.id,'running','regression',now()+interval '2 minutes') returning id into child_job; + begin + perform public.complete_job(child_job,'regression',0,''); + raise exception 'Contribution map render completed'; + exception when others then + if sqlerrm<>'Contributions cannot refresh a guessing map' then raise; end if; + end; end; $$; rollback; diff --git a/worker/main.ts b/worker/main.ts index 77fa413..db918d8 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -105,14 +105,15 @@ async function processJob(job: { .single(), ); const manifest = datasetSchema.parse(input.manifest); - await progress(0, `Downloading ${manifest.assets.length} export files`); - let downloaded = 0; - for (const asset of manifest.assets.filter( + const assets = manifest.assets.filter( (a) => !mapOnly || a.path === (manifest.source === "plugin" ? "terrain.json.gz" : "map.png"), - )) { + ); + await progress(0, `Downloading ${assets.length} export files`); + let downloaded = 0; + for (const asset of assets) { if (abort.signal.aborted) throw new Error("Lease lost or cancelled"); const blob = check( await client.storage @@ -129,8 +130,8 @@ async function processJob(job: { await mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true }); await writeFile(path, bytes); await progress( - (20 * ++downloaded) / manifest.assets.length, - `Downloaded ${downloaded}/${manifest.assets.length} export files`, + (20 * ++downloaded) / assets.length, + `Downloaded ${downloaded}/${assets.length} export files`, ); } const output = `${directory}/media`;