fix: miscellaneous bug fixes (#1139)

* fix(share): serve place thumbnails in shared trip links (#1100)

Google-sourced place photos are stored as image_url pointing at the
JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401
for an unauthenticated shared-trip viewer and render as broken images.

Rewrite place image_url values in the shared payload to a public,
token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and
add an unguarded SharedController route that validates the token and that
the place belongs to its trip before streaming the cached bytes. Mirrors
the existing JourneyPublicController precedent. No client changes needed.

* fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119)

Atlas sourced country and sub-national boundaries from Natural Earth's GitHub
`master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020
counties such as Oppland/Hordaland) and depicts some contested territory in
unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped
entirely.

- Country borders (admin0) now come from the geoBoundaries CGAZ composite;
  sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2
  codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes
  them into committed gzipped bundles under server/assets/atlas, read server-side at
  runtime (no network at boot, no GitHub CSP allowlist entry).
- New GET /addons/atlas/countries/geo serves the country layer; the client fetches
  it from the API instead of GitHub.
- A migration reconciles manually-marked visited_regions against the new bundle
  (valid code -> keep; region name still matches -> re-code; curated merge crosswalk
  for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and
  visited_countries hold only invariant alpha-2 country codes, so they are untouched.
- Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0.

Closes #1119

* fix(packing): make templates admin-only to create, usable by members

Creating a packing-list template was gated only by trip access, so any
trip member could create one from the Lists feature, while applying a
template silently failed for non-admins because the apply dropdown was
populated from the AdminGuard-protected /api/admin/packing-templates
endpoint.

- save-as-template now returns 403 for non-admins; the Save-as-Template
  button is hidden unless the user is an admin (both the TripPlanner
  toolbar and the inline packing header).
- add member-accessible GET /api/trips/:tripId/packing/templates so the
  apply dropdown lists templates for any trip member; client fetches
  from it instead of the admin endpoint.

Closes #1120
Closes #1121

* fix(packing): show bag tracking to non-admin members

The global Bag Tracking toggle was only readable via the admin-gated
GET /api/admin/bag-tracking, so non-admin trip members got 403 and the
weight fields, bag circles, and BAGS sidebar never rendered (#1124).

Surface the flag through the already-authenticated GET /api/addons
(loaded into the client addon store on app start for every user); the
packing hook reads it from the store instead of the admin endpoint. The
admin write path stays admin-gated and unchanged.
This commit is contained in:
jubnl
2026-06-09 16:02:37 +02:00
committed by GitHub
parent 49b3af8b0d
commit 3c040fab11
41 changed files with 1061 additions and 277 deletions
+91
View File
@@ -1,3 +1,6 @@
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import Database from 'better-sqlite3';
import { encrypt_api_key } from '../services/apiKeyCrypto';
@@ -2369,6 +2372,94 @@ function runMigrations(db: Database.Database): void {
);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
`),
// Atlas dropped Natural Earth for geoBoundaries. Manually-marked sub-national
// regions (`visited_regions`) stored the OLD Natural Earth ISO-3166-2 codes; some no
// longer match any polygon in the new bundle and would stop highlighting. Reconcile
// every row against the ACTUAL shipped admin-1 bundle so this covers *all* countries,
// not just one hand-listed reform:
// 1. code still present in the new bundle → leave it (already correct);
// 2. else a region in the same country shares → adopt that region's code+name
// the stored region_name (case-insensitive) (handles code re-spellings, e.g.
// ES-AN → ES_AND, names unchanged);
// 3. else a curated merge crosswalk maps it → adopt the merged region (handles
// (region absorbed into a *renamed* one) reforms where the name changed,
// which step 2 cannot catch);
// 4. else → leave as-is (cannot be resolved; the client's name fallback may still
// highlight it, and nothing is destroyed).
// Other Atlas tables need NO remap: `visited_countries` / `bucket_list` hold only
// ISO-3166-1 alpha-2 codes (invariant across the swap), `bucket_list.name` is free
// text we must not auto-rewrite, and `place_regions` is a re-derivable Nominatim cache.
() => {
type Row = { id: number; region_code: string; region_name: string; country_code: string };
const rows = db.prepare(
'SELECT id, region_code, region_name, country_code FROM visited_regions'
).all() as Row[];
if (rows.length === 0) return; // nothing marked → skip the bundle read entirely
// Index the shipped admin-1 bundle: valid codes, name→code per country, code→name.
// __dirname resolves ../../assets under both dist (dist/db) and tests (src/db).
let features: { properties?: { iso_a2?: string; iso_3166_2?: string; name?: string } }[] = [];
try {
const file = path.join(__dirname, '..', '..', 'assets', 'atlas', 'admin1.geojson.gz');
features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || [];
} catch {
features = []; // bundle missing → degrade to the curated crosswalk below
}
const validCodes = new Set<string>();
const nameToCode = new Map<string, string>(); // `${A2}|${nameLower}` → code
const codeToName = new Map<string, string>();
for (const f of features) {
const a2 = (f.properties?.iso_a2 || '').toUpperCase();
const code = f.properties?.iso_3166_2 || '';
const name = f.properties?.name || '';
if (!code) continue;
validCodes.add(code);
if (!codeToName.has(code)) codeToName.set(code, name);
if (a2 && name) nameToCode.set(`${a2}|${name.toLowerCase()}`, code);
}
// Curated crosswalk for regions absorbed into a *renamed* successor (step 2 can't
// match these because the name changed). Norway's 2018/2020 reforms; extend as the
// pinned geoBoundaries dataset gains further reforms.
const MERGE_CROSSWALK: Record<string, string> = {
'NO-04': 'NO-34', 'NO-05': 'NO-34', // Hedmark, Oppland → Innlandet
'NO-12': 'NO-46', 'NO-14': 'NO-46', // Hordaland, Sogn og Fjordane → Vestland
'NO-09': 'NO-42', 'NO-10': 'NO-42', // Aust-/Vest-Agder → Agder
'NO-01': 'NO-30', 'NO-02': 'NO-30', 'NO-06': 'NO-30', // Østfold/Akershus/Buskerud → Viken
'NO-07': 'NO-38', 'NO-08': 'NO-38', // Vestfold, Telemark → Vestfold og Telemark
'NO-19': 'NO-54', 'NO-20': 'NO-54', // Troms, Finnmark → Troms og Finnmark
'NO-16': 'NO-50', 'NO-17': 'NO-50', // Sør-/Nord-Trøndelag → Trøndelag
};
const resolve = (row: Row): string | null => {
if (validCodes.has(row.region_code)) return null; // already valid
const a2 = (row.country_code || '').toUpperCase();
const byName = nameToCode.get(`${a2}|${(row.region_name || '').toLowerCase()}`);
if (byName) return byName;
const merged = MERGE_CROSSWALK[row.region_code];
// Only trust the crosswalk target if it actually exists in the bundle (or the
// bundle was unreadable, in which case we apply the curated map blindly).
if (merged && (validCodes.size === 0 || validCodes.has(merged))) return merged;
return null;
};
const update = db.prepare(
'UPDATE OR IGNORE visited_regions SET region_code = ?, region_name = ? WHERE id = ?'
);
const del = db.prepare('DELETE FROM visited_regions WHERE id = ?');
for (const row of rows) {
const newCode = resolve(row);
if (!newCode || newCode === row.region_code) continue;
const newName = codeToName.get(newCode) || row.region_name;
update.run(newCode, newName, row.id);
// UNIQUE(user_id, region_code): if the user already had the target code the
// UPDATE was IGNORED and this row still carries the old code → drop the duplicate.
const after = db.prepare('SELECT region_code FROM visited_regions WHERE id = ?').get(row.id) as
| { region_code: string }
| undefined;
if (after && after.region_code === row.region_code) del.run(row.id);
}
},
];
if (currentVersion < migrations.length) {
@@ -97,7 +97,6 @@ export function applyGlobalMiddleware(
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
],
+2 -1
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { db } from '../../db/database';
import type { Addon } from '../../types';
import { getCollabFeatures } from '../../services/adminService';
import { getBagTracking, getCollabFeatures } from '../../services/adminService';
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
/**
@@ -53,6 +53,7 @@ export class AddonsService {
return {
collabFeatures: getCollabFeatures(),
bagTracking: getBagTracking().enabled,
addons: [
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
...providers.map((p) => ({
@@ -62,6 +62,12 @@ export class AtlasController {
return geo;
}
@Get('countries/geo')
@Header('Cache-Control', 'public, max-age=86400')
countryGeo(): RegionGeo {
return this.atlas.countryGeo();
}
@Get('country/:code')
countryPlaces(@CurrentUser() user: User, @Param('code') code: string) {
return this.atlas.countryPlaces(user.id, code.toUpperCase());
+5
View File
@@ -8,6 +8,7 @@ import {
unmarkRegionVisited,
getVisitedRegions,
getRegionGeo,
getCountryGeo,
listBucketList,
createBucketItem,
updateBucketItem,
@@ -37,6 +38,10 @@ export class AtlasService {
return getRegionGeo(countries);
}
countryGeo() {
return getCountryGeo();
}
countryPlaces(userId: number, code: string) {
return getCountryPlaces(userId, code);
}
@@ -195,6 +195,12 @@ export class PackingController {
return { success: true };
}
@Get('templates')
listTemplates(@CurrentUser() user: User, @Param('tripId') tripId: string) {
this.requireTrip(tripId, user);
return { templates: this.packing.listTemplates() };
}
@Post('apply-template/:templateId')
@HttpCode(200)
applyTemplate(
@@ -238,6 +244,9 @@ export class PackingController {
@Body('name') name?: string,
) {
this.requireTrip(tripId, user);
if (user.role !== 'admin') {
throw new HttpException({ error: 'Admin access required' }, 403);
}
if (!name?.trim()) {
throw new HttpException({ error: 'Template name is required' }, 400);
}
@@ -71,6 +71,10 @@ export class PackingService {
return svc.setBagMembers(tripId, bagId, userIds);
}
listTemplates() {
return svc.listTemplates();
}
applyTemplate(tripId: string, templateId: string) {
return svc.applyTemplate(tripId, templateId);
}
+25
View File
@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpException, Param, Post, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { createReadStream } from 'node:fs';
import type { User } from '../../types';
import { ShareService } from './share.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -72,6 +73,30 @@ export class TripShareController {
export class SharedController {
constructor(private readonly share: ShareService) {}
/**
* Public, token-scoped place-photo proxy. The shared payload rewrites place
* image URLs to this route so thumbnails load without a session cookie (the
* /api/maps bytes endpoint is JwtAuthGuard'd). The service validates the token
* and that the place belongs to its trip; a miss streams nothing and answers
* 404. Declared before the bare ':token' read route. Streaming mirrors
* MapsController.placePhotoBytes (cached photos are always JPEG).
*/
@Get(':token/place-photo/:placeId/bytes')
placePhotoBytes(@Param('token') token: string, @Param('placeId') placeId: string, @Res() res: Response): void {
const fp = this.share.getSharedPlacePhotoPath(token, placeId);
if (!fp) {
res.status(404).json({ error: 'Photo not cached' });
return;
}
res.set('Cache-Control', 'public, max-age=2592000, immutable');
res.type('image/jpeg');
const stream = createReadStream(fp);
stream.on('error', () => {
if (!res.headersSent) res.status(404).json({ error: 'Photo not cached' });
});
stream.pipe(res);
}
@Get(':token')
read(@Param('token') token: string) {
const data = this.share.getSharedTripData(token);
+1
View File
@@ -26,4 +26,5 @@ export class ShareService {
get(tripId: string) { return svc.getShareLink(tripId); }
remove(tripId: string) { return svc.deleteShareLink(tripId); }
getSharedTripData(token: string) { return svc.getSharedTripData(token); }
getSharedPlacePhotoPath(token: string, placeId: string) { return svc.getSharedPlacePhotoPath(token, placeId); }
}
+34 -21
View File
@@ -1,32 +1,45 @@
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import { db } from '../db/database';
import { Trip, Place } from '../types';
// ── Admin-1 GeoJSON cache (sub-national regions) ─────────────────────────
// ── Bundled boundary GeoJSON (admin-0 countries + admin-1 regions) ─────────
//
// Sourced from geoBoundaries (CC BY 4.0), normalized + quantized offline by
// scripts/build-atlas-geo.mjs into gzipped FeatureCollections under server/assets.
// They are read + decompressed once and cached in memory — no network at runtime.
// (Replaces the previous runtime fetch of Natural Earth, which was stale for recent
// sub-national reforms and depicts some contested borders in unwanted ways.)
//
// __dirname is server/dist/services at runtime and server/src/services under vitest;
// both resolve ../../assets to server/assets.
let admin1GeoCache: any = null;
let admin1GeoLoading: Promise<any> | null = null;
const geoBundleCache = new Map<string, any>();
async function loadAdmin1Geo(): Promise<any> {
if (admin1GeoCache) return admin1GeoCache;
if (admin1GeoLoading) return admin1GeoLoading;
admin1GeoLoading = fetch(
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_1_states_provinces.geojson',
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
).then(r => r.json()).then((geo: any) => {
admin1GeoCache = geo;
admin1GeoLoading = null;
console.log(`[Atlas] Cached admin-1 GeoJSON: ${geo.features?.length || 0} features`);
return geo;
}).catch(err => {
admin1GeoLoading = null;
console.error('[Atlas] Failed to load admin-1 GeoJSON:', err);
return null;
});
return admin1GeoLoading;
function loadGeoBundle(name: 'admin0' | 'admin1'): any {
const cached = geoBundleCache.get(name);
if (cached) return cached;
const file = path.join(__dirname, '..', '..', 'assets', 'atlas', `${name}.geojson.gz`);
if (!fs.existsSync(file)) {
console.warn(`[Atlas] ${name}.geojson.gz missing — run \`node scripts/build-atlas-geo.mjs\``);
const empty = { type: 'FeatureCollection', features: [] };
geoBundleCache.set(name, empty);
return empty;
}
const geo = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8'));
geoBundleCache.set(name, geo);
console.log(`[Atlas] Loaded ${name} GeoJSON: ${geo.features?.length || 0} features`);
return geo;
}
/** Full admin-0 country-border FeatureCollection (for the client map's country layer). */
export function getCountryGeo(): any {
return loadGeoBundle('admin0');
}
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
const geo = await loadAdmin1Geo();
const geo = loadGeoBundle('admin1');
if (!geo) return { type: 'FeatureCollection', features: [] };
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
+16
View File
@@ -191,6 +191,22 @@ export function deleteBag(tripId: string | number, bagId: string | number) {
return true;
}
// ── List Templates ─────────────────────────────────────────────────────────
/**
* Read-only template list for trip members (name + item count), so non-admins
* can pick a template to apply. Management (create/edit/delete) stays admin-only
* under /api/admin/packing-templates.
*/
export function listTemplates() {
return db.prepare(`
SELECT pt.id, pt.name,
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count
FROM packing_templates pt
ORDER BY pt.created_at DESC
`).all() as { id: number; name: string; item_count: number }[];
}
// ── Apply Template ─────────────────────────────────────────────────────────
export function applyTemplate(tripId: string | number, templateId: string | number) {
+44 -3
View File
@@ -1,6 +1,24 @@
import { db, canAccessTrip } from '../db/database';
import crypto from 'crypto';
import { loadTagsByPlaceIds } from './queryHelpers';
import { serveFilePath } from './placePhotoCache';
const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/';
/**
* Place photo proxy URLs (`/api/maps/place-photo/<id>/bytes`) are served by the
* JWT-guarded MapsController, so they 401 for an unauthenticated shared-trip
* viewer. Rewrite them to the public, token-scoped equivalent
* (`/api/shared/<token>/place-photo/<id>/bytes`) so thumbnails load in a shared
* link. A simple prefix swap keeps the already-encoded placeId segment intact, so
* the URL round-trips. Non-proxy URLs (data:, /uploads/, null) pass through.
*/
function rewritePlacePhotoUrl(url: string | null | undefined, token: string): string | null {
if (typeof url === 'string' && url.startsWith(PLACE_PHOTO_PROXY_PREFIX)) {
return `/api/shared/${token}/place-photo/${url.slice(PLACE_PHOTO_PROXY_PREFIX.length)}`;
}
return url ?? null;
}
interface SharePermissions {
share_map?: boolean;
@@ -129,7 +147,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
id: a.place_id, name: a.place_name, description: a.place_description,
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
price: a.price, place_time: a.place_time, end_time: a.end_time,
image_url: a.image_url, transport_mode: a.transport_mode,
image_url: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode,
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
tags: tagsByPlace[a.place_id] || [],
}
@@ -147,11 +165,11 @@ export function getSharedTripData(token: string): Record<string, any> | null {
}
// Places
const places = db.prepare(`
const places = (db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id
WHERE p.trip_id = ? ORDER BY p.created_at DESC
`).all(tripId);
`).all(tripId) as any[]).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) }));
// Reservations — include per-day positions so the client can render the same order as the planner
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId) as any[];
@@ -210,3 +228,26 @@ export function getSharedTripData(token: string): Record<string, any> | null {
collab: collabMessages,
};
}
/**
* Resolves the on-disk path for a cached place photo requested through a public
* share link. Validates that the token is valid + unexpired and that the place
* actually belongs to that token's trip (matched via the stored proxy URL, which
* covers both Google `placeId` and Wikimedia `coords:` pseudo-IDs without
* depending on google_place_id). Returns null — never throws — so the caller
* answers a plain 404, mirroring the authenticated bytes endpoint.
*/
export function getSharedPlacePhotoPath(token: string, placeId: string): string | null {
const shareRow = db.prepare(
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).get(token) as { trip_id: string } | undefined;
if (!shareRow) return null;
const expectedUrl = `${PLACE_PHOTO_PROXY_PREFIX}${encodeURIComponent(placeId)}/bytes`;
const place = db.prepare(
'SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?'
).get(shareRow.trip_id, expectedUrl);
if (!place) return null;
return serveFilePath(placeId);
}