Compare commits

..

3 Commits

Author SHA1 Message Date
Maurice ef191ae7dc i18n(auth): passkey strings across all locales
Add login/settings/admin passkey keys to en and all 19 translated locales.
2026-06-05 18:46:23 +02:00
Maurice 7471976c9a feat(auth): passkey enrolment, login button + admin settings UI
PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action.
2026-06-05 18:46:23 +02:00
Maurice 5b8c61d215 feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle
Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers.
2026-06-05 18:46:22 +02:00
167 changed files with 484 additions and 2605 deletions
+2 -7
View File
@@ -48,8 +48,8 @@ RUN apt-get update && \
npm ci --workspace=server --omit=dev && \
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.0.tgz && \
echo "b7058d98990053c7b61847fef0c21e02d59b60e323e2b171ca210b682334e801 /tmp/ki.tgz" | sha256sum -c && \
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
rm /tmp/ki.tgz; \
else \
@@ -68,11 +68,6 @@ ENV QT_QPA_PLATFORM=offscreen
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
COPY --from=server-builder /app/server/dist ./server/dist
# Runtime data assets read from server/assets at runtime: airports.json (flight
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
# only emits dist, so these must be copied explicitly or the features silently
# degrade to empty in the image.
COPY --from=server-builder /app/server/assets ./server/assets
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/
COPY --from=shared-builder /app/shared/dist ./shared/dist
-33
View File
@@ -1,33 +0,0 @@
# Third-party data & attributions
TREK bundles and uses third-party data that requires attribution.
## geoBoundaries — country & sub-national boundaries
The Atlas map's administrative boundaries (admin-0 countries and admin-1
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
`server/assets/atlas/admin1.geojson.gz` and generated by
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
> administrative boundaries. PLoS ONE 15(4): e0231866.
> https://doi.org/10.1371/journal.pone.0231866
geoBoundaries is licensed under **CC BY 4.0**
(https://creativecommons.org/licenses/by/4.0/). Source: https://www.geoboundaries.org/
The bundled files are simplified (coordinate-quantized) and re-tagged with the
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
(gbOpen) release.
## OpenStreetMap — geocoding
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
https://www.openstreetmap.org/copyright
## OurAirports — airport reference data
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
-7
View File
@@ -437,13 +437,6 @@ Caddy handles TLS and WebSockets automatically.
<br />
## Data sources
The Atlas map's country and sub-national (province/county) boundaries come from
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
for full third-party attributions.
## License
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
-1
View File
@@ -394,7 +394,6 @@ export const packingApi = {
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
@@ -175,7 +175,7 @@ describe('CollabNotes', () => {
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-NOTES-013: deleting a note asks for confirmation, then calls DELETE API and removes it', async () => {
it('FE-COMP-NOTES-013: delete note calls DELETE API and removes it from grid', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/trips/1/collab/notes', () =>
@@ -193,11 +193,8 @@ describe('CollabNotes', () => {
);
render(<CollabNotes {...defaultProps} />);
await screen.findByText('Remove Me');
await user.click(screen.getByTitle('Delete'));
// Deleting now asks for confirmation first — the note stays until confirmed.
expect(screen.getByText('Delete note?')).toBeInTheDocument();
expect(screen.getByText('Remove Me')).toBeInTheDocument();
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
const deleteBtn = screen.getByTitle('Delete');
await user.click(deleteBtn);
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
});
+2 -15
View File
@@ -10,7 +10,6 @@ import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import ConfirmDialog from '../shared/ConfirmDialog'
import type { User } from '../../types'
import type { CollabNote } from './CollabNotes.types'
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
@@ -45,7 +44,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
const [previewFile, setPreviewFile] = useState(null)
const [showSettings, setShowSettings] = useState(false)
const [activeCategory, setActiveCategory] = useState(null)
const [pendingDeleteNoteId, setPendingDeleteNoteId] = useState<number | null>(null)
// Empty categories (no notes yet) stored in localStorage
const [emptyCategories, setEmptyCategories] = useState(() => {
@@ -233,7 +231,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
pendingDeleteNoteId, setPendingDeleteNoteId,
}
}
@@ -322,7 +319,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
function CollabNotesGrid(S: NotesState) {
const {
sortedNotes, currentUser, canEdit, handleUpdateNote, setPendingDeleteNoteId,
sortedNotes, currentUser, canEdit, handleUpdateNote, handleDeleteNote,
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
} = S
return (
@@ -355,7 +352,7 @@ function CollabNotesGrid(S: NotesState) {
currentUser={currentUser}
canEdit={canEdit}
onUpdate={handleUpdateNote}
onDelete={setPendingDeleteNoteId}
onDelete={handleDeleteNote}
onEdit={setEditingNote}
onView={setViewingNote}
onPreviewFile={setPreviewFile}
@@ -473,7 +470,6 @@ export default function CollabNotes(props: CollabNotesProps) {
viewingNote, showNewModal, editingNote, previewFile, showSettings,
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId,
} = S
if (loading) return <CollabNotesLoading {...S} />
@@ -531,15 +527,6 @@ export default function CollabNotes(props: CollabNotesProps) {
t={t}
/>
)}
{/* Confirm: delete a collab note — guards against accidental deletion */}
<ConfirmDialog
isOpen={pendingDeleteNoteId !== null}
onClose={() => setPendingDeleteNoteId(null)}
onConfirm={() => { if (pendingDeleteNoteId !== null) handleDeleteNote(pendingDeleteNoteId) }}
title={t('collab.notes.confirmDeleteTitle')}
message={t('collab.notes.confirmDeleteBody')}
/>
</div>
)
}
@@ -16,7 +16,7 @@ interface NoteCardProps {
currentUser: User
canEdit: boolean
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
onDelete: (noteId: number) => void
onDelete: (noteId: number) => Promise<void>
onEdit: (note: CollabNote) => void
onView: (note: CollabNote) => void
onPreviewFile: (file: NoteFile) => void
+3 -14
View File
@@ -131,21 +131,10 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) {
// Pan to the selected place without changing zoom. Offset the centre by the
// side-panel + bottom-inspector padding so the pin lands in the middle of the
// *visible* map area rather than the geometric centre (where the bottom panel
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
// Pan to the selected place without changing zoom
const selected = places.find(p => p.id === selectedPlaceId)
if (selected?.lat != null && selected?.lng != null) {
const latlng: [number, number] = [selected.lat, selected.lng]
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2])
map.panTo(map.unproject(point), { animate: true })
} else {
map.panTo(latlng, { animate: true })
}
if (selected?.lat && selected?.lng) {
map.panTo([selected.lat, selected.lng], { animate: true })
}
}
prev.current = selectedPlaceId
-4
View File
@@ -553,10 +553,6 @@ export function MapViewGL({
zoom: Math.max(map.getZoom(), 14),
pitch: mapbox3d ? 45 : 0,
duration: 400,
// Account for the side panels and the bottom inspector / day-detail panel
// so the selected pin lands in the centre of the *visible* map area rather
// than the geometric centre (where the bottom panel would cover it).
padding: paddingOpts,
})
} catch { /* noop */ }
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -3,7 +3,6 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import { useSettingsStore } from '../../store/settingsStore'
import type { Reservation, ReservationEndpoint } from '../../types'
@@ -43,7 +42,7 @@ function useEndpointPane() {
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
const { icon: IconCmp, color } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span>${escapeHtml(label)}</span>` : ''
const labelHtml = label ? `<span>${label}</span>` : ''
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
return L.divIcon({
className: 'trek-endpoint-marker',
@@ -54,7 +53,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
border:1.5px solid #fff;color:#fff;
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
box-sizing:border-box;height:22px;white-space:nowrap;
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''}</div>`,
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''}</div>`,
iconSize: [estWidth, 22],
iconAnchor: [estWidth / 2, 11],
popupAnchor: [0, -11],
@@ -173,8 +172,8 @@ function buildStatsHtml(color: string, mainLabel: string | null, subLabel: strin
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
@@ -161,62 +161,6 @@ describe('optimizeRoute', () => {
expect(result[1]).toEqual(c)
expect(result[2]).toEqual(b)
})
it('FE-COMP-ROUTECALCULATOR-016: start anchor begins the chain at the anchor-nearest stop', () => {
const a = { lat: 10, lng: 1 }
const b = { lat: 2, lng: 1 }
const c = { lat: 5, lng: 1 }
// From the accommodation anchor (1,1): nearest is b(2,1), then c(5,1), then a(10,1)
const result = optimizeRoute([a, b, c], { start: { lat: 1, lng: 1 } })
expect(result).toEqual([b, c, a])
})
it('FE-COMP-ROUTECALCULATOR-017: start + end anchors reorder a shuffled day and keep the end-nearest stop last', () => {
const a = { lat: 2, lng: 1 }
const b = { lat: 5, lng: 1 }
const c = { lat: 8, lng: 1 }
// Transfer day: start at hotel A (1,1), end at hotel B (9,1). c is nearest B, so it must be last.
const result = optimizeRoute([c, a, b], { start: { lat: 1, lng: 1 }, end: { lat: 9, lng: 1 } })
expect(result).toEqual([a, b, c])
})
it('FE-COMP-ROUTECALCULATOR-018: an anchor makes even a two-stop day sortable', () => {
const a = { lat: 10, lng: 1 }
const b = { lat: 2, lng: 1 }
// Without anchors two stops are returned unchanged; the start anchor orders them by proximity.
const result = optimizeRoute([a, b], { start: { lat: 1, lng: 1 } })
expect(result).toEqual([b, a])
})
it('FE-COMP-ROUTECALCULATOR-019: 2-opt untangles a round-trip into a clean loop around the hotel', () => {
const hotel = { lat: 48.8668, lng: 2.3013 } // Rue Marbeuf
const stops = [
{ id: 1, lat: 48.8565, lng: 2.3324 },
{ id: 2, lat: 48.8813, lng: 2.3151 },
{ id: 3, lat: 48.8796, lng: 2.308 },
{ id: 4, lat: 48.8723, lng: 2.2926 },
{ id: 5, lat: 48.866, lng: 2.3102 }, // nearest the hotel
]
const d = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) =>
Math.hypot(a.lat - b.lat, a.lng - b.lng)
const loop = (order: typeof stops) =>
d(hotel, order[0]) + order.slice(1).reduce((s, p, i) => s + d(order[i], p), 0) + d(order[order.length - 1], hotel)
const result = optimizeRoute(stops, { start: hotel, end: hotel })
// The optimized loop is no longer than the original order…
expect(loop(result)).toBeLessThanOrEqual(loop(stops) + 1e-9)
// …and the hotel-adjacent stop sits at one end of the loop, right next to the hotel.
expect([result[0].id, result[result.length - 1].id]).toContain(5)
})
it('FE-COMP-ROUTECALCULATOR-020: an end anchor without a start finishes at the stop nearest it', () => {
const a = { lat: 2, lng: 1 }
const b = { lat: 5, lng: 1 }
const c = { lat: 9, lng: 1 }
// a is nearest the end anchor, so the route must finish at a rather than start there.
const result = optimizeRoute([a, b, c], { end: { lat: 1, lng: 1 } })
expect(result[result.length - 1]).toEqual(a)
})
})
// ── generateGoogleMapsUrl ──────────────────────────────────────────────────────
+13 -76
View File
@@ -1,4 +1,4 @@
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -77,98 +77,35 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
return `https://www.google.com/maps/dir/${stops}`
}
// Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine.
function sqDist(a: Waypoint, b: Waypoint): number {
return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2
}
/** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */
export function optimizeRoute<T extends Waypoint>(places: T[]): T[] {
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length <= 2) return places
// Length of visiting `order` in sequence, optionally pinned to a fixed start and/or end anchor.
// With start === end this is a closed loop back to the anchor (a day out from and back to the hotel).
function tourLength(order: Waypoint[], start?: Waypoint, end?: Waypoint): number {
if (order.length === 0) return 0
let total = 0
if (start) total += Math.sqrt(sqDist(start, order[0]))
for (let i = 0; i < order.length - 1; i++) total += Math.sqrt(sqDist(order[i], order[i + 1]))
if (end) total += Math.sqrt(sqDist(order[order.length - 1], end))
return total
}
// Greedy nearest-neighbor ordering, seeded at the start anchor when there is one.
function nearestNeighborOrder<T extends Waypoint>(valid: T[], start?: Waypoint): T[] {
const visited = new Set<number>()
const result: T[] = []
let current: Waypoint
if (start) {
current = start
} else {
current = valid[0]
visited.add(0)
result.push(valid[0])
}
let current = valid[0]
visited.add(0)
result.push(current)
while (result.length < valid.length) {
let nearestIdx = -1
let minDist = Infinity
for (let i = 0; i < valid.length; i++) {
if (visited.has(i)) continue
const d = sqDist(valid[i], current)
const d = Math.sqrt(
Math.pow(valid[i].lat - current.lat, 2) + Math.pow(valid[i].lng - current.lng, 2)
)
if (d < minDist) { minDist = d; nearestIdx = i }
}
if (nearestIdx === -1) break
visited.add(nearestIdx)
current = valid[nearestIdx]
result.push(valid[nearestIdx])
result.push(current)
}
return result
}
// 2-opt: repeatedly reverse a sub-segment whenever it shortens the tour. This removes the crossings
// a pure nearest-neighbor pass leaves behind. The start/end anchors stay fixed, so a round trip
// (start === end) is untangled into a clean loop rather than an open path.
function twoOptImprove<T extends Waypoint>(order: T[], start?: Waypoint, end?: Waypoint): T[] {
if (order.length < 3) return order
let best = order
let bestLen = tourLength(best, start, end)
let improved = true
while (improved) {
improved = false
for (let i = 0; i < best.length - 1; i++) {
for (let j = i + 1; j < best.length; j++) {
const candidate = best.slice(0, i).concat(best.slice(i, j + 1).reverse(), best.slice(j + 1))
const len = tourLength(candidate, start, end)
if (len < bestLen - 1e-12) {
best = candidate
bestLen = len
improved = true
}
}
}
}
return best
}
/**
* Reorders waypoints to minimize travel distance: a nearest-neighbor pass for a good starting order,
* then 2-opt to untangle crossings. Optional anchors (e.g. the day's accommodation) pin the route's
* ends start === end makes it a loop out from and back to the hotel; a transfer day runs start end.
*/
export function optimizeRoute<T extends Waypoint>(places: T[], anchors: RouteAnchors = {}): T[] {
const { start, end } = anchors
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length <= 1) return places
// Two unanchored stops have no meaningful order to optimize; anchors can still flip them.
if (valid.length === 2 && !start && !end) return places
const order = twoOptImprove(nearestNeighborOrder(valid, start), start, end)
// A round trip's loop direction is arbitrary, so orient it to begin at the stop nearest the hotel —
// that reads naturally as "leave the hotel, head to the closest place, …, come back".
if (start && end && start.lat === end.lat && start.lng === end.lng && order.length > 1) {
if (sqDist(order[order.length - 1], start) < sqDist(order[0], start)) order.reverse()
}
return order
}
/** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */
export async function calculateSegments(
waypoints: Waypoint[],
@@ -10,7 +10,6 @@ import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types'
export const RESERVATION_SOURCE_ID = 'trek-reservations'
@@ -162,7 +161,7 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
function endpointMarkerHtml(type: TransportType, label: string | null): string {
const { icon: IconCmp } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''
return `<div style="
display:inline-flex;align-items:center;justify-content:center;gap:4px;
padding:0 8px;border-radius:999px;
@@ -180,8 +179,8 @@ function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { ht
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
@@ -146,20 +146,4 @@ describe('downloadJourneyBookPDF', () => {
expect(html).toContain('Journey Book');
expect(html).toContain('The End');
});
it('FE-COMP-JOURNEYPDF-007: sanitises HTML injected via an entry story and keeps the iframe script-free', async () => {
const journey = buildJourney();
journey.entries[0].story = 'Hello <script>alert(1)</script> <img src=x onerror="alert(2)"> world';
await downloadJourneyBookPDF(journey);
const iframe = getIframe()!;
const html = iframe.srcdoc;
// The script tag, image beacon and event handler are stripped from the story.
expect(html).not.toContain('<script');
expect(html).not.toContain('onerror');
expect(html).not.toContain('alert(2)');
// Benign prose survives.
expect(html).toContain('Hello');
expect(html).toContain('world');
});
});
+2 -7
View File
@@ -1,6 +1,5 @@
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
import { marked } from 'marked'
import { sanitizeRichTextHtml } from '@trek/shared'
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
function esc(str: string | null | undefined): string {
@@ -10,9 +9,7 @@ function esc(str: string | null | undefined): string {
function md(str: string | null | undefined): string {
if (!str) return ''
// marked passes embedded raw HTML through by default, so sanitise the result
// before it goes into the srcdoc iframe (keeps prose markup, drops scripts).
return sanitizeRichTextHtml(marked.parse(str, { async: false, breaks: true }) as string)
return marked.parse(str, { async: false, breaks: true }) as string
}
function abs(url: string | null | undefined): string {
@@ -311,9 +308,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
// No script runs inside the document (print is triggered from the parent via
// contentWindow.print()), so withhold allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
iframe.srcdoc = html
card.appendChild(header)
-17
View File
@@ -259,23 +259,6 @@ describe('downloadTripPDF', () => {
expect(iframe!.srcdoc).toContain('colosseum.jpg')
})
it('FE-COMP-TRIPPDF-018b: renders a persisted place-photo proxy image_url as an <img>, not the category icon (#1130)', async () => {
const args = {
...richArgs,
assignments: {
'10': [{
...assignmentForDay,
place: { ...placeWithDetails, image_url: '/api/maps/place-photo/ChIJabc/bytes' },
}],
} as any,
}
await downloadTripPDF(args)
const iframe = getIframe()
// The proxy path (no file extension) must still embed as an absolute <img>.
expect(iframe!.srcdoc).toContain('http://localhost:3000/api/maps/place-photo/ChIJabc/bytes')
expect(iframe!.srcdoc).toContain('class="place-thumb"')
})
it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => {
let photoCalled = false
server.use(
+3 -10
View File
@@ -55,10 +55,6 @@ function absUrl(url) {
function safeImg(url) {
if (!url) return null
if (url.startsWith('https://') || url.startsWith('http://')) return url
// The in-app place-photo proxy always streams a JPEG but has no file extension
// (it ends in …/bytes), so the extension check below would wrongly reject it —
// which is why persisted place photos showed as category icons in the PDF.
if (url.startsWith('/api/maps/place-photo/')) return absUrl(url)
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
}
@@ -258,10 +254,9 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const cat = categories.find(c => c.id === place.category_id)
const color = cat?.color || '#6366f1'
// Image: direct > google photo > fallback icon. Both go through safeImg
// so the proxy path is resolved to an absolute URL the PDF can load.
// Image: direct > google photo > fallback icon
const directImg = safeImg(place.image_url)
const googleImg = safeImg(photoMap[place.id])
const googleImg = photoMap[place.id] || null
const img = directImg || googleImg
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
@@ -574,9 +569,7 @@ ${daysHtml}
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
// No script runs inside the document (print is parent-initiated), so withhold
// allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
iframe.srcdoc = html
card.appendChild(header)
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { Package } from 'lucide-react'
import { packingApi } from '../../api/client'
import { adminApi, packingApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -28,7 +28,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
const { t } = useTranslation()
useEffect(() => {
packingApi.listTemplates(tripId).then(d => setTemplates(d.templates || [])).catch(() => {})
adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
}, [tripId])
useEffect(() => {
@@ -7,7 +7,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
import PackingListPanel, { itemWeight } from './PackingListPanel';
describe('itemWeight (bag total weight calc)', () => {
@@ -34,10 +34,10 @@ beforeEach(() => {
http.get('/api/trips/:id/packing/category-assignees', () =>
HttpResponse.json({ assignees: {} })
),
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: false, addons: [] })
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: false })
),
http.get('/api/trips/:id/packing/templates', () =>
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [] })
),
);
@@ -381,7 +381,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-030: packing template button present when templates available', async () => {
server.use(
http.get('/api/trips/:id/packing/templates', () =>
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [{ id: 1, name: 'Beach Trip', item_count: 5 }] })
)
);
@@ -457,8 +457,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => {
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: true, addons: [] })
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] })
@@ -556,8 +556,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: true, addons: [] })
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
@@ -585,8 +585,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => {
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: true, addons: [] })
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] })
@@ -601,36 +601,26 @@ describe('PackingListPanel', () => {
});
});
it('FE-COMP-PACKING-041: save-as-template button present for admins when items exist', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
it('FE-COMP-PACKING-041: save-as-template button present when items exist', async () => {
const user = userEvent.setup();
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
render(<PackingListPanel tripId={1} items={items} />);
const { container } = render(<PackingListPanel tripId={1} items={items} />);
// Save-as-template button shows its label "Save as template"
const saveBtn = screen.getByText('Save as template').closest('button');
expect(saveBtn).toBeTruthy();
// Save-as-template button uses FolderPlus icon and "Save as template" text
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
expect(folderPlusBtn).toBeTruthy();
// Click to show the name input
await user.click(saveBtn!);
await user.click(folderPlusBtn!);
// Template name input appears
expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument();
});
it('FE-COMP-PACKING-041b: save-as-template button hidden for non-admins', () => {
// Default seeded user (beforeEach) is a non-admin trip owner with edit rights.
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
render(<PackingListPanel tripId={1} items={items} />);
// The "Save as template" action must not be available to normal users.
expect(screen.queryByText('Save as template')).not.toBeInTheDocument();
});
it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/trips/:id/packing/templates', () =>
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [{ id: 2, name: 'Summer Packing', item_count: 10 }] })
)
);
@@ -668,8 +658,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => {
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: true, addons: [] })
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [] })
@@ -716,7 +706,6 @@ describe('PackingListPanel', () => {
});
it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
const user = userEvent.setup();
let savedTemplateName = '';
server.use(
@@ -725,16 +714,16 @@ describe('PackingListPanel', () => {
savedTemplateName = String(body.name);
return HttpResponse.json({ success: true });
}),
http.get('/api/trips/:id/packing/templates', () =>
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [] })
)
);
const items = [buildPackingItem({ name: 'Item', category: 'Test' })];
render(<PackingListPanel tripId={1} items={items} />);
const { container } = render(<PackingListPanel tripId={1} items={items} />);
// Click the "Save as template" button
const saveBtn = screen.getByText('Save as template').closest('button');
await user.click(saveBtn!);
// Click the FolderPlus "Save as template" button
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
await user.click(folderPlusBtn!);
// Type template name
const nameInput = await screen.findByPlaceholderText('Template name');
@@ -747,8 +736,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: true, addons: [] })
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] })
@@ -776,8 +765,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: true, addons: [] })
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
@@ -816,8 +805,8 @@ describe('PackingListPanel', () => {
let putBody: Record<string, unknown> | null = null;
const itemId = 120;
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: true, addons: [] })
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [] })
@@ -872,8 +861,8 @@ describe('PackingListPanel', () => {
const itemId = 130;
let putBody: Record<string, unknown> | null = null;
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: true, addons: [] })
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] })
@@ -941,8 +930,8 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => {
const itemId = 140;
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: true, addons: [] })
http.get('/api/admin/bag-tracking', () =>
HttpResponse.json({ enabled: true })
),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] })
@@ -968,7 +957,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/trips/:id/packing/templates', () =>
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] })
)
);
@@ -1135,7 +1124,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup();
let applyCalled = false;
server.use(
http.get('/api/trips/:id/packing/templates', () =>
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] })
),
http.post('/api/trips/1/packing/apply-template/5', () => {
@@ -1188,7 +1177,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup();
let createBody: Record<string, unknown> | null = null;
server.use(
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
// Start with one bag so the sidebar renders (sidebar requires bags.length > 0)
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
@@ -1218,7 +1207,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
),
@@ -1246,7 +1235,7 @@ describe('PackingListPanel', () => {
const user = userEvent.setup();
let updateBody: Record<string, unknown> | null = null;
server.use(
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] })
),
@@ -1284,7 +1273,7 @@ describe('PackingListPanel', () => {
current_user_id: 1,
})
),
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] })
)
@@ -1325,7 +1314,7 @@ describe('PackingListPanel', () => {
current_user_id: 1,
})
),
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () =>
HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] })
),
@@ -1363,7 +1352,7 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-068: inline bag create in item row picker creates bag and assigns it', async () => {
let createBody: Record<string, unknown> | null = null;
server.use(
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
http.post('/api/trips/1/packing/bags', async ({ request }) => {
createBody = await request.json() as Record<string, unknown>;
@@ -5,7 +5,7 @@ import type { PackingState } from './usePackingListPanel'
export function PackingHeader(S: PackingState) {
const {
inlineHeader, t, items, abgehakt, fortschritt, canEdit, isAdmin,
inlineHeader, t, items, abgehakt, fortschritt, canEdit,
showSaveTemplate, saveTemplateName, setSaveTemplateName, handleSaveAsTemplate, setShowSaveTemplate,
setShowImportModal, handleClearChecked, availableTemplates, templateDropdownRef,
showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, handleApplyTemplate,
@@ -26,7 +26,7 @@ export function PackingHeader(S: PackingState) {
</div>
) : <span />}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{canEdit && isAdmin && items.length > 0 && showSaveTemplate && (
{canEdit && items.length > 0 && showSaveTemplate && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
type="text" autoFocus
@@ -97,7 +97,7 @@ export function PackingHeader(S: PackingState) {
)}
</div>
)}
{inlineHeader && canEdit && isAdmin && items.length > 0 && !showSaveTemplate && (
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
<button onClick={() => setShowSaveTemplate(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
@@ -2,11 +2,9 @@ import { useState, useMemo, useRef, useEffect } from 'react'
import type { ChangeEvent } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { packingApi, tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import { packingApi, tripsApi, adminApi } from '../../api/client'
import type { PackingItem, PackingBag } from '../../types'
import { BAG_COLORS } from './packingListPanel.constants'
import { parseImportLines } from './packingListPanel.helpers'
@@ -48,7 +46,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
const toast = useToast()
const { t } = useTranslation()
@@ -148,24 +145,19 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
if (failed) toast.error(t('packing.toast.deleteError'))
}
// Bag tracking — the global toggle is a packing sub-flag surfaced to every
// authenticated user via the addon store (loaded on app start), not the
// admin-only endpoint, so non-admin members see weights/bags too.
const bagTrackingEnabled = useAddonStore(s => s.bagTracking)
const addonsLoaded = useAddonStore(s => s.loaded)
const loadAddons = useAddonStore(s => s.loadAddons)
// Bag tracking
const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false)
const [bags, setBags] = useState<PackingBag[]>([])
const [newBagName, setNewBagName] = useState('')
const [showAddBag, setShowAddBag] = useState(false)
const [showBagModal, setShowBagModal] = useState(false)
useEffect(() => {
if (!addonsLoaded) loadAddons()
}, [addonsLoaded, loadAddons])
useEffect(() => {
if (bagTrackingEnabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
}, [tripId, bagTrackingEnabled])
adminApi.getBagTracking().then(d => {
setBagTrackingEnabled(d.enabled)
if (d.enabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
}).catch(() => {})
}, [tripId])
const handleCreateBag = async () => {
if (!newBagName.trim()) return
@@ -242,7 +234,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const templateDropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
}, [tripId])
useEffect(() => {
@@ -275,7 +267,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
toast.success(t('packing.templateSaved'))
setShowSaveTemplate(false)
setSaveTemplateName('')
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
} catch {
toast.error(t('common.error'))
}
@@ -305,7 +297,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const font = { fontFamily: "var(--font-system)" }
return {
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
tripId, items, inlineHeader, t, canEdit, font,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
@@ -982,7 +982,7 @@ describe('DayPlanSidebar', () => {
}
})
it('FE-PLANNER-DAYPLAN-065: deleting a note asks for confirmation before calling deleteNote', async () => {
it('FE-PLANNER-DAYPLAN-065: note card delete button calls deleteNote', async () => {
const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
@@ -992,11 +992,6 @@ describe('DayPlanSidebar', () => {
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
if (noteEditBtns.length > 1) {
await user.click(noteEditBtns[1] as HTMLElement)
// Clicking delete opens a confirmation dialog rather than deleting immediately.
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
expect(screen.getByText('Delete note?')).toBeInTheDocument()
// Confirming triggers the actual delete.
await user.click(screen.getByRole('button', { name: /^delete$/i }))
expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
}
})
@@ -7,7 +7,6 @@ import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLi
import { assignmentsApi, reservationsApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -18,7 +17,7 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
@@ -452,10 +451,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
_openEditNote(dayId, note)
}
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
// easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable.
const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null)
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
e?.stopPropagation()
await _deleteNote(dayId, noteId)
@@ -708,14 +703,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// Optimize only unlocked assignments (work on assignments, not places)
const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng)
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
const day = days.find(d => d.id === selectedDayId)
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
? getAccommodationAnchors(day, days, accommodations)
: {}
const optimizedAssignments = unlockedWithCoords.length >= 2
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id })), anchors).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id }))).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
: unlockedWithCoords
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
@@ -728,8 +717,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}
await onReorder(selectedDayId, result.map(a => a.id))
const usedHotel = !!(anchors.start || anchors.end)
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
toast.success(t('dayplan.toast.routeOptimized'))
const capturedDayId = selectedDayId
pushUndo?.(t('undo.optimize'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
@@ -863,8 +851,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
cancelNote,
saveNote,
deleteNote,
pendingDeleteNote,
setPendingDeleteNote,
moveNote,
expandedDays,
setExpandedDays,
@@ -1007,8 +993,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
cancelNote,
saveNote,
deleteNote,
pendingDeleteNote,
setPendingDeleteNote,
moveNote,
expandedDays,
setExpandedDays,
@@ -1924,7 +1908,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onContextMenu={canEditDays ? e => ctxMenu.open(e, [
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
{ divider: true },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
]) : undefined}
onMouseEnter={e => {
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
@@ -1966,7 +1950,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Pencil size={10} /></button>
<button onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Trash2 size={10} /></button>
<button onClick={e => deleteNote(day.id, note.id, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Trash2 size={10} /></button>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
@@ -2109,15 +2093,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
t={t}
/>
{/* Confirm: delete a day note — guards against accidental taps on touch devices */}
<ConfirmDialog
isOpen={!!pendingDeleteNote}
onClose={() => setPendingDeleteNote(null)}
onConfirm={() => { if (pendingDeleteNote) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }}
title={t('dayplan.confirmDeleteNoteTitle')}
message={t('dayplan.confirmDeleteNoteBody')}
/>
{/* Transport-Detail-Modal */}
<DayPlanSidebarTransportDetailModal
transportDetail={transportDetail}
@@ -270,18 +270,6 @@ describe('PlaceFormModal', () => {
expect(screen.getByText(/No category/i)).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-023b: editing a place shows its assigned category, not the placeholder (#1134)', () => {
// Regression: form.category_id is a string but the option values were numbers,
// so CustomSelect's strict-equality match failed and the trigger fell back to
// "No category". With string option values the chosen category renders.
const cat = buildCategory({ name: 'Museums' });
const place = buildPlace({ name: 'Louvre', category_id: cat.id });
render(<PlaceFormModal {...defaultProps} place={place} categories={[cat]} />);
// Dropdown is closed, so the only place the category name can appear is the trigger.
expect(screen.getByText('Museums')).toBeInTheDocument();
expect(screen.queryByText(/No category/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => {
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
// Directly invoke handleCreateCategory by setting showNewCategory via the category name input
@@ -636,10 +636,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
options={[
{ value: '', label: t('places.noCategory') },
...(categories || []).map(c => ({
// form.category_id is a string; CustomSelect matches options by
// strict equality, so the option value must be a string too —
// otherwise the chosen category never renders in the trigger.
value: String(c.id),
value: c.id,
label: c.name,
})),
]}
@@ -291,37 +291,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
))}
</div>
</div>
{/* Optimize route from accommodation */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.optimizeFromAccommodation')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('optimize_from_accommodation', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: (settings.optimize_from_accommodation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.optimize_from_accommodation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs mt-1 text-content-faint">{t('settings.optimizeFromAccommodationHint')}</p>
</div>
</Section>
)
}
-18
View File
@@ -148,24 +148,6 @@ export async function upsertSyncMeta(meta: SyncMeta): Promise<void> {
await offlineDb.syncMeta.put(meta);
}
/**
* Read a pre-downloaded file blob for offline use. Returns null when the file
* was never cached (or on any read error). The stored MIME is reapplied so the
* caller's inline-vs-download decision stays correct even if the persisted Blob
* lost its type.
*/
export async function getCachedBlob(url: string): Promise<Blob | null> {
try {
const entry = await offlineDb.blobCache.get(url);
if (!entry) return null;
return entry.blob.type
? entry.blob
: new Blob([entry.blob], { type: entry.mime || 'application/octet-stream' });
} catch {
return null;
}
}
// ── Eviction / cleanup ────────────────────────────────────────────────────────
/** Delete all cached data for one trip (eviction or explicit clear). */
+145 -51
View File
@@ -175,9 +175,6 @@ function useDefaultAtlasHandlers() {
http.get('/api/addons/atlas/stats', () => HttpResponse.json(atlasStatsResponse)),
http.get('/api/addons/atlas/bucket-list', () => HttpResponse.json({ items: [] })),
http.get('/api/addons/atlas/regions', () => HttpResponse.json({ regions: {} })),
// Country-border GeoJSON (admin-0) — served by the API now. Tests that need real
// country features override this handler via server.use(...).
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json({ type: 'FeatureCollection', features: [] })),
// Handler for region GeoJSON fetch (triggered by loadRegionsForViewport when intersects=true)
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
);
@@ -190,6 +187,18 @@ beforeEach(() => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
// Stub the external GeoJSON fetch (GitHub raw URL) to avoid real network calls
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ type: 'FeatureCollection', features: [] }),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
useDefaultAtlasHandlers();
});
@@ -460,9 +469,16 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-017: country search filters options from GeoJSON', () => {
it('typing in search updates the input value', async () => {
// Override fetch to return GeoJSON with FR feature
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
const user = userEvent.setup();
render(<AtlasPage />);
@@ -503,9 +519,16 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-019: confirm popup shows via Enter on search with GeoJSON', () => {
it('pressing Enter in search with matching GeoJSON result triggers confirm popup', async () => {
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
@@ -577,9 +600,16 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-022: confirm popup for bucket type shows month/year selects', () => {
it('selecting Add to bucket list in confirm popup shows month/year pickers', async () => {
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
const user = userEvent.setup();
render(<AtlasPage />);
@@ -612,9 +642,16 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-031: confirm popup opens and mark-visited action works', () => {
it('opens confirm popup via search and clicking Mark as visited closes it', async () => {
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
@@ -673,9 +710,16 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-032: confirm popup Add to Bucket opens bucket type', () => {
it('clicking Add to bucket list in choose popup switches to bucket type', async () => {
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
const user = userEvent.setup();
render(<AtlasPage />);
@@ -807,9 +851,16 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-029: confirm popup opens via search dropdown click', () => {
it('clicking a country in the search dropdown opens the confirm action popup', async () => {
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
@@ -863,9 +914,16 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-030: confirm popup overlay click closes it', () => {
it('clicking the overlay backdrop closes the confirm popup', async () => {
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(geoJsonWithFR),
} as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
const user = userEvent.setup();
render(<AtlasPage />);
@@ -942,9 +1000,13 @@ describe('AtlasPage', () => {
{ type: 'Feature', properties: { ISO_A2: 'DE', ADM0_A3: 'DEU', ISO_A3: 'DEU', NAME: 'Germany', ADMIN: 'Germany' }, geometry: null },
],
};
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandDE)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonFRandDE) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
render(<AtlasPage />);
@@ -961,9 +1023,13 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-034: dropdown button click + mouse events', () => {
it('clicking France dropdown button covers onClick and mouse event handlers', async () => {
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
@@ -1034,9 +1100,13 @@ describe('AtlasPage', () => {
http.get('/api/addons/atlas/stats', () => HttpResponse.json(emptyAtlasResponse)),
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
);
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
const user = userEvent.setup();
render(<AtlasPage />);
@@ -1088,9 +1158,13 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-036: bucket popup submit action', () => {
it('submits a bucket list item from the confirm popup', async () => {
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.post('/api/addons/atlas/bucket-list', () =>
@@ -1247,9 +1321,13 @@ describe('AtlasPage', () => {
},
],
};
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithXK)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithXK) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
render(<AtlasPage />);
@@ -1267,9 +1345,13 @@ describe('AtlasPage', () => {
{ a3: 'FRA', name: 'France', query: 'france' },
{ a3: 'NOR', name: 'Norway', query: 'norway' },
])('returns $name in search results when GeoJSON provides ADM0_A3=$a3 but ISO_A2 is -99', async ({ a3, name, query }) => {
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(makeGeoJsonWithA3Fallback(a3, name))),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(makeGeoJsonWithA3Fallback(a3, name)) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
const user = userEvent.setup();
render(<AtlasPage />);
@@ -1377,9 +1459,13 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-044: direct France dropdown button click', () => {
it('directly finds and clicks the France button in the dropdown to cover onClick', async () => {
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
@@ -1431,9 +1517,13 @@ describe('AtlasPage', () => {
describe('FE-PAGE-ATLAS-045: dark mode toggle covers map re-init + loadRegionsForViewport', () => {
it('switching to dark mode re-initializes map and covers region loading code path', async () => {
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
server.use(
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
@@ -1546,9 +1636,13 @@ describe('AtlasPage', () => {
{ type: 'Feature', properties: { ISO_A2: 'IT', ADM0_A3: 'ITA', ISO_A3: 'ITA', NAME: 'Italy', ADMIN: 'Italy' }, geometry: null },
],
};
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandIT)),
);
vi.spyOn(global, 'fetch').mockImplementation((url) => {
const urlStr = String(url);
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonFRandIT) } as Response);
}
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
});
render(<AtlasPage />);
+1 -2
View File
@@ -53,7 +53,6 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
const [addTodoSignal, setAddTodoSignal] = useState(0)
const { t } = useTranslation()
const isAdmin = useAuthStore(s => s.user?.role === 'admin')
const tabs = [
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
@@ -122,7 +121,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
className={`${sharedBtnClass} bg-accent text-accent-text`}
style={sharedBtnStyle}
/>
{isAdmin && packingItems.length > 0 && (
{packingItems.length > 0 && (
<button onClick={() => setSaveTemplateSignal(s => s + 1)}
className={`${sharedBtnClass} bg-accent text-accent-text`}
style={sharedBtnStyle}
+7 -8
View File
@@ -132,19 +132,18 @@ export function useAtlas() {
}).catch(() => setLoading(false))
}, [])
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
// no third-party fetch from the browser).
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
useEffect(() => {
apiClient.get('/addons/atlas/countries/geo')
.then(res => {
const geo = res.data
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
.then(r => r.json())
.then(geo => {
// Dynamically build A2→A3 mapping from GeoJSON
for (const f of geo.features) {
const a2 = f.properties?.ISO_A2
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
// Only accept clean 2-letter ISO codes and never overwrite an existing
// mapping: some datasets carry subdivision-style values like "CN-TW" for
// Taiwan, which would clobber the legitimate TWN->TW entry (#1049).
// Only real 2-letter ISO codes: natural-earth uses subdivision-style
// values like "CN-TW" for Taiwan, which would otherwise overwrite the
// legitimate TWN->TW reverse mapping and break the country (#1049).
if (a2 && a3 && a2.length === 2 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
A2_TO_A3[a2] = a3
}
+1 -3
View File
@@ -24,7 +24,6 @@ interface Addon {
interface AddonState {
addons: Addon[]
bagTracking: boolean
loaded: boolean
loadAddons: () => Promise<void>
isEnabled: (id: string) => boolean
@@ -32,13 +31,12 @@ interface AddonState {
export const useAddonStore = create<AddonState>((set, get) => ({
addons: [],
bagTracking: false,
loaded: false,
loadAddons: async () => {
try {
const data = await addonsApi.enabled()
set({ addons: data.addons || [], bagTracking: !!data.bagTracking, loaded: true })
set({ addons: data.addons || [], loaded: true })
} catch {
set({ loaded: true })
}
-1
View File
@@ -32,7 +32,6 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
temperature_unit: 'fahrenheit',
time_format: '12h',
show_place_description: false,
optimize_from_accommodation: true,
map_provider: 'leaflet',
mapbox_access_token: '',
mapbox_style: 'mapbox://styles/mapbox/standard',
-17
View File
@@ -580,23 +580,6 @@
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
.trek-dash .add-trip-card { min-height: 180px; }
/* Compact list row on mobile keeps the list view distinct from the grid. The
desktop list row uses a 520px cover, which overflowed the phone width: the
cover was clipped, the body pushed off-screen, and the fixed 100px cover
height left a white strip beneath it. Use a fitting cover that stretches to
the row, and show just the title + dates (the counts live in grid view and
on the trip itself). */
.trek-dash .trips.list-view .trip-card { grid-template-columns: 42% 1fr; min-height: 92px; }
.trek-dash .trips.list-view .trip-cover { height: auto; aspect-ratio: unset; }
.trek-dash .trips.list-view .trip-cover-content { left: 14px; right: 14px; bottom: 12px; }
.trek-dash .trips.list-view .trip-name {
font-size: 17px; overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-start; padding: 12px 16px; }
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: flex-start; }
.trek-dash .trips.list-view .trip-meta { display: none; }
/* Tools — stacked full-width cards (mockup) */
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; }
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
-7
View File
@@ -113,7 +113,6 @@ export interface Settings {
show_place_description: boolean
blur_booking_codes?: boolean
map_booking_labels?: boolean
optimize_from_accommodation?: boolean
map_provider?: 'leaflet' | 'mapbox-gl'
mapbox_access_token?: string
mapbox_style?: string
@@ -163,12 +162,6 @@ export interface Waypoint {
lng: number
}
// Optional fixed start/end points for route optimization (e.g. the day's accommodation).
export interface RouteAnchors {
start?: Waypoint
end?: Waypoint
}
// User with optional OIDC fields
export interface UserWithOidc extends User {
oidc_issuer?: string | null
-73
View File
@@ -1,73 +0,0 @@
import { describe, it, expect } from 'vitest'
import type { Day, Accommodation } from '../types'
import { getDayOrder, isDayInAccommodationRange, getAccommodationAnchors } from './dayOrder'
const days = [
{ id: 10, day_number: 1 },
{ id: 20, day_number: 2 },
{ id: 30, day_number: 3 },
] as unknown as Day[]
const hotel = (over: Partial<Accommodation>): Accommodation =>
({ place_lat: 48.1, place_lng: 11.5, start_day_id: 10, end_day_id: 30, ...over }) as Accommodation
describe('getDayOrder', () => {
it('prefers day_number when present', () => {
expect(getDayOrder(days[1], days)).toBe(2)
})
it('falls back to array index when day_number is missing', () => {
const noNumber = [{ id: 5 }, { id: 6 }] as unknown as Day[]
expect(getDayOrder(noNumber[1], noNumber)).toBe(1)
})
})
describe('isDayInAccommodationRange', () => {
it('is inclusive of both the check-in and check-out day', () => {
expect(isDayInAccommodationRange(days[0], 10, 30, days)).toBe(true) // check-in morning
expect(isDayInAccommodationRange(days[1], 10, 30, days)).toBe(true) // mid-stay
expect(isDayInAccommodationRange(days[2], 10, 30, days)).toBe(true) // check-out day
})
it('excludes days outside the stay', () => {
expect(isDayInAccommodationRange(days[0], 20, 30, days)).toBe(false)
})
})
describe('getAccommodationAnchors', () => {
it('returns no anchors when the day has no accommodation', () => {
expect(getAccommodationAnchors(days[1], days, [])).toEqual({})
})
it('anchors both ends to the same hotel on a mid-stay day (round trip)', () => {
const accs = [hotel({ start_day_id: 10, end_day_id: 30, place_lat: 48.1, place_lng: 11.5 })]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({
start: { lat: 48.1, lng: 11.5 },
end: { lat: 48.1, lng: 11.5 },
})
})
it('loops a single hotel on its check-out day (home base for the day)', () => {
const accs = [hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 2 })]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({ start: { lat: 1, lng: 2 }, end: { lat: 1, lng: 2 } })
})
it('loops a single hotel on its check-in day (home base for the day)', () => {
const accs = [hotel({ start_day_id: 20, end_day_id: 30, place_lat: 3, place_lng: 4 })]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({ start: { lat: 3, lng: 4 }, end: { lat: 3, lng: 4 } })
})
it('uses the checked-out hotel as start and the checked-in hotel as end on a transfer day', () => {
const accs = [
hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 }), // checkout today
hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 }), // check-in today
]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({
start: { lat: 1, lng: 1 },
end: { lat: 9, lng: 9 },
})
})
it('ignores accommodations that have no coordinates', () => {
const accs = [hotel({ start_day_id: 10, end_day_id: 30, place_lat: null, place_lng: null })]
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({})
})
})
+1 -27
View File
@@ -1,34 +1,8 @@
import type { Day, Accommodation, RouteAnchors } from '../types'
import type { Day } from '../types'
export const getDayOrder = (day: Day, days: Day[]): number =>
day.day_number ?? days.indexOf(day)
// Derives route anchors from the accommodation(s) active on a day. A single hotel is the day's home
// base, so the route is a loop that starts and ends there. A transfer day — checking out of one hotel
// and into another — instead runs from the morning hotel to the evening one.
export const getAccommodationAnchors = (
day: Day,
days: Day[],
accommodations: Accommodation[],
): RouteAnchors => {
const located = accommodations.filter(a =>
a.place_lat != null && a.place_lng != null &&
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
)
if (located.length === 0) return {}
const toAnchor = (a: Accommodation) => ({ lat: a.place_lat as number, lng: a.place_lng as number })
const checkOut = located.find(a => a.end_day_id === day.id) // the hotel you leave this morning
const checkIn = located.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
if (checkOut && checkIn && checkOut !== checkIn) {
return { start: toAnchor(checkOut), end: toAnchor(checkIn) }
}
const hotel = toAnchor(located[0])
return { start: hotel, end: hotel }
}
export const isDayInAccommodationRange = (
day: Day,
startDayId: number,
+9 -37
View File
@@ -1,5 +1,3 @@
import { getCachedBlob } from '../db/offlineDb'
// MIME types safe to open inline (will not execute script in any browser).
// Everything else (text/html, image/svg+xml, text/javascript, …) is forced to
// download so a maliciously-named upload cannot run code in the TREK origin.
@@ -41,46 +39,17 @@ function isIosStandalone(): boolean {
return (navigator as any).standalone === true
}
/**
* Resolves a protected file to a Blob, preferring the live server but falling
* back to the offline cache (pre-downloaded by the trip sync manager). This is
* what lets attachments open in a PWA / airplane mode. When offline we go
* straight to the cache; when online we fetch live and only fall back if the
* network actually fails which also covers flaky links where navigator.onLine
* still reports true ("sometimes it works, sometimes it doesn't").
*/
async function getFileBlob(url: string): Promise<Blob> {
assertRelativeUrl(url)
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
const cached = await getCachedBlob(url)
if (cached) return cached
throw new Error('File not available offline')
}
let resp: Response
try {
resp = await fetch(url, { credentials: 'include' })
} catch (err) {
// Genuine network failure — the fetch itself rejected (offline, or a flaky
// link even though navigator.onLine is true). Serve the pre-downloaded copy.
const cached = await getCachedBlob(url)
if (cached) return cached
throw err
}
// The server answered: a non-ok status (401/403/404/…) is a real error and must
// surface, not be masked by a stale cached copy.
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
return await resp.blob()
}
/**
* Fetches a protected file using cookie auth (credentials: include) and
* triggers a browser download. Works inside PWA standalone mode because the
* fetch stays in the PWA's WebView rather than handing off to the system
* browser (which would lose the session cookie). Falls back to the offline
* cache when the network is unavailable.
* browser (which would lose the session cookie).
*/
export async function downloadFile(url: string, filename?: string): Promise<void> {
const blob = await getFileBlob(url)
assertRelativeUrl(url)
const resp = await fetch(url, { credentials: 'include' })
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
const blob = await resp.blob()
const blobUrl = URL.createObjectURL(blob)
triggerAnchorDownload(blobUrl, filename)
}
@@ -103,7 +72,10 @@ export async function downloadFile(url: string, filename?: string): Promise<void
* spurious in-page download is triggered.
*/
export async function openFile(url: string, filename?: string): Promise<void> {
const blob = await getFileBlob(url)
assertRelativeUrl(url)
const resp = await fetch(url, { credentials: 'include' })
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
const blob = await resp.blob()
const blobUrl = URL.createObjectURL(blob)
// Force download for MIME types that can execute script when rendered inline
@@ -3,7 +3,6 @@ import { http, HttpResponse } from 'msw';
export const addonHandlers = [
http.get('/api/addons', () => {
return HttpResponse.json({
bagTracking: false,
addons: [
{ id: 'vacay', name: 'Vacay', type: 'feature', icon: 'calendar', enabled: true },
{ id: 'atlas', name: 'Atlas', type: 'feature', icon: 'map', enabled: true },
@@ -18,18 +18,6 @@ describe('addonStore', () => {
expect(state.addons.length).toBeGreaterThan(0);
expect(state.addons[0]).toHaveProperty('id');
expect(state.addons[0]).toHaveProperty('enabled', true);
expect(state.bagTracking).toBe(false);
});
it('captures the global bagTracking flag from the response', async () => {
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ bagTracking: true, addons: [] })
)
);
await useAddonStore.getState().loadAddons();
expect(useAddonStore.getState().bagTracking).toBe(true);
});
});
@@ -1,9 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { downloadFile, openFile } from '../../../src/utils/fileDownload'
import { getCachedBlob } from '../../../src/db/offlineDb'
// Mock the offline DB so these tests never touch Dexie/IndexedDB.
vi.mock('../../../src/db/offlineDb', () => ({ getCachedBlob: vi.fn() }))
function makeFetchMock(status: number, blob: Blob = new Blob(['data'], { type: 'application/pdf' })) {
return vi.fn().mockResolvedValue({
@@ -174,52 +170,3 @@ describe('openFile', () => {
}
})
})
describe('offline fallback (#1046)', () => {
function setOnline(value: boolean) {
Object.defineProperty(navigator, 'onLine', { value, configurable: true })
}
beforeEach(() => vi.mocked(getCachedBlob).mockReset())
afterEach(() => setOnline(true))
it('serves the cached blob without a network call when offline', async () => {
setOnline(false)
const blob = new Blob(['x'], { type: 'application/pdf' })
vi.mocked(getCachedBlob).mockResolvedValue(blob)
const fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await downloadFile('/uploads/files/cached.pdf')
expect(fetchSpy).not.toHaveBeenCalled()
expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf')
expect(URL.createObjectURL).toHaveBeenCalledWith(blob)
})
it('falls back to the cache when a live fetch rejects (network error) while online', async () => {
setOnline(true)
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
const blob = new Blob(['x'], { type: 'application/pdf' })
vi.mocked(getCachedBlob).mockResolvedValue(blob)
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await downloadFile('/uploads/files/cached.pdf')
expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf')
expect(URL.createObjectURL).toHaveBeenCalledWith(blob)
})
it('throws when offline and the file was never cached', async () => {
setOnline(false)
vi.mocked(getCachedBlob).mockResolvedValue(null)
await expect(downloadFile('/uploads/files/missing.pdf')).rejects.toThrow(/offline/i)
})
it('does not consult the cache on an HTTP error — a 401 still surfaces', async () => {
setOnline(true)
vi.stubGlobal('fetch', makeFetchMock(401))
await expect(downloadFile('/uploads/files/secret.pdf')).rejects.toThrow('Unauthorized')
expect(getCachedBlob).not.toHaveBeenCalled()
})
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-1
View File
@@ -1 +0,0 @@
.atlas-geo-cache/
Binary file not shown.
Binary file not shown.
-225
View File
@@ -1,225 +0,0 @@
#!/usr/bin/env node
// Build server/assets/atlas/{admin0,admin1}.geojson.gz from geoBoundaries (gbOpen).
//
// Why: Atlas previously fetched country + sub-national boundaries from Natural Earth's
// GitHub `master` at runtime. Natural Earth is stale (e.g. it still shows Norway's
// pre-2020 counties) and depicts some contested territory in ways the project does not
// want (see nvkelso/natural-earth-vector#391). geoBoundaries (CC BY 4.0) is current,
// redistributable, and carries ISO 3166-2 codes on its per-country ADM1 files.
//
// This downloads the *simplified* per-country gbOpen ADM0 (countries) and ADM1
// (regions) layers from a pinned geoBoundaries revision, normalizes each feature to
// the property names the Atlas client/server already read, and writes two gzipped
// FeatureCollections that the server serves at runtime (no network at boot).
//
// geoBoundaries: CC BY 4.0 — https://www.geoboundaries.org/ (attribution required).
import fs from 'node:fs'
import path from 'node:path'
import zlib from 'node:zlib'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const OUT_DIR = path.join(__dirname, '..', 'assets', 'atlas')
// Pinned geoBoundaries revision (override with GB_REF=<sha|branch|tag>). The LFS media
// endpoint resolves a commit SHA, branch, or tag in the <ref> path segment.
const GB_REF = process.env.GB_REF || '5c25134028196d43ce97b5071934fd0cfc92f09f'
const MEDIA = (a3, level) =>
`https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
`/releaseData/gbOpen/${a3}/${level}/geoBoundaries-${a3}-${level}_simplified.geojson`
// Country borders come from CGAZ (the Comprehensive Global Administrative Zones composite)
// rather than per-country gbOpen ADM0: CGAZ is gap-filled, so it includes territories
// that gbOpen omits or folds away — notably Svalbard (inside Norway's geometry) and
// Greenland. The country layer only needs A3/A2/name, so CGAZ's lack of `shapeISO` is
// irrelevant. (gbOpen ADM0 maxes Norway at 71°N and has no Svalbard at all.)
const CGAZ_ADM0 =
`https://media.githubusercontent.com/media/wmgeolab/geoBoundaries/${GB_REF}` +
`/releaseData/CGAZ/geoBoundariesCGAZ_ADM0.geojson`
const CONCURRENCY = 8
const RETRIES = 3
// Complete ISO-3166-1 alpha-3 → alpha-2 map (source: lukes/ISO-3166-Countries-with-
// Regional-Codes). Drives ADM1 enumeration (one gbOpen request per code; missing ones
// 404 and are skipped) and stamps `iso_a2`/`ISO_A2` (geoBoundaries keys by alpha-3
// `shapeGroup`). A complete map — not the client's curated ~180 — is what restores the
// dropped territories (Greenland, Falklands, French Guiana, …).
const A3_TO_A2 = {"ABW":"AW", "AFG":"AF", "AGO":"AO", "AIA":"AI", "ALA":"AX", "ALB":"AL", "AND":"AD", "ARE":"AE", "ARG":"AR", "ARM":"AM", "ASM":"AS", "ATA":"AQ", "ATF":"TF", "ATG":"AG", "AUS":"AU", "AUT":"AT", "AZE":"AZ", "BDI":"BI", "BEL":"BE", "BEN":"BJ", "BES":"BQ", "BFA":"BF", "BGD":"BD", "BGR":"BG", "BHR":"BH", "BHS":"BS", "BIH":"BA", "BLM":"BL", "BLR":"BY", "BLZ":"BZ", "BMU":"BM", "BOL":"BO", "BRA":"BR", "BRB":"BB", "BRN":"BN", "BTN":"BT", "BVT":"BV", "BWA":"BW", "CAF":"CF", "CAN":"CA", "CCK":"CC", "CHE":"CH", "CHL":"CL", "CHN":"CN", "CIV":"CI", "CMR":"CM", "COD":"CD", "COG":"CG", "COK":"CK", "COL":"CO", "COM":"KM", "CPV":"CV", "CRI":"CR", "CUB":"CU", "CUW":"CW", "CXR":"CX", "CYM":"KY", "CYP":"CY", "CZE":"CZ", "DEU":"DE", "DJI":"DJ", "DMA":"DM", "DNK":"DK", "DOM":"DO", "DZA":"DZ", "ECU":"EC", "EGY":"EG", "ERI":"ER", "ESH":"EH", "ESP":"ES", "EST":"EE", "ETH":"ET", "FIN":"FI", "FJI":"FJ", "FLK":"FK", "FRA":"FR", "FRO":"FO", "FSM":"FM", "GAB":"GA", "GBR":"GB", "GEO":"GE", "GGY":"GG", "GHA":"GH", "GIB":"GI", "GIN":"GN", "GLP":"GP", "GMB":"GM", "GNB":"GW", "GNQ":"GQ", "GRC":"GR", "GRD":"GD", "GRL":"GL", "GTM":"GT", "GUF":"GF", "GUM":"GU", "GUY":"GY", "HKG":"HK", "HMD":"HM", "HND":"HN", "HRV":"HR", "HTI":"HT", "HUN":"HU", "IDN":"ID", "IMN":"IM", "IND":"IN", "IOT":"IO", "IRL":"IE", "IRN":"IR", "IRQ":"IQ", "ISL":"IS", "ISR":"IL", "ITA":"IT", "JAM":"JM", "JEY":"JE", "JOR":"JO", "JPN":"JP", "KAZ":"KZ", "KEN":"KE", "KGZ":"KG", "KHM":"KH", "KIR":"KI", "KNA":"KN", "KOR":"KR", "KWT":"KW", "LAO":"LA", "LBN":"LB", "LBR":"LR", "LBY":"LY", "LCA":"LC", "LIE":"LI", "LKA":"LK", "LSO":"LS", "LTU":"LT", "LUX":"LU", "LVA":"LV", "MAC":"MO", "MAF":"MF", "MAR":"MA", "MCO":"MC", "MDA":"MD", "MDG":"MG", "MDV":"MV", "MEX":"MX", "MHL":"MH", "MKD":"MK", "MLI":"ML", "MLT":"MT", "MMR":"MM", "MNE":"ME", "MNG":"MN", "MNP":"MP", "MOZ":"MZ", "MRT":"MR", "MSR":"MS", "MTQ":"MQ", "MUS":"MU", "MWI":"MW", "MYS":"MY", "MYT":"YT", "NAM":"NA", "NCL":"NC", "NER":"NE", "NFK":"NF", "NGA":"NG", "NIC":"NI", "NIU":"NU", "NLD":"NL", "NOR":"NO", "NPL":"NP", "NRU":"NR", "NZL":"NZ", "OMN":"OM", "PAK":"PK", "PAN":"PA", "PCN":"PN", "PER":"PE", "PHL":"PH", "PLW":"PW", "PNG":"PG", "POL":"PL", "PRI":"PR", "PRK":"KP", "PRT":"PT", "PRY":"PY", "PSE":"PS", "PYF":"PF", "QAT":"QA", "REU":"RE", "ROU":"RO", "RUS":"RU", "RWA":"RW", "SAU":"SA", "SDN":"SD", "SEN":"SN", "SGP":"SG", "SGS":"GS", "SHN":"SH", "SJM":"SJ", "SLB":"SB", "SLE":"SL", "SLV":"SV", "SMR":"SM", "SOM":"SO", "SPM":"PM", "SRB":"RS", "SSD":"SS", "STP":"ST", "SUR":"SR", "SVK":"SK", "SVN":"SI", "SWE":"SE", "SWZ":"SZ", "SXM":"SX", "SYC":"SC", "SYR":"SY", "TCA":"TC", "TCD":"TD", "TGO":"TG", "THA":"TH", "TJK":"TJ", "TKL":"TK", "TKM":"TM", "TLS":"TL", "TON":"TO", "TTO":"TT", "TUN":"TN", "TUR":"TR", "TUV":"TV", "TWN":"TW", "TZA":"TZ", "UGA":"UG", "UKR":"UA", "UMI":"UM", "URY":"UY", "USA":"US", "UZB":"UZ", "VAT":"VA", "VCT":"VC", "VEN":"VE", "VGB":"VG", "VIR":"VI", "VNM":"VN", "VUT":"VU", "WLF":"WF", "WSM":"WS", "YEM":"YE", "ZAF":"ZA", "ZMB":"ZM", "ZWE":"ZW"}
const COUNTRIES = Object.keys(A3_TO_A2) // every ISO alpha-3 code (ADM1 fetch list)
// Cache raw downloads so re-runs (e.g. to tune simplification) don't re-fetch ~360 files.
const CACHE_DIR = path.join(__dirname, '..', '.atlas-geo-cache', GB_REF)
async function fetchGeo(url) {
const cacheFile = path.join(CACHE_DIR, url.split('/').slice(-1)[0])
if (fs.existsSync(cacheFile)) {
const cached = fs.readFileSync(cacheFile, 'utf8')
return cached === '' ? null : JSON.parse(cached)
}
for (let attempt = 1; attempt <= RETRIES; attempt++) {
try {
const res = await fetch(url, { headers: { 'User-Agent': 'TREK atlas builder' } })
if (res.status === 404) { fs.writeFileSync(cacheFile, ''); return null } // no file — skip
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const text = await res.text()
if (text.startsWith('version https://git-lfs')) throw new Error('got LFS pointer, not content')
const parsed = JSON.parse(text)
fs.writeFileSync(cacheFile, text)
return parsed
} catch (err) {
if (attempt === RETRIES) {
console.warn(` ! ${url.split('/').slice(-1)[0]}: ${err.message}`)
return null
}
await new Promise(r => setTimeout(r, 500 * attempt))
}
}
return null
}
// Run async tasks with a fixed concurrency cap.
async function pool(items, worker) {
const results = []
let i = 0
const runners = Array.from({ length: CONCURRENCY }, async () => {
while (i < items.length) {
const idx = i++
results[idx] = await worker(items[idx], idx)
}
})
await Promise.all(runners)
return results
}
// Geometry size control. geoBoundaries' "_simplified" files still carry ~12-decimal
// coordinates, which dominate the JSON size. Quantizing to a fixed grid (rounding
// preserves topology — identical input coords map to identical output) and dropping
// the now-redundant consecutive duplicate points shrinks the bundles ~5-8x with no
// visible effect at the atlas' zoom range (3-10). ADM0 fills are viewed zoomed out, so
// they tolerate a coarser grid than ADM1 region borders.
const ADM0_DECIMALS = 2 // ~1.1 km
const ADM1_DECIMALS = 3 // ~110 m
function quantizeRing(ring, decimals) {
const m = 10 ** decimals
const out = []
let prevX, prevY
for (const pt of ring) {
const x = Math.round(pt[0] * m) / m
const y = Math.round(pt[1] * m) / m
if (x === prevX && y === prevY) continue
out.push([x, y])
prevX = x; prevY = y
}
return out
}
// Quantize a (Multi)Polygon, dropping rings that collapse below a valid ring (<4 pts).
function quantizeGeometry(geom, decimals) {
if (!geom) return null
if (geom.type === 'Polygon') {
const rings = geom.coordinates.map(r => quantizeRing(r, decimals)).filter(r => r.length >= 4)
return rings.length ? { type: 'Polygon', coordinates: rings } : null
}
if (geom.type === 'MultiPolygon') {
const polys = geom.coordinates
.map(poly => poly.map(r => quantizeRing(r, decimals)).filter(r => r.length >= 4))
.filter(poly => poly.length)
return polys.length ? { type: 'MultiPolygon', coordinates: polys } : null
}
return geom
}
// Normalize one CGAZ ADM0 feature (keyed by alpha-3 `shapeGroup`) to the property names
// the client country layer reads (ISO_A2/ADM0_A3/NAME/ADMIN). Returns null for the CRS
// pseudo-entry or anything without a group/geometry.
function normalizeAdm0Feature(f) {
const a3 = f.properties?.shapeGroup
if (!a3) return null
const name = f.properties?.shapeName || a3
const geometry = quantizeGeometry(f.geometry, ADM0_DECIMALS)
if (!geometry) return null
return {
type: 'Feature',
properties: { ISO_A2: A3_TO_A2[a3] || null, ADM0_A3: a3, NAME: name, ADMIN: name },
geometry,
}
}
function normalizeAdm1(geo, a3, countryName) {
if (!geo?.features) return []
return geo.features.map(f => {
const name = f.properties?.shapeName || ''
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS)
if (!geometry) return null
const a2 = A3_TO_A2[a3] || null
// shapeISO is a real ISO 3166-2 code for ~90% of features; geoBoundaries leaves the
// rest blank or uses an `XX_YYY` placeholder. Keep real/placeholder codes as-is
// (stable per polygon → manual mark/unmark works, real ones match Nominatim). For
// blank codes, synthesize a stable id mirroring the server's geocode fallback so
// every region is still markable.
let code = f.properties?.shapeISO || ''
if (!code && a2) code = `${a2}-${name.replace(/[^A-Za-z0-9]/g, '').substring(0, 3).toUpperCase()}`
return {
type: 'Feature',
// Property names the Atlas region layer + server getRegionGeo already read.
properties: {
iso_a2: a2,
iso_3166_2: code,
name,
name_en: name,
admin: countryName,
},
geometry,
}
}).filter(Boolean)
}
async function main() {
console.log(`[atlas-geo] geoBoundaries ref ${GB_REF}; ${COUNTRIES.length} countries`)
fs.mkdirSync(OUT_DIR, { recursive: true })
fs.mkdirSync(CACHE_DIR, { recursive: true })
// ADM0 (countries) — one comprehensive CGAZ file (large; cached). Also yields the
// English country name (shapeGroup → shapeName) used for the ADM1 `admin` field.
console.log('[atlas-geo] downloading CGAZ ADM0 (countries)…')
const cgaz = await fetchGeo(CGAZ_ADM0)
const adm0Features = []
const a3ToName = {}
for (const f of cgaz?.features || []) {
const nf = normalizeAdm0Feature(f)
if (nf) { a3ToName[nf.properties.ADM0_A3] = nf.properties.NAME; adm0Features.push(nf) }
}
// ADM1 (sub-national regions) — per-country gbOpen (carries ISO 3166-2 `shapeISO`).
console.log('[atlas-geo] downloading ADM1 (regions)…')
const adm1Raw = await pool(COUNTRIES, a3 => fetchGeo(MEDIA(a3, 'ADM1')))
const adm1Features = []
let withCodes = 0
COUNTRIES.forEach((a3, idx) => {
const feats = normalizeAdm1(adm1Raw[idx], a3, a3ToName[a3] || a3)
for (const f of feats) if (f.properties.iso_3166_2) withCodes++
adm1Features.push(...feats)
})
const write = (name, features) => {
const fc = { type: 'FeatureCollection', features }
const gz = zlib.gzipSync(Buffer.from(JSON.stringify(fc)), { level: 9 })
const file = path.join(OUT_DIR, `${name}.geojson.gz`)
fs.writeFileSync(file, gz)
console.log(`[atlas-geo] wrote ${path.relative(path.join(__dirname, '..'), file)}${features.length} features, ${(gz.length / 1e6).toFixed(1)} MB gz`)
}
write('admin0', adm0Features)
write('admin1', adm1Features)
const missing1 = COUNTRIES.filter((a3, i) => !normalizeAdm1(adm1Raw[i], a3, '').length)
console.log(`[atlas-geo] ADM0 country features: ${adm0Features.length}`)
console.log(`[atlas-geo] ADM1 countries without regions (skipped/404): ${missing1.length}`)
console.log(`[atlas-geo] ADM1 features with ISO 3166-2 code: ${withCodes}/${adm1Features.length}`)
}
main().catch(err => { console.error(err); process.exit(1) })
-91
View File
@@ -1,6 +1,3 @@
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import Database from 'better-sqlite3';
import { encrypt_api_key } from '../services/apiKeyCrypto';
@@ -2372,94 +2369,6 @@ 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) {
-20
View File
@@ -1,6 +1,4 @@
import { broadcast } from '../../websocket';
import { db } from '../../db/database';
import { checkPermission } from '../../services/permissions';
export function safeBroadcast(tripId: number, event: string, payload: Record<string, unknown>): void {
try {
@@ -48,24 +46,6 @@ export function noAccess() {
return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true };
}
export function permissionDenied() {
return { content: [{ type: 'text' as const, text: 'You do not have permission to perform this action on this trip.' }], isError: true };
}
/**
* RBAC gate for MCP tools, mirroring the checkPermission() calls the REST/Nest
* routes run. Call this after canAccessTrip() with the same action key the
* matching REST route uses. Returns true when the user may perform `action`
* on `tripId`.
*/
export function hasTripPermission(action: string, tripId: number | string, userId: number): boolean {
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id?: number } | undefined;
if (!trip) return false;
const userRow = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role?: string } | undefined;
const tripOwnerId = typeof trip.user_id === 'number' ? trip.user_id : null;
return checkPermission(action, userRow?.role ?? 'user', tripOwnerId, userId, tripOwnerId !== userId);
}
export function ok(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
+1 -7
View File
@@ -13,7 +13,7 @@ import { getDay } from '../../services/dayService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
@@ -38,7 +38,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
async ({ tripId, dayId, placeId, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
const assignment = createAssignment(dayId, placeId, notes || null);
@@ -61,7 +60,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
async ({ tripId, dayId, assignmentId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
if (!assignmentExistsInDay(assignmentId, dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
deleteAssignment(assignmentId);
@@ -85,7 +83,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
async ({ tripId, assignmentId, place_time, end_time }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const existing = getAssignmentForTrip(assignmentId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
const assignment = updateTime(
@@ -114,7 +111,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
async ({ tripId, assignmentId, newDayId, oldDayId, orderIndex }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
if (!getDay(newDayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const result = moveAssignment(assignmentId, newDayId, orderIndex ?? 0, oldDayId);
@@ -155,7 +151,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
async ({ tripId, assignmentId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
if (!getAssignmentForTrip(assignmentId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
const participants = setAssignmentParticipants(assignmentId, userIds);
safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
@@ -179,7 +174,6 @@ export function registerAssignmentTools(server: McpServer, userId: number, scope
async ({ tripId, dayId, assignmentIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
reorderAssignments(dayId, assignmentIds);
safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
+2 -8
View File
@@ -10,7 +10,7 @@ import {
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
demoDenied, noAccess, ok,
} from './_shared';
import { canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
@@ -38,7 +38,6 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
async ({ tripId, name, category, total_price, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const item = createBudgetItem(tripId, { category, name, total_price, note });
safeBroadcast(tripId, 'budget:created', { item });
return ok({ item });
@@ -58,7 +57,6 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const deleted = deleteBudgetItem(itemId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
safeBroadcast(tripId, 'budget:deleted', { itemId });
@@ -87,7 +85,6 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
safeBroadcast(tripId, 'budget:updated', { item });
@@ -114,7 +111,6 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
async ({ tripId, name, category, total_price, note, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const hasMembers = userIds && userIds.length > 0;
try {
const run = db.transaction(() => {
@@ -148,7 +144,6 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
async ({ tripId, itemId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const item = updateBudgetMembers(itemId, tripId, userIds);
safeBroadcast(tripId, 'budget:members-updated', { item });
return ok({ item });
@@ -170,8 +165,7 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
async ({ tripId, itemId, memberId, paid }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, userId)) return permissionDenied();
const member = toggleMemberPaid(itemId, tripId, memberId, paid);
const member = toggleMemberPaid(itemId, memberId, paid);
safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, member });
return ok({ member });
}
+1 -11
View File
@@ -12,7 +12,7 @@ import { ADDON_IDS } from '../../addons';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
@@ -43,7 +43,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
async ({ tripId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
const note = createCollabNote(tripId, userId, { title, content, category, color, pinned });
safeBroadcast(tripId, 'collab:note:created', { note });
return ok({ note });
@@ -68,7 +67,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
async ({ tripId, noteId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
safeBroadcast(tripId, 'collab:note:updated', { note });
@@ -89,7 +87,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
async ({ tripId, noteId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
const deleted = deleteCollabNote(tripId, noteId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
safeBroadcast(tripId, 'collab:note:deleted', { noteId });
@@ -131,7 +128,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
async ({ tripId, question, options, multiple, deadline }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
const poll = createPoll(tripId, userId, { question, options, multiple, deadline });
safeBroadcast(tripId, 'collab:poll:created', { poll });
return ok({ poll });
@@ -151,7 +147,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
},
async ({ tripId, pollId, optionIndex }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
const result = votePoll(tripId, pollId, userId, optionIndex);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll });
@@ -172,7 +167,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
async ({ tripId, pollId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
const poll = closePoll(tripId, pollId);
if (!poll) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
safeBroadcast(tripId, 'collab:poll:closed', { poll });
@@ -193,7 +187,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
async ({ tripId, pollId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
const deleted = deletePoll(tripId, pollId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
safeBroadcast(tripId, 'collab:poll:deleted', { pollId });
@@ -232,7 +225,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
async ({ tripId, text, replyTo }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
const result = createMessage(tripId, userId, text, replyTo ?? null);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:message:created', { message: result.message });
@@ -253,7 +245,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
async ({ tripId, messageId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
const result = deleteMessage(tripId, messageId, userId);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username });
@@ -275,7 +266,6 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s
async ({ tripId, messageId, emoji }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('collab_edit', tripId, userId)) return permissionDenied();
const result = addOrRemoveReaction(messageId, tripId, userId, emoji);
if (!result.found) return { content: [{ type: 'text' as const, text: 'Message not found.' }], isError: true };
safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions });
+1 -11
View File
@@ -15,7 +15,7 @@ import {
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
demoDenied, noAccess, ok,
} from './_shared';
import { canWrite } from '../scopes';
@@ -38,7 +38,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, dayId, title }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const current = getDay(dayId, tripId);
if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const updated = updateDay(dayId, current, title !== undefined ? { title } : {});
@@ -61,7 +60,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, date, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const day = createDay(tripId, date, notes);
safeBroadcast(tripId, 'day:created', { day });
return ok({ day });
@@ -81,7 +79,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, dayId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
deleteDay(dayId);
safeBroadcast(tripId, 'day:deleted', { id: dayId });
@@ -108,7 +105,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
@@ -148,7 +144,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
try {
@@ -187,7 +182,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const existing = getAccommodation(accommodationId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
@@ -209,7 +203,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, accommodationId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
if (!getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
const { linkedReservationId } = deleteAccommodation(accommodationId);
safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId });
@@ -235,7 +228,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, dayId, text, time, icon }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const note = createDayNote(dayId, tripId, text, time, icon);
safeBroadcast(tripId, 'dayNote:created', { dayId, note });
@@ -260,7 +252,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, dayId, noteId, text, time, icon }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const existing = getDayNote(noteId, dayId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon });
@@ -283,7 +274,6 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
async ({ tripId, dayId, noteId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
const note = getDayNote(noteId, dayId, tripId);
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
deleteDayNote(noteId);
+1 -14
View File
@@ -14,7 +14,7 @@ import {
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
@@ -42,7 +42,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, name, category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const item = createPackingItem(tripId, { name, category: category || 'General' });
safeBroadcast(tripId, 'packing:created', { item });
return ok({ item });
@@ -63,7 +62,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, itemId, checked }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const item = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']);
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
safeBroadcast(tripId, 'packing:updated', { item });
@@ -84,7 +82,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const deleted = deletePackingItem(tripId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
safeBroadcast(tripId, 'packing:deleted', { itemId });
@@ -109,7 +106,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, itemId, name, category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined);
const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys);
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
@@ -133,7 +129,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
reorderPackingItems(tripId, orderedIds);
safeBroadcast(tripId, 'packing:reordered', { orderedIds });
return ok({ success: true });
@@ -170,7 +165,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const bag = createBag(tripId, { name, color });
safeBroadcast(tripId, 'packing:bag-created', { bag });
return ok({ bag });
@@ -192,7 +186,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, bagId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const fields: Record<string, unknown> = {};
const bodyKeys: string[] = [];
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
@@ -216,7 +209,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, bagId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
deleteBag(tripId, bagId);
safeBroadcast(tripId, 'packing:bag-deleted', { id: bagId });
return ok({ success: true });
@@ -237,7 +229,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, bagId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
setBagMembers(tripId, bagId, userIds);
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
return ok({ success: true });
@@ -274,7 +265,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, categoryName, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
updatePackingCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
return ok({ success: true });
@@ -294,7 +284,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, templateId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const applied = applyTemplate(tripId, templateId);
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
safeBroadcast(tripId, 'packing:template-applied', { templateId });
@@ -315,7 +304,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, templateName }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
saveAsTemplate(tripId, userId, templateName);
return ok({ success: true });
}
@@ -338,7 +326,6 @@ export function registerPackingTools(server: McpServer, userId: number, scopes:
async ({ tripId, items }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
bulkImport(tripId, items);
safeBroadcast(tripId, 'packing:updated', {});
return ok({ success: true, count: items.length });
+1 -7
View File
@@ -10,7 +10,7 @@ import { searchPlaces } from '../../services/mapsService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
@@ -45,7 +45,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
@@ -79,7 +78,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try {
const run = db.transaction(() => {
@@ -127,7 +125,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id });
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:updated', { place });
@@ -148,7 +145,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
async ({ tripId, placeId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
const deleted = deletePlace(String(tripId), String(placeId));
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:deleted', { placeId });
@@ -226,7 +222,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
async ({ tripId, url, source }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
const result = source === 'google-list'
? await importGoogleList(String(tripId), url)
@@ -256,7 +251,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
async ({ tripId, placeIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
const deleted = deletePlacesMany(String(tripId), placeIds);
for (const id of deleted) {
+1 -6
View File
@@ -12,7 +12,7 @@ import { placeExists, getAssignmentForTrip } from '../../services/assignmentServ
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
demoDenied, noAccess, ok,
} from './_shared';
import { canWrite } from '../scopes';
@@ -47,7 +47,6 @@ export function registerReservationTools(server: McpServer, userId: number, scop
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
// Validate that all referenced IDs belong to this trip
if (day_id && !getDay(day_id, tripId))
@@ -114,7 +113,6 @@ export function registerReservationTools(server: McpServer, userId: number, scop
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
const existing = getReservation(reservationId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
@@ -146,7 +144,6 @@ export function registerReservationTools(server: McpServer, userId: number, scop
async ({ tripId, reservationId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
const { deleted, accommodationDeleted } = deleteReservation(reservationId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
if (accommodationDeleted) {
@@ -174,7 +171,6 @@ export function registerReservationTools(server: McpServer, userId: number, scop
async ({ tripId, positions, dayId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
updateReservationPositions(tripId, positions, dayId);
safeBroadcast(tripId, 'reservation:positions', { positions, dayId });
return ok({ success: true });
@@ -199,7 +195,6 @@ export function registerReservationTools(server: McpServer, userId: number, scop
async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
const current = getReservation(reservationId, tripId);
if (!current) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
if (current.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
+1 -7
View File
@@ -10,7 +10,7 @@ import {
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
@@ -58,7 +58,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
async ({ tripId, name, category, due_date, description, assigned_user_id, priority }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const item = createTodoItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
safeBroadcast(tripId, 'todo:created', { item });
return ok({ item });
@@ -84,7 +83,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
async ({ tripId, itemId, name, category, due_date, description, assigned_user_id, priority }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
// Build bodyKeys to signal which nullable fields were explicitly provided
const bodyKeys: string[] = [];
if (due_date !== undefined) bodyKeys.push('due_date');
@@ -112,7 +110,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
async ({ tripId, itemId, checked }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const item = updateTodoItem(tripId, itemId, { checked: checked ? 1 : 0 }, []);
if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
safeBroadcast(tripId, 'todo:updated', { item });
@@ -133,7 +130,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const deleted = deleteTodoItem(tripId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
safeBroadcast(tripId, 'todo:deleted', { itemId });
@@ -154,7 +150,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
async ({ tripId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
reorderTodoItems(tripId, orderedIds);
return ok({ success: true });
}
@@ -190,7 +185,6 @@ export function registerTodoTools(server: McpServer, userId: number, scopes: str
async ({ tripId, categoryName, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const assignees = updateTodoCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees });
return ok({ assignees });
+1 -4
View File
@@ -9,7 +9,7 @@ import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService';
import {
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok,
} from './_shared';
import { canWrite } from '../scopes';
@@ -56,7 +56,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
if (start_day_id && !getDay(start_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
@@ -121,7 +120,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
async ({ tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
const existing = getReservation(reservationId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true };
@@ -167,7 +165,6 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
async ({ tripId, reservationId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied();
const { deleted } = deleteReservation(reservationId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true };
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
+1 -6
View File
@@ -22,7 +22,7 @@ import {
safeBroadcast, MAX_MCP_TRIP_DAYS,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
demoDenied, noAccess, ok,
} from './_shared';
import { canRead, canReadTrips, canWrite, canDeleteTrips, canShareTrips } from '../scopes';
@@ -84,7 +84,6 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
async ({ tripId, title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('trip_edit', tripId, userId)) return permissionDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
@@ -322,8 +321,6 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
// Read parity with the REST route GET /api/trips/:tripId/share-link, which
// only requires trip membership (share_manage gates create/delete, not read).
if (!canAccessTrip(tripId, userId)) return noAccess();
const link = getShareLink(String(tripId));
return ok({ link });
@@ -347,7 +344,6 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
async ({ tripId, share_map, share_bookings, share_packing, share_budget, share_collab }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('share_manage', tripId, userId)) return permissionDenied();
const { token, created } = createOrUpdateShareLink(String(tripId), userId, {
share_map: share_map ?? true,
share_bookings: share_bookings ?? true,
@@ -371,7 +367,6 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('share_manage', tripId, userId)) return permissionDenied();
deleteShareLink(String(tripId));
return ok({ success: true });
}
+1 -5
View File
@@ -27,11 +27,7 @@ export function extractToken(req: Request): string | null {
*/
export function verifyJwtAndLoadUser(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number; purpose?: string };
// Purpose-scoped tokens (e.g. the short-lived mfa_login token) share this
// secret but are not full session tokens — only their dedicated endpoint
// may accept them, so reject any token carrying a purpose claim here.
if (decoded.purpose) return null;
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number };
const row = db.prepare(
'SELECT id, username, email, role, password_version FROM users WHERE id = ?'
).get(decoded.id) as (User & { password_version?: number }) | undefined;
+1 -3
View File
@@ -97,6 +97,7 @@ 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"
],
@@ -106,9 +107,6 @@ export function applyGlobalMiddleware(
objectSrc: ["'none'"],
frameSrc: ["'none'"],
frameAncestors: ["'self'"],
// Restrict <form> submission targets (form-action has no default-src
// fallback, so it must be set explicitly).
formAction: ["'self'"],
upgradeInsecureRequests: shouldForceHttps ? [] : null
}
},
+1 -2
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { db } from '../../db/database';
import type { Addon } from '../../types';
import { getBagTracking, getCollabFeatures } from '../../services/adminService';
import { getCollabFeatures } from '../../services/adminService';
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
/**
@@ -53,7 +53,6 @@ export class AddonsService {
return {
collabFeatures: getCollabFeatures(),
bagTracking: getBagTracking().enabled,
addons: [
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
...providers.map((p) => ({
@@ -62,12 +62,6 @@ 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,7 +8,6 @@ import {
unmarkRegionVisited,
getVisitedRegions,
getRegionGeo,
getCountryGeo,
listBucketList,
createBucketItem,
updateBucketItem,
@@ -38,10 +37,6 @@ export class AtlasService {
return getRegionGeo(countries);
}
countryGeo() {
return getCountryGeo();
}
countryPlaces(userId: number, code: string) {
return getCountryPlaces(userId, code);
}
+2 -6
View File
@@ -9,14 +9,13 @@ import {
Post,
Put,
Req,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import type { Request, Response } from 'express';
import type { Request } from 'express';
import path from 'path';
import fs from 'fs';
import { v4 as uuid } from 'uuid';
@@ -77,15 +76,12 @@ export class AuthController {
}
@Put('me/password')
changePassword(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
changePassword(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
this.limit('login', req, 5);
const result = this.auth.changePassword(user.id, user.email, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
// Refresh this device's cookie with the new password_version so the user
// stays logged in here while all other sessions are invalidated.
if (result.token) this.auth.setAuthCookie(res, result.token, req);
writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
return { success: true };
}
+1 -1
View File
@@ -229,7 +229,7 @@ export class BudgetController {
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const member = this.budget.toggleMemberPaid(id, tripId, userId, paid);
const member = this.budget.toggleMemberPaid(id, userId, paid);
this.budget.broadcast(tripId, 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, socketId);
return { member };
}
+2 -2
View File
@@ -57,8 +57,8 @@ export class BudgetService {
return svc.updateMembers(id, tripId, userIds);
}
toggleMemberPaid(id: string, tripId: string, userId: string, paid: boolean) {
return svc.toggleMemberPaid(id, tripId, userId, paid);
toggleMemberPaid(id: string, userId: string, paid: boolean) {
return svc.toggleMemberPaid(id, userId, paid);
}
setPayers(id: string, tripId: string, payers: { user_id: number; amount: number }[]) {
@@ -52,11 +52,9 @@ export class JourneyPublicController {
const wantThumb = kind === 'thumbnail' ? 'thumbnail' : 'original';
if (provider === 'local') {
// Local journey assets are flat filenames; use basename() and confine the
// resolved path to the journey upload directory.
const journeyDir = path.resolve(__dirname, '../../../uploads/journey');
const resolved = path.resolve(path.join(journeyDir, path.basename(assetId)));
if (!resolved.startsWith(journeyDir + path.sep) || !fs.existsSync(resolved)) {
const resolved = path.resolve(path.join(__dirname, '../../../uploads/journey', assetId));
const uploadsDir = path.resolve(__dirname, '../../../uploads');
if (!resolved.startsWith(uploadsDir) || !fs.existsSync(resolved)) {
throw new HttpException({ error: 'Not found' }, 404);
}
res.set('Cache-Control', 'public, max-age=86400');
-16
View File
@@ -1,9 +1,6 @@
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { OidcService } from './oidc.service';
import { cookieOptions } from '../../services/cookie';
const OIDC_STATE_COOKIE = 'trek_oidc_state';
/**
* /api/auth/oidc OIDC SSO login flow (Authorization Code + PKCE).
@@ -43,11 +40,6 @@ export class OidcController {
const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
const inviteToken = req.query.invite as string | undefined;
const { state, codeChallenge } = this.oidc.createState(redirectUri, inviteToken);
// Bind the state to THIS browser. The callback requires a matching cookie,
// so an attacker-initiated login (whose callback URL carries a valid state
// from the shared server map) cannot be replayed in a victim's browser to
// log them into the attacker's account (OIDC login CSRF / session fixation).
res.cookie(OIDC_STATE_COOKIE, state, { ...cookieOptions(false, req), maxAge: 10 * 60 * 1000 });
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
@@ -69,15 +61,10 @@ export class OidcController {
@Query('code') code: string | undefined,
@Query('state') state: string | undefined,
@Query('error') oidcError: string | undefined,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const f = (p: string) => res.redirect(this.oidc.frontendUrl(p));
// The state cookie is single-use — clear it regardless of the outcome.
const boundState = (req.cookies as Record<string, string> | undefined)?.[OIDC_STATE_COOKIE];
res.clearCookie(OIDC_STATE_COOKIE, cookieOptions(true, req));
if (!this.oidc.oidcLoginEnabled()) return f('/login?oidc_error=sso_disabled');
if (oidcError) {
console.error('[OIDC] Provider error:', oidcError);
@@ -85,9 +72,6 @@ export class OidcController {
}
if (!code || !state) return f('/login?oidc_error=missing_params');
// Require the callback to come from the browser that started the flow.
if (!boundState || boundState !== state) return f('/login?oidc_error=invalid_state');
const pending = this.oidc.consumeState(state);
if (!pending) return f('/login?oidc_error=invalid_state');
@@ -195,12 +195,6 @@ 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(
@@ -244,9 +238,6 @@ 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,10 +71,6 @@ 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,6 +1,5 @@
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';
@@ -73,30 +72,6 @@ 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,5 +26,4 @@ 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); }
}
+21 -34
View File
@@ -1,45 +1,32 @@
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
import { db } from '../db/database';
import { Trip, Place } from '../types';
// ── 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.
// ── Admin-1 GeoJSON cache (sub-national regions) ─────────────────────────
const geoBundleCache = new Map<string, any>();
let admin1GeoCache: any = null;
let admin1GeoLoading: Promise<any> | null = null;
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');
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;
}
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
const geo = loadGeoBundle('admin1');
const geo = await loadAdmin1Geo();
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()));
+7 -30
View File
@@ -490,9 +490,8 @@ export function loginUser(body: {
}
if (user.mfa_enabled === 1 || user.mfa_enabled === true) {
const pv = (user as User & { password_version?: number }).password_version ?? 0;
const mfa_token = jwt.sign(
{ id: Number(user.id), purpose: 'mfa_login', pv },
{ id: Number(user.id), purpose: 'mfa_login' },
JWT_SECRET,
{ expiresIn: '5m', algorithm: 'HS256' }
);
@@ -535,7 +534,7 @@ export function changePassword(
userId: number,
userEmail: string,
body: { current_password?: string; new_password?: string }
): { error?: string; status?: number; success?: boolean; token?: string } {
): { error?: string; status?: number; success?: boolean } {
if (isOidcOnlyMode()) {
return { error: 'Password authentication is disabled.', status: 403 };
}
@@ -550,32 +549,14 @@ export function changePassword(
const pwCheck = validatePassword(new_password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
const user = db.prepare('SELECT password_hash, password_version FROM users WHERE id = ?').get(userId) as { password_hash: string; password_version?: number } | undefined;
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(userId) as { password_hash: string } | undefined;
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
return { error: 'Current password is incorrect', status: 401 };
}
const hash = bcrypt.hashSync(new_password, BCRYPT_COST);
const newPv = (user.password_version ?? 0) + 1;
db.transaction(() => {
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, newPv, userId);
// A password change rotates the user's sessions: bumping password_version
// invalidates existing JWT cookie sessions, and the separate MCP static
// token and OAuth bearer-token stores are pruned to match (same set the
// password-reset path already revokes).
db.prepare('DELETE FROM mcp_tokens WHERE user_id = ?').run(userId);
try {
db.prepare("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL").run(userId);
} catch { /* oauth_tokens table may not exist in very old installs */ }
})();
try { revokeUserSessions?.(userId); } catch { /* best-effort */ }
// Re-issue a session bound to the new password_version so the current device
// stays logged in while other existing sessions are rotated out by the pv gate.
const token = generateToken({ id: userId, password_version: newPv });
return { success: true, token };
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, userId);
return { success: true };
}
export function deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } {
@@ -1194,13 +1175,9 @@ export function requestPasswordReset(rawEmail: string, createdIp: string | null)
if (!user) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
}
// SSO-linked account — refuse a reset. OIDC users are created with a random
// bcrypt hash (so password_hash is never empty), which is why we must key off
// oidc_sub rather than a missing hash. Letting the reset proceed would set a
// local password and revoke session/credential state, which breaks the SSO
// login; admins (or the user, with their current password) can still set one.
// OIDC-only account (no local password) — we can't reset what isn't there.
// The client still gets the generic "if that email exists…" response.
if (user.oidc_sub) {
if (!user.password_hash && user.oidc_sub) {
return { tokenForDelivery: null, userId: user.id, userEmail: user.email, reason: 'oidc_only' };
}
+1 -12
View File
@@ -15,10 +15,7 @@ const dataDir = path.join(__dirname, '../../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../../uploads');
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB compressed
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
// limit only caps the compressed bytes). Generous enough for any real backup.
export const MAX_BACKUP_DECOMPRESSED_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
// ---------------------------------------------------------------------------
// Helpers
@@ -190,14 +187,6 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
let reinitFailed: unknown = null;
try {
// Check the declared uncompressed size from the central directory and bail
// if it exceeds the cap, before extracting anything.
const directory = await unzipper.Open.file(zipPath);
const claimedSize = directory.files.reduce((sum, f) => sum + (f.uncompressedSize || 0), 0);
if (claimedSize > MAX_BACKUP_DECOMPRESSED_SIZE) {
return { success: false, error: 'Backup exceeds the maximum decompressed size.', status: 400 };
}
await fs.createReadStream(zipPath)
.pipe(unzipper.Extract({ path: extractDir }))
.promise();
+1 -5
View File
@@ -280,11 +280,7 @@ export function updateMembers(id: string | number, tripId: string | number, user
return { members, item: updated };
}
export function toggleMemberPaid(id: string | number, tripId: string | number, userId: string | number, paid: boolean) {
// Resolve the item within the caller's trip before updating.
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return null;
export function toggleMemberPaid(id: string | number, userId: string | number, paid: boolean) {
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
.run(paid ? 1 : 0, id, userId);
-10
View File
@@ -568,18 +568,8 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
const fields: string[] = [];
const values: unknown[] = [];
// Allow-list the columns a client may set: keys come from the request body
// and are interpolated as SQL column names, so restrict them to the known
// entry fields. Keep this in sync with the data type above.
const allowed = new Set([
'type', 'title', 'story', 'entry_date', 'entry_time',
'location_name', 'location_lat', 'location_lng',
'mood', 'weather', 'tags', 'pros_cons', 'visibility', 'sort_order',
]);
for (const [key, val] of Object.entries(data)) {
if (val === undefined) continue;
if (!allowed.has(key)) continue;
if (key === 'tags') {
fields.push('tags = ?');
values.push(Array.isArray(val) ? JSON.stringify(val) : val);
+10 -40
View File
@@ -84,8 +84,10 @@ export function validateShareTokenForAsset(token: string, assetId: string): { ow
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE tkp.asset_id = ? AND gp.journey_id = ?
`).get(assetId, row.journey_id) as any;
// Only resolve assets that actually belong to this shared journey.
if (!photo) return null;
if (!photo) {
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
return journey ? { ownerId: journey.user_id } : null;
}
return { ownerId: photo.owner_id };
}
@@ -135,45 +137,13 @@ export function getPublicJourney(token: string) {
photos: photosByEntry[e.id] || [],
}));
// Stats are derived from the full data so the overview pills stay accurate
// even when a section is hidden.
// Stats
const stats = {
entries: entries.length,
photos: gallery.length,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
const shareTimeline = !!row.share_timeline;
const shareGallery = !!row.share_gallery;
const shareMap = !!row.share_map;
// Honour the share flags server-side so the API only returns the sections the
// owner enabled (the client gates these too, but it must not rely on that).
let publicEntries: Record<string, unknown>[] = [];
if (shareTimeline) {
// Include the full entry, but drop GPS unless the map is shared and inline
// photos unless the gallery is shared.
publicEntries = enrichedEntries.map(e => {
const projected: Record<string, unknown> = { ...e };
if (!shareMap) { projected.location_lat = null; projected.location_lng = null; }
if (!shareGallery) projected.photos = [];
return projected;
});
} else if (shareMap) {
// Map-only share: just enough to plot markers, no story/photos/mood.
publicEntries = enrichedEntries.map(e => ({
id: e.id,
journey_id: e.journey_id,
type: e.type,
entry_date: e.entry_date,
title: e.title,
location_name: e.location_name,
location_lat: e.location_lat,
location_lng: e.location_lng,
sort_order: e.sort_order,
}));
}
return {
journey: {
title: journey.title,
@@ -181,13 +151,13 @@ export function getPublicJourney(token: string) {
cover_image: journey.cover_image,
status: journey.status,
},
entries: publicEntries,
gallery: shareGallery ? gallery : [],
entries: enrichedEntries,
gallery,
stats,
permissions: {
share_timeline: shareTimeline,
share_gallery: shareGallery,
share_map: shareMap,
share_timeline: !!row.share_timeline,
share_gallery: !!row.share_gallery,
share_map: !!row.share_map,
},
};
}
+87 -108
View File
@@ -70,24 +70,6 @@ interface GooglePlaceDetails extends GooglePlaceResult {
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
// Nominatim) expect BCP-47, so normalise before sending — otherwise names and
// opening hours come back in the wrong language. Codes not listed here pass
// through unchanged (they are already valid), as do locale forms the client
// sometimes sends (e.g. 'pt-BR').
const API_LANG_OVERRIDES: Record<string, string> = {
br: 'pt-BR',
gr: 'el',
'el-GR': 'el',
};
function toApiLang(lang: string | undefined, fallback = 'en'): string {
const code = (lang || '').trim();
if (!code) return fallback;
return API_LANG_OVERRIDES[code] ?? code;
}
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import * as placePhotoCache from './placePhotoCache';
@@ -133,7 +115,7 @@ export async function searchNominatim(query: string, lang?: string) {
format: 'json',
addressdetails: '1',
limit: '10',
'accept-language': toApiLang(lang),
'accept-language': lang || 'en',
});
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
headers: { 'User-Agent': UA },
@@ -166,7 +148,7 @@ export async function lookupNominatim(osmType: string, osmId: string, lang?: str
const params = new URLSearchParams({
osm_ids: `${typePrefix}${osmId}`,
format: 'json',
'accept-language': toApiLang(lang),
'accept-language': lang || 'en',
});
try {
const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, {
@@ -357,7 +339,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
},
body: JSON.stringify({ textQuery: query, languageCode: toApiLang(lang) }),
body: JSON.stringify({ textQuery: query, languageCode: lang || 'en' }),
});
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
@@ -399,7 +381,7 @@ export async function autocompletePlaces(
const body: Record<string, unknown> = {
input,
languageCode: toApiLang(lang),
languageCode: lang || 'en',
};
if (locationBias) {
body.locationBias = {
@@ -490,7 +472,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
}
// Google details
const langKey = toApiLang(lang, 'de');
const langKey = lang || 'de';
const apiKey = getMapsKey(userId);
if (!apiKey) {
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
@@ -550,7 +532,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
}
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
const langKey = toApiLang(lang, 'de');
const langKey = lang || 'de';
const apiKey = getMapsKey(userId);
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
@@ -646,93 +628,90 @@ export async function getPlacePhoto(
const apiKey = getMapsKey(userId);
const isCoordLookup = placeId.startsWith('coords:');
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
// (right-click) places and as a fallback when a Google place yields no photo,
// so a place added via search still gets a marker image when Google returns
// nothing. Returns null (without marking an error) so the caller decides.
const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
if (isNaN(lat) || isNaN(lng)) return null;
try {
const wiki = await fetchWikimediaPhoto(lat, lng, name);
if (!wiki) return null;
// Follow redirects manually so each hop (the image URL can 3xx to a CDN
// host) is re-validated against the SSRF guard, not just the first URL.
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
if (!imgRes.ok) return null;
const bytes = Buffer.from(await imgRes.arrayBuffer());
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
return { filePath: cached.filePath, attribution: cached.attribution };
} catch {
return null;
// No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached)
if (!apiKey || isCoordLookup) {
if (!isNaN(lat) && !isNaN(lng)) {
try {
const wiki = await fetchWikimediaPhoto(lat, lng, name);
if (wiki) {
// Wikimedia photos: fetch bytes and cache to disk. Follow redirects
// manually so each hop (the image URL can 3xx to a CDN host) is
// re-validated against the SSRF guard, not just the first URL.
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
if (imgRes.ok) {
const bytes = Buffer.from(await imgRes.arrayBuffer());
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
return { filePath: cached.filePath, attribution: cached.attribution };
}
}
} catch { /* fall through */ }
}
};
// Google Places photo for a Google place_id. Returns null (without marking an
// error) on any miss — no key, URL-shaped id, request rejected, no photos, or
// a failed media download — so the caller can fall back to Wikimedia.
const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
// URL-shaped placeIds aren't Google IDs — legacy DBs may store raw photo URLs in image_url
if (!apiKey || /^https?:\/\//i.test(placeId)) return null;
// Fetch details to get the photo name
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
});
const body = await detailsRes.text();
if (!detailsRes.ok) {
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
return null;
}
let details: GooglePlaceDetails & { error?: { message?: string } };
try { details = body ? JSON.parse(body) : { photos: [] }; }
catch { return null; }
if (!details.photos?.length) return null;
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
// Fetch actual image bytes
const mediaRes = await googleFetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
`getPlacePhoto/media(${placeId})`,
{ headers: { 'X-Goog-Api-Key': apiKey } }
);
if (!mediaRes.ok) return null;
const bytes = Buffer.from(await mediaRes.arrayBuffer());
if (!bytes.length) return null;
const cached = await placePhotoCache.put(placeId, bytes, attribution);
// Persist stable proxy URL to database
try {
db.prepare(
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
).run(cached.photoUrl, placeId);
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
return { filePath: cached.filePath, attribution };
};
// Prefer the Google photo (higher quality); if Google yields nothing, fall
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
// places use. Coordinate-only ids skip Google entirely.
if (!isCoordLookup) {
const googlePhoto = await fetchGooglePhoto();
if (googlePhoto) return googlePhoto;
placePhotoCache.markError(placeId);
return null;
}
const fallback = await fetchWikimediaFallback();
if (fallback) return fallback;
// Reject URL-shaped placeIds — legacy DBs may store raw photo URLs in image_url
if (/^https?:\/\//i.test(placeId)) {
placePhotoCache.markError(placeId);
return null;
}
placePhotoCache.markError(placeId);
return null;
// Google Photos — fetch details to get photo name
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
});
const body = await detailsRes.text();
if (!detailsRes.ok) {
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
placePhotoCache.markError(placeId);
return null;
}
let details: GooglePlaceDetails & { error?: { message?: string } };
try { details = body ? JSON.parse(body) : { photos: [] }; }
catch { placePhotoCache.markError(placeId); return null; }
if (!details.photos?.length) {
placePhotoCache.markError(placeId);
return null;
}
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
// Fetch actual image bytes
const mediaRes = await googleFetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
`getPlacePhoto/media(${placeId})`,
{ headers: { 'X-Goog-Api-Key': apiKey } }
);
if (!mediaRes.ok) {
placePhotoCache.markError(placeId);
return null;
}
const bytes = Buffer.from(await mediaRes.arrayBuffer());
if (!bytes.length) {
placePhotoCache.markError(placeId);
return null;
}
const cached = await placePhotoCache.put(placeId, bytes, attribution);
// Persist stable proxy URL to database
try {
db.prepare(
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
).run(cached.photoUrl, placeId);
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
return { filePath: cached.filePath, attribution };
} finally {
releasePhotoFetchSlot();
}
@@ -750,7 +729,7 @@ export async function getPlacePhoto(
export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> {
const params = new URLSearchParams({
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
'accept-language': toApiLang(lang),
'accept-language': lang || 'en',
});
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
headers: { 'User-Agent': UA },
+2 -14
View File
@@ -28,8 +28,6 @@ export interface OidcTokenResponse {
export interface OidcUserInfo {
sub: string;
email?: string;
// Standard OIDC claim. Some IdPs send it as the string "true"/"false".
email_verified?: boolean | string;
name?: string;
preferred_username?: string;
groups?: string[];
@@ -202,11 +200,7 @@ export function frontendUrl(path: string): string {
}
export function generateToken(user: { id: number }): string {
// Embed the current password_version so an OIDC-issued session is invalidated
// by a password change/reset exactly like a password-login session (the auth
// middleware compares this `pv` against users.password_version).
const pv = (db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0;
return jwt.sign({ id: user.id, pv }, JWT_SECRET, { expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' });
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' });
}
// ---------------------------------------------------------------------------
@@ -371,14 +365,8 @@ export function findOrCreateUser(
}
if (user) {
// Reaching here without an oidc_sub means we matched an existing local
// account by email. Only auto-link the OIDC identity when the IdP asserts
// the email is verified; an unverified email must not auto-link.
// Link OIDC identity if not yet linked
if (!user.oidc_sub) {
const emailVerified = userInfo.email_verified === true || userInfo.email_verified === 'true';
if (!emailVerified) {
return { error: 'email_not_verified' };
}
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
}
// Update role based on OIDC claims on every login (if claim mapping is configured)
-16
View File
@@ -191,22 +191,6 @@ 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) {
+3 -44
View File
@@ -1,24 +1,6 @@
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;
@@ -147,7 +129,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: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode,
image_url: a.image_url, 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] || [],
}
@@ -165,11 +147,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) as any[]).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) }));
`).all(tripId);
// 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[];
@@ -228,26 +210,3 @@ 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);
}
+4 -6
View File
@@ -318,12 +318,10 @@ export function deleteTrip(tripId: string | number, userId: number, userRole: st
export function deleteOldCover(coverImage: string | null | undefined) {
if (!coverImage) return;
// cover_image is client-supplied, so treat it as untrusted: covers live in
// uploads/covers as a flat filename — use basename() and confine the unlink
// to that directory.
const coversDir = path.resolve(__dirname, '../../uploads/covers');
const resolvedPath = path.resolve(path.join(coversDir, path.basename(coverImage)));
if (resolvedPath.startsWith(coversDir + path.sep) && fs.existsSync(resolvedPath)) {
const oldPath = path.join(__dirname, '../../', coverImage.replace(/^\//, ''));
const resolvedPath = path.resolve(oldPath);
const uploadsDir = path.resolve(__dirname, '../../uploads');
if (resolvedPath.startsWith(uploadsDir) && fs.existsSync(resolvedPath)) {
fs.unlinkSync(resolvedPath);
}
}
+2 -7
View File
@@ -30,12 +30,11 @@ vi.mock('../../src/db/database', () => ({
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
}));
const { getCollabFeatures, getBagTracking, getPhotoProviderConfig } = vi.hoisted(() => ({
const { getCollabFeatures, getPhotoProviderConfig } = vi.hoisted(() => ({
getCollabFeatures: vi.fn(() => ({ chat: true, notes: true, polls: true, whatsnext: true })),
getBagTracking: vi.fn(() => ({ enabled: true })),
getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })),
}));
vi.mock('../../src/services/adminService', () => ({ getCollabFeatures, getBagTracking }));
vi.mock('../../src/services/adminService', () => ({ getCollabFeatures }));
vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
import { AddonsModule } from '../../src/nest/addons/addons.module';
@@ -73,15 +72,11 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
expect((await request(server).get('/api/addons')).status).toBe(401);
});
// Session 1 is a default-role ('user') account — i.e. a non-admin. Asserting the
// global bagTracking flag here is present is the #1124 regression guard: reading the
// toggle must not require admin.
it('200 returns enabled addons + photo providers (disabled addon excluded)', async () => {
const res = await request(server).get('/api/addons').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
bagTracking: true,
addons: [
{ id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true },
{
-9
View File
@@ -33,7 +33,6 @@ const { mocks } = vi.hoisted(() => ({
unmarkRegionVisited: vi.fn(),
getVisitedRegions: vi.fn(),
getRegionGeo: vi.fn(),
getCountryGeo: vi.fn(),
listBucketList: vi.fn(),
createBucketItem: vi.fn(),
updateBucketItem: vi.fn(),
@@ -76,14 +75,6 @@ describe('Atlas e2e (real auth guard + temp SQLite)', () => {
expect(res.status).toBe(401);
});
it('200 countries/geo returns the admin-0 FeatureCollection', async () => {
mocks.getCountryGeo.mockReturnValue({ type: 'FeatureCollection', features: [{ id: 'NO' }] });
const res = await request(server).get('/api/addons/atlas/countries/geo').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.type).toBe('FeatureCollection');
expect(res.headers['cache-control']).toContain('max-age=86400');
});
it('200 stats for an authenticated user', async () => {
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
+1 -29
View File
@@ -8,9 +8,6 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import type { Server } from 'http';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
@@ -31,7 +28,7 @@ const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
const { shareSvc } = vi.hoisted(() => ({
shareSvc: { createOrUpdateShareLink: vi.fn(), getShareLink: vi.fn(), deleteShareLink: vi.fn(), getSharedTripData: vi.fn(), getSharedPlacePhotoPath: vi.fn() },
shareSvc: { createOrUpdateShareLink: vi.fn(), getShareLink: vi.fn(), deleteShareLink: vi.fn(), getSharedTripData: vi.fn() },
}));
vi.mock('../../src/services/shareService', () => shareSvc);
@@ -109,29 +106,4 @@ describe('Share-link e2e (real auth guard + temp SQLite)', () => {
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Invalid or expired link' });
});
describe('public place-photo proxy (/api/shared/:token/place-photo/:placeId/bytes)', () => {
const photoFile = path.join(os.tmpdir(), 'trek-share-photo.e2e.jpg');
const photoBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); // JPEG-ish header
beforeAll(() => fs.writeFileSync(photoFile, photoBytes));
afterAll(() => { try { fs.unlinkSync(photoFile); } catch { /* ignore */ } });
it('streams cached bytes with no cookie (unguarded) for a valid token + place', async () => {
shareSvc.getSharedPlacePhotoPath.mockReturnValueOnce(photoFile);
const res = await request(server).get('/api/shared/tok/place-photo/ChIJabc/bytes');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/jpeg');
expect(res.headers['cache-control']).toContain('immutable');
expect(Buffer.from(res.body)).toEqual(photoBytes);
expect(shareSvc.getSharedPlacePhotoPath).toHaveBeenCalledWith('tok', 'ChIJabc');
});
it('404 when the token/place does not resolve to a cached photo', async () => {
shareSvc.getSharedPlacePhotoPath.mockReturnValueOnce(null);
const res = await request(server).get('/api/shared/bad/place-photo/ChIJabc/bytes');
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Photo not cached' });
});
});
});
+7 -8
View File
@@ -165,13 +165,12 @@ describe('GET /api/auth/oidc/callback', () => {
sub: 'sub-alice-123',
email: 'alice@example.com',
name: 'Alice',
email_verified: true, // verified IdP — required to auto-link onto the existing account
});
// Create a valid state token
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('/login?oidc_code=');
@@ -189,7 +188,7 @@ describe('GET /api/auth/oidc/callback', () => {
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('/login?oidc_code=');
@@ -226,7 +225,7 @@ describe('GET /api/auth/oidc/callback', () => {
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=token_failed');
@@ -238,7 +237,7 @@ describe('GET /api/auth/oidc/callback', () => {
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=no_id_token');
@@ -251,7 +250,7 @@ describe('GET /api/auth/oidc/callback', () => {
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=id_token_invalid');
@@ -269,7 +268,7 @@ describe('GET /api/auth/oidc/callback', () => {
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=subject_mismatch');
@@ -292,7 +291,7 @@ describe('GET /api/auth/oidc/callback', () => {
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
expect(res.status).toBe(302);
expect(res.headers.location).toContain('oidc_error=registration_disabled');
+6 -39
View File
@@ -448,8 +448,8 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
expect(res.body.error).toBeDefined();
});
it('PACK-017 — POST /save-as-template saves packing list as a template (admin)', async () => {
const { user } = createUser(testDb, { role: 'admin' });
it('PACK-017 — POST /save-as-template saves packing list as a template', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Add an item so the trip has something to save
@@ -465,8 +465,8 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
expect(res.body.template.name).toBe('My Summer Template');
});
it('PACK-017b — POST /save-as-template without name returns 400 (admin)', async () => {
const { user } = createUser(testDb, { role: 'admin' });
it('PACK-017b — POST /save-as-template without name returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
@@ -478,8 +478,8 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
expect(res.body.error).toBeDefined();
});
it('PACK-017c — POST /save-as-template when trip has no items returns 400 (admin)', async () => {
const { user } = createUser(testDb, { role: 'admin' });
it('PACK-017c — POST /save-as-template when trip has no items returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
@@ -490,37 +490,4 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('PACK-017d — POST /save-as-template is forbidden for non-admins (403)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/packing/save-as-template`)
.set('Cookie', authCookie(user.id))
.send({ name: 'My Summer Template' });
expect(res.status).toBe(403);
expect(res.body.error).toBe('Admin access required');
});
it('PACK-017e — GET /packing/templates lists templates for a trip member', async () => {
const { user: admin } = createUser(testDb, { role: 'admin' });
const trip = createTrip(testDb, admin.id);
createPackingItem(testDb, trip.id);
await request(app)
.post(`/api/trips/${trip.id}/packing/save-as-template`)
.set('Cookie', authCookie(admin.id))
.send({ name: 'Shared Template' });
const res = await request(app)
.get(`/api/trips/${trip.id}/packing/templates`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.templates)).toBe(true);
expect(res.body.templates.some((t: { name: string }) => t.name === 'Shared Template')).toBe(true);
expect(res.body.templates[0]).toHaveProperty('item_count');
});
});
-77
View File
@@ -49,8 +49,6 @@ import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import * as placePhotoCache from '../../src/services/placePhotoCache';
import fs from 'node:fs';
let nestApp: INestApplication;
let app: Application;
@@ -353,78 +351,3 @@ describe('Shared trip — ordering parity (issue #981)', () => {
expect(reservation.day_positions[day.id]).toBe(1.5);
});
});
describe('Shared trip — place photos in shared links (issue #1100)', () => {
const PLACE_ID = 'ChIJsharedPhoto1100';
const PROXY_URL = `/api/maps/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`;
const photoBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
let cachedFilePath: string;
afterAll(() => { try { if (cachedFilePath) fs.unlinkSync(cachedFilePath); } catch { /* ignore */ } });
async function setupSharedPlaceWithPhoto() {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Photo Place' });
testDb.prepare('UPDATE places SET image_url = ?, google_place_id = ? WHERE id = ?').run(PROXY_URL, PLACE_ID, place.id);
const { body: { token } } = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({});
return { token, place };
}
it('SHARE-016 — shared payload rewrites place image_url to the public token-scoped proxy', async () => {
const { token } = await setupSharedPlaceWithPhoto();
const res = await request(app).get(`/api/shared/${token}`);
expect(res.status).toBe(200);
const place = res.body.places.find((p: any) => p.image_url);
expect(place.image_url).toBe(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
expect(place.image_url.startsWith('/api/maps/')).toBe(false);
});
it('SHARE-017 — shared payload rewrites assignment place image_url too', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id, { date: '2025-10-01' });
const place = createPlace(testDb, trip.id, { name: 'Assigned Photo Place' });
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run(PROXY_URL, place.id);
createDayAssignment(testDb, day.id, place.id, {});
const { body: { token } } = await request(app)
.post(`/api/trips/${trip.id}/share-link`)
.set('Cookie', authCookie(user.id))
.send({});
const res = await request(app).get(`/api/shared/${token}`);
expect(res.status).toBe(200);
expect(res.body.assignments[day.id][0].place.image_url)
.toBe(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
});
it('SHARE-018 — public proxy streams cached bytes for a valid token + place (no cookie)', async () => {
const { token } = await setupSharedPlaceWithPhoto();
const cached = await placePhotoCache.put(PLACE_ID, photoBytes, null);
cachedFilePath = cached.filePath;
const res = await request(app).get(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/jpeg');
expect(Buffer.from(res.body)).toEqual(photoBytes);
});
it('SHARE-019 — public proxy 404s for a placeId not in the shared trip', async () => {
const { token } = await setupSharedPlaceWithPhoto();
const res = await request(app).get(`/api/shared/${token}/place-photo/ChIJnotInTrip/bytes`);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Photo not cached' });
});
it('SHARE-020 — public proxy 404s for an invalid token', async () => {
await setupSharedPlaceWithPhoto();
const res = await request(app).get(`/api/shared/bad-token/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Photo not cached' });
});
});
@@ -1,119 +0,0 @@
/**
* Unit test for the Atlas region-code reconciliation migration (#1119).
*
* After Atlas swapped Natural Earth for geoBoundaries, manually-marked regions
* (`visited_regions`) held the old Natural Earth ISO-3166-2 codes. The final migration
* reconciles each row against the shipped admin-1 bundle: valid codes are kept, codes
* whose region NAME still matches are re-coded, renamed-merge cases use a curated
* crosswalk, and anything else is left untouched. We exercise the real migration by
* running all migrations, seeding rows, rewinding schema_version by one, and re-running
* so only the last (reconciliation) migration fires.
*/
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { createUser } from '../../helpers/factories';
function freshDb() {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
createTables(db);
runMigrations(db);
return db;
}
function mark(db: Database.Database, userId: number, code: string, name: string, country = 'NO') {
db.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(userId, code, name, country);
}
// Rewind one migration and re-run so only the reconciliation (the last migration) executes.
function rerunLastMigration(db: Database.Database) {
const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version;
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
runMigrations(db);
}
describe('Atlas region-code reconciliation migration', () => {
it('CROSSWALK-001: remaps a renamed-merge county via the curated crosswalk', () => {
const db = freshDb();
const { user } = createUser(db);
mark(db, user.id, 'NO-05', 'Oppland'); // merged into Innlandet, name changed
rerunLastMigration(db);
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
expect(rows).toEqual([{ region_code: 'NO-34', region_name: 'Innlandet' }]);
db.close();
});
it('CROSSWALK-002: merges two old counties that map to the same new region (no UNIQUE clash)', () => {
const db = freshDb();
const { user } = createUser(db);
mark(db, user.id, 'NO-04', 'Hedmark'); // → Innlandet
mark(db, user.id, 'NO-05', 'Oppland'); // → Innlandet
rerunLastMigration(db);
const rows = db.prepare('SELECT region_code FROM visited_regions WHERE user_id = ?').all(user.id);
expect(rows).toEqual([{ region_code: 'NO-34' }]);
db.close();
});
it('CROSSWALK-003: leaves a still-valid code untouched', () => {
const db = freshDb();
const { user } = createUser(db);
mark(db, user.id, 'NO-03', 'Oslo'); // present in the new bundle
rerunLastMigration(db);
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
expect(rows).toEqual([{ region_code: 'NO-03', region_name: 'Oslo' }]);
db.close();
});
it('CROSSWALK-004: re-codes a stale code whose region NAME still matches the bundle', () => {
// Not in any crosswalk: a bogus code but a name ("Oslo") that the bundle still carries
// for NO → reconciled to the bundle's code for that name (NO-03) by the name-match path.
const db = freshDb();
const { user } = createUser(db);
mark(db, user.id, 'NO-99', 'Oslo');
rerunLastMigration(db);
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
expect(rows).toEqual([{ region_code: 'NO-03', region_name: 'Oslo' }]);
db.close();
});
it('CROSSWALK-005: leaves an unresolvable row as-is (no code, no name, no crosswalk match)', () => {
const db = freshDb();
const { user } = createUser(db);
mark(db, user.id, 'ZZ-99', 'Nowhere', 'ZZ');
rerunLastMigration(db);
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
expect(rows).toEqual([{ region_code: 'ZZ-99', region_name: 'Nowhere' }]);
db.close();
});
it('CROSSWALK-006: does not touch bucket_list or visited_countries (no region identifier there)', () => {
const db = freshDb();
const { user } = createUser(db);
db.prepare('INSERT INTO bucket_list (user_id, name, country_code) VALUES (?, ?, ?)').run(user.id, 'Oppland', 'NO');
db.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'NO');
mark(db, user.id, 'NO-05', 'Oppland'); // ensure the migration actually runs its body
rerunLastMigration(db);
const bucket = db.prepare('SELECT name, country_code FROM bucket_list WHERE user_id = ?').all(user.id);
expect(bucket).toEqual([{ name: 'Oppland', country_code: 'NO' }]); // free-text name untouched
const countries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(user.id);
expect(countries).toEqual([{ country_code: 'NO' }]);
db.close();
});
});
@@ -135,8 +135,6 @@ const ALLOWED_DESTRUCTIVE: Record<string, string> = {
"Migration 121: DELETE ... WHERE title IN ('Gallery','[Trip Photos]') — remove synthetic wrapper entries replaced by the gallery model.",
'DELETE FROM place_regions':
'Atlas enclave fix: DELETE ... WHERE place_id IN (places inside specific enclave boxes) — invalidate stale region cache; re-resolved on next request.',
'DELETE FROM visited_regions':
'Atlas geoBoundaries swap (#1119): DELETE ... WHERE id = ? — after UPDATE OR IGNORE re-codes a manually-marked region to its current code, drop only the single leftover row whose UNIQUE(user_id, region_code) collision caused the update to be skipped (a duplicate of a region the user already has).',
};
describe('migration hygiene — destructive operation guard', () => {
-17
View File
@@ -134,23 +134,6 @@ describe('authenticate', () => {
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(401);
});
it('AUTH-MW-007: rejects a purpose-scoped mfa_login token even when the user is valid', () => {
// The token issued after the password check but before TOTP is signed with
// the same secret. It must never authenticate a normal request, otherwise
// password alone grants full access and MFA is bypassed.
const mockUser = { id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 };
vi.mocked(db.prepare).mockReturnValue({ get: vi.fn(() => mockUser), all: vi.fn() } as any);
const mfaToken = jwt.sign({ id: 1, purpose: 'mfa_login', pv: 0 }, 'test-secret', { algorithm: 'HS256' });
const next = vi.fn() as unknown as NextFunction;
const { res, status } = makeRes();
authenticate(makeReq({ headers: { authorization: `Bearer ${mfaToken}` } }), res, next);
expect(next).not.toHaveBeenCalled();
expect(status).toHaveBeenCalledWith(401);
});
});
// ── adminOnly ─────────────────────────────────────────────────────────────────
@@ -53,13 +53,6 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
});
});
it('GET /countries/geo delegates to the service', () => {
const fc = { type: 'FeatureCollection', features: [{ id: 'NO' }] };
const countryGeo = vi.fn().mockReturnValue(fc);
expect(makeController({ countryGeo }).countryGeo()).toBe(fc);
expect(countryGeo).toHaveBeenCalledWith();
});
describe('country', () => {
it('GET /country/:code upper-cases the code', () => {
const countryPlaces = vi.fn().mockReturnValue([]);
@@ -1,8 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Response } from 'express';
import path from 'node:path';
import fs from 'node:fs';
import { JourneyController } from '../../../src/nest/journey/journey.controller';
import { JourneyPublicController } from '../../../src/nest/journey/journey-public.controller';
@@ -166,27 +164,4 @@ describe('JourneyPublicController', () => {
await new JourneyPublicController(s).legacyPhoto('tok', 'immich', 'a1', '2', 'original', {} as Response);
expect(streamImmichAsset).toHaveBeenCalledWith({}, 5, 'a1', 'original', 5);
});
it('legacy photo proxy: local provider cannot escape uploads/journey via a traversal asset id', async () => {
// Pretend any path exists so we can inspect exactly what would be served.
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true);
try {
const sendFile = vi.fn();
const res = { set: vi.fn(), sendFile } as unknown as Response;
const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }) } as Partial<JourneyService>);
// Express decodes %2F in a single path param to '/', so the handler sees this.
await new JourneyPublicController(s).legacyPhoto('tok', 'local', '../../files/secret.pdf', '2', 'original', res);
expect(sendFile).toHaveBeenCalledTimes(1);
const served = sendFile.mock.calls[0][0] as string;
// basename() collapses the traversal: the served file stays inside
// uploads/journey and never reaches the sibling /uploads/files dir.
expect(path.basename(served)).toBe('secret.pdf');
expect(served).toMatch(/[\\/]journey[\\/]secret\.pdf$/);
expect(served).not.toMatch(/[\\/]files[\\/]/);
} finally {
existsSpy.mockRestore();
}
});
});
+7 -20
View File
@@ -34,16 +34,11 @@ function makeRes() {
status: vi.fn((c: number) => { res.statusCode = c; return res; }),
json: vi.fn((b: unknown) => { res.body = b; return res; }),
redirect: vi.fn((u: string) => { res.redirectedTo = u; }),
cookie: vi.fn(),
clearCookie: vi.fn(),
};
return res as unknown as Response & { statusCode: number; redirectedTo: string; body: unknown };
}
const req = { query: {}, headers: {} } as Request;
// Callback request carrying the state-binding cookie a real browser would send
// after going through /login.
const reqCb = (state = 's') => ({ query: {}, headers: {}, cookies: { trek_oidc_state: state } } as unknown as Request);
beforeEach(() => vi.clearAllMocks());
afterEach(() => { delete process.env.NODE_ENV; });
@@ -76,29 +71,29 @@ describe('OidcController /login', () => {
describe('OidcController /callback', () => {
it('redirects with sso_disabled when SSO is off', async () => {
const res = makeRes();
await new OidcController(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).callback('c', 's', undefined, reqCb('s'), res);
await new OidcController(svc({ oidcLoginEnabled: vi.fn().mockReturnValue(false) })).callback('c', 's', undefined, res);
expect(res.redirectedTo).toBe('https://app/login?oidc_error=sso_disabled');
});
it('redirects with the provider error', async () => {
const res = makeRes();
await new OidcController(svc()).callback(undefined, undefined, 'access_denied', reqCb('s'), res);
await new OidcController(svc()).callback(undefined, undefined, 'access_denied', res);
expect(res.redirectedTo).toBe('https://app/login?oidc_error=access_denied');
});
it('redirects missing_params / invalid_state', async () => {
const r1 = makeRes();
await new OidcController(svc()).callback(undefined, 's', undefined, reqCb('s'), r1);
await new OidcController(svc()).callback(undefined, 's', undefined, r1);
expect(r1.redirectedTo).toBe('https://app/login?oidc_error=missing_params');
const r2 = makeRes();
await new OidcController(svc({ consumeState: vi.fn().mockReturnValue(null) })).callback('c', 's', undefined, reqCb('s'), r2);
await new OidcController(svc({ consumeState: vi.fn().mockReturnValue(null) })).callback('c', 's', undefined, r2);
expect(r2.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
});
it('rejects a missing id_token, then completes with an auth code on success', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const noId = makeRes();
await new OidcController(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at' }) })).callback('c', 's', undefined, reqCb('s'), noId);
await new OidcController(svc({ exchangeCodeForToken: vi.fn().mockResolvedValue({ _ok: true, access_token: 'at' }) })).callback('c', 's', undefined, noId);
expect(noId.redirectedTo).toBe('https://app/login?oidc_error=no_id_token');
const ok = makeRes();
@@ -108,18 +103,10 @@ describe('OidcController /callback', () => {
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'u1' }),
findOrCreateUser: vi.fn().mockReturnValue({ user: { id: 1 } }),
}));
await c.callback('c', 's', undefined, reqCb('s'), ok);
await c.callback('c', 's', undefined, ok);
expect(ok.redirectedTo).toBe('https://app/login?oidc_code=ac');
});
it('rejects a callback whose state cookie does not match the query state', async () => {
const res = makeRes();
// Browser presents a different (or no) state cookie than the callback URL —
// an attacker-initiated flow replayed in the victim's browser.
await new OidcController(svc()).callback('c', 's', undefined, reqCb('attacker-state'), res);
expect(res.redirectedTo).toBe('https://app/login?oidc_error=invalid_state');
});
it('rejects a userinfo subject mismatch', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const res = makeRes();
@@ -128,7 +115,7 @@ describe('OidcController /callback', () => {
verifyIdToken: vi.fn().mockResolvedValue({ ok: true, claims: { sub: 'u1' } }),
getUserInfo: vi.fn().mockResolvedValue({ email: 'a@b.c', sub: 'OTHER' }),
}));
await c.callback('c', 's', undefined, reqCb('s'), res);
await c.callback('c', 's', undefined, res);
expect(res.redirectedTo).toBe('https://app/login?oidc_error=subject_mismatch');
});
});
@@ -5,7 +5,6 @@ import type { PackingService } from '../../../src/nest/packing/packing.service';
import type { User } from '../../../src/types';
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
const admin = { id: 1, role: 'admin', email: 'a@example.test' } as User;
const trip = { id: 5, user_id: 1 };
/** Service mock with trip access granted + edit allowed by default. */
@@ -120,14 +119,6 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
describe('templates', () => {
it('GET /templates returns the template list for an accessible trip', () => {
const listTemplates = vi.fn().mockReturnValue([{ id: 1, name: 'Beach', item_count: 4 }]);
const svc = makeService({ listTemplates } as Partial<PackingService>);
expect(new PackingController(svc).listTemplates(user, '5')).toEqual({
templates: [{ id: 1, name: 'Beach', item_count: 4 }],
});
});
it('404 when applying a missing/empty template (POST stays 200 otherwise)', () => {
const svc = makeService({ applyTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).applyTemplate(user, '5', 't1'))).toEqual({
@@ -135,30 +126,12 @@ describe('PackingController (parity with the legacy /api/trips/:tripId/packing r
});
});
it('403 when a non-admin tries to save a template', () => {
const saveAsTemplate = vi.fn();
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).saveAsTemplate(user, '5', 'My template'))).toEqual({
status: 403, body: { error: 'Admin access required' },
});
expect(saveAsTemplate).not.toHaveBeenCalled();
});
it('400 when an admin saves a template with no items', () => {
it('400 saving a template with no items', () => {
const svc = makeService({ saveAsTemplate: vi.fn().mockReturnValue(null) } as Partial<PackingService>);
expect(thrown(() => new PackingController(svc).saveAsTemplate(admin, '5', 'My template'))).toEqual({
expect(thrown(() => new PackingController(svc).saveAsTemplate(user, '5', 'My template'))).toEqual({
status: 400, body: { error: 'No items to save' },
});
});
it('saves a template for an admin', () => {
const saveAsTemplate = vi.fn().mockReturnValue({ id: 7, name: 'My template' });
const svc = makeService({ saveAsTemplate } as Partial<PackingService>);
expect(new PackingController(svc).saveAsTemplate(admin, '5', 'My template')).toEqual({
template: { id: 7, name: 'My template' },
});
expect(saveAsTemplate).toHaveBeenCalledWith('5', admin.id, 'My template');
});
});
describe('category assignees', () => {
+27 -46
View File
@@ -31,7 +31,7 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { getStats, getCached, setCache, getCountryFromCoords, getCountryFromAddress, reverseGeocodeCountry, getRegionGeo, getCountryGeo, getCountryPlaces, getVisitedRegions } from '../../../src/services/atlasService';
import { getStats, getCached, setCache, getCountryFromCoords, getCountryFromAddress, reverseGeocodeCountry, getRegionGeo, getCountryPlaces, getVisitedRegions } from '../../../src/services/atlasService';
function insertPlace(db: any, tripId: number, name: string, address: string | null = null) {
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
@@ -243,57 +243,38 @@ describe('reverseGeocodeCountry', () => {
// ── getRegionGeo ────────────────────────────────────────────────────────────
// These read the committed geoBoundaries bundle (server/assets/atlas/admin1.geojson.gz),
// so they double as a guard that the bundle ships current sub-national data (#1119).
describe('getRegionGeo', () => {
it('ATLAS-SVC-017: returns an empty FeatureCollection for a country with no admin-1 features', async () => {
const result = await getRegionGeo(['ZZ']);
it('ATLAS-SVC-017: returns empty FeatureCollection when fetch throws a network error', async () => {
// Override the default stub to throw so loadAdmin1Geo's .catch handler runs,
// returning null — which causes getRegionGeo to return the empty FeatureCollection.
// (The default ok:false stub does NOT trigger the catch; it still resolves json()
// to {}, which loadAdmin1Geo caches as a non-null truthy value.)
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')));
const result = await getRegionGeo(['DE', 'FR']);
expect(result).toEqual({ type: 'FeatureCollection', features: [] });
});
it('ATLAS-SVC-018: returns the current geoBoundaries regions for a country, case-insensitively', async () => {
// Pass lowercase 'no' — getRegionGeo uppercases internally for matching.
const result = await getRegionGeo(['no']);
it('ATLAS-SVC-018: returns filtered features for matching country codes when fetch returns mock GeoJSON', async () => {
// ATLAS-SVC-017 ran with a throwing fetch, so admin1GeoCache is null and
// admin1GeoLoading is null — this test's fetch override will be called.
const mockGeoJson = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { iso_a2: 'DE' }, geometry: {} },
{ type: 'Feature', properties: { iso_a2: 'FR' }, geometry: {} },
],
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => mockGeoJson,
}));
// Pass lowercase 'de' — getRegionGeo uppercases internally for matching
const result = await getRegionGeo(['de']);
expect(result.type).toBe('FeatureCollection');
expect(result.features.length).toBeGreaterThan(0);
expect(result.features.every((f: any) => f.properties.iso_a2 === 'NO')).toBe(true);
const names = result.features.map((f: any) => f.properties.name);
const codes = result.features.map((f: any) => f.properties.iso_3166_2);
// Post-2020 reform is present…
expect(codes).toContain('NO-34'); // Innlandet
expect(codes).toContain('NO-46'); // Vestland
// …and the merged-away pre-2020 counties are gone (the original #1119 bug).
expect(names).not.toContain('Oppland');
expect(names).not.toContain('Hordaland');
expect(names).not.toContain('Sogn og Fjordane');
});
});
describe('getCountryGeo', () => {
it('ATLAS-SVC-019: returns the admin-0 FeatureCollection with ISO_A2/ADM0_A3 properties', () => {
const geo = getCountryGeo();
expect(geo.type).toBe('FeatureCollection');
expect(geo.features.length).toBeGreaterThan(0);
const no = geo.features.find((f: any) => f.properties.ISO_A2 === 'NO');
expect(no).toBeDefined();
expect(no.properties.ADM0_A3).toBe('NOR');
expect(no.properties.NAME).toBe('Norway');
});
it('ATLAS-SVC-020: includes territories that the curated list dropped (Greenland + Svalbard)', () => {
const geo = getCountryGeo();
// Greenland is its own feature.
expect(geo.features.some((f: any) => f.properties.ISO_A2 === 'GL')).toBe(true);
// Svalbard has no separate ISO entity in geoBoundaries; it sits inside Norway's
// geometry (lat ~74-81°N). Guard that the country polygon reaches those latitudes.
const no = geo.features.find((f: any) => f.properties.ISO_A2 === 'NO');
const maxLat = (function max(coords: any): number {
if (typeof coords[0] === 'number') return coords[1];
return Math.max(...coords.map(max));
})(no.geometry.coordinates);
expect(maxLat).toBeGreaterThan(78);
expect(result.features).toHaveLength(1);
expect(result.features[0].properties.iso_a2).toBe('DE');
});
});
@@ -36,7 +36,6 @@ vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
SESSION_DURATION_SECONDS: 86400,
updateJwtSecret: () => {},
}));
vi.mock('../../../src/services/mfaCrypto', () => ({
@@ -85,14 +84,11 @@ import {
validateInviteToken,
registerUser,
loginUser,
requestPasswordReset,
changePassword,
verifyMfaLogin,
createMcpToken,
deleteMcpToken,
generateToken,
} from '../../../src/services/authService';
import { verifyJwtAndLoadUser } from '../../../src/middleware/auth';
// ---------------------------------------------------------------------------
// Lifecycle
@@ -107,35 +103,6 @@ beforeEach(() => resetTestDb(testDb));
afterAll(() => testDb.close());
// ---------------------------------------------------------------------------
// requestPasswordReset — OIDC/SSO accounts (#1129)
// ---------------------------------------------------------------------------
describe('requestPasswordReset — OIDC/SSO accounts', () => {
it('AUTH-DB-PR1: refuses a reset for an OIDC-linked account that has a (random) password hash', () => {
const { user } = createUser(testDb);
// OIDC users are created with a random bcrypt hash, so password_hash is set —
// the old guard keyed off a missing hash and therefore let the reset through.
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-1129', 'https://idp.example', user.id);
const result = requestPasswordReset(user.email, null);
expect(result.reason).toBe('oidc_only');
expect(result.tokenForDelivery).toBeNull();
const { n } = testDb.prepare('SELECT COUNT(*) AS n FROM password_reset_tokens WHERE user_id = ?')
.get(user.id) as { n: number };
expect(n).toBe(0);
});
it('AUTH-DB-PR2: still issues a reset for a normal local (non-SSO) account', () => {
const { user } = createUser(testDb);
const result = requestPasswordReset(user.email, null);
expect(result.reason).toBe('issued');
expect(result.tokenForDelivery).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// updateSettings
// ---------------------------------------------------------------------------
@@ -606,39 +573,6 @@ describe('changePassword — OIDC-only mode', () => {
});
});
describe('changePassword — session invalidation', () => {
const pvOf = (id: number) =>
(testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(id) as { password_version: number }).password_version;
const mcpCount = (id: number) =>
(testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(id) as { c: number }).c;
it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', () => {
const { user, password } = createUser(testDb);
createMcpToken(user.id, 'cli');
expect(pvOf(user.id)).toBe(0);
expect(mcpCount(user.id)).toBe(1);
const result = changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
expect(result.success).toBe(true);
expect(typeof result.token).toBe('string'); // fresh session for the current device
expect(pvOf(user.id)).toBe(1); // old JWT/cookie sessions now rejected by the pv gate
expect(mcpCount(user.id)).toBe(0); // static MCP tokens revoked
});
it('AUTH-DB-036c: a token minted before the change no longer validates afterwards', () => {
const { user, password } = createUser(testDb);
const stolen = generateToken({ id: user.id }); // pv=0 at mint time
expect(verifyJwtAndLoadUser(stolen)).not.toBeNull();
changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
expect(verifyJwtAndLoadUser(stolen)).toBeNull(); // invalidated by the pv bump
});
});
// ---------------------------------------------------------------------------
// disableMfa — require_mfa policy
// ---------------------------------------------------------------------------
@@ -33,9 +33,6 @@ const archiverMock = vi.hoisted(() => vi.fn());
const unzipperMock = vi.hoisted(() => ({
Extract: vi.fn(),
// Central-directory reader used for the pre-extract zip-bomb size check.
// Default to an empty archive so existing restore tests proceed to Extract.
Open: { file: vi.fn().mockResolvedValue({ files: [] }) },
}));
const dbMock = vi.hoisted(() => ({
@@ -535,19 +532,6 @@ describe('BACKUP-038 restoreFromZip', () => {
expect(result.error).toMatch(/travel\.db not found/i);
expect(result.status).toBe(400);
});
it('BACKUP-038b — rejects a zip bomb whose declared decompressed size exceeds the cap', async () => {
unzipperMock.Open.file.mockResolvedValueOnce({
files: [{ uncompressedSize: 6 * 1024 * 1024 * 1024 }], // 6 GB > 5 GB cap
});
const result = await restoreFromZip('/data/tmp/bomb.zip');
expect(result.success).toBe(false);
expect(result.status).toBe(400);
expect(result.error).toMatch(/decompressed size/i);
expect(unzipperMock.Extract).not.toHaveBeenCalled(); // bailed before extracting
});
});
// ---------------------------------------------------------------------------
@@ -1,83 +0,0 @@
/**
* DB-backed unit tests for budgetService trip-scoping (BUDGET-SVC-DB-001+).
* Uses a real in-memory SQLite DB so the SQL WHERE clauses are exercised.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { createBudgetItem, updateMembers, toggleMemberPaid } from '../../../src/services/budgetService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
function paidFlag(itemId: number, memberId: number): number | undefined {
const row = testDb
.prepare('SELECT paid FROM budget_item_members WHERE budget_item_id = ? AND user_id = ?')
.get(itemId, memberId) as { paid: number } | undefined;
return row?.paid;
}
describe('toggleMemberPaid trip-scoping', () => {
it('BUDGET-SVC-DB-001: toggles paid for an item that belongs to the given trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip A' });
const item = createBudgetItem(trip.id, { name: 'Hotel', total_price: 100 });
updateMembers(item.id, trip.id, [user.id]);
const member = toggleMemberPaid(item.id, trip.id, user.id, true);
expect(member).not.toBeNull();
expect(paidFlag(item.id, user.id)).toBe(1);
});
it('BUDGET-SVC-DB-002: refuses to toggle an item from a different trip (cross-trip IDOR)', () => {
const { user } = createUser(testDb);
const tripA = createTrip(testDb, user.id, { title: 'Trip A' });
const tripB = createTrip(testDb, user.id, { title: 'Trip B' });
const itemB = createBudgetItem(tripB.id, { name: 'Foreign expense', total_price: 50 });
updateMembers(itemB.id, tripB.id, [user.id]);
// Caller passes a trip they can access (A) but the item lives in trip B.
const member = toggleMemberPaid(itemB.id, tripA.id, user.id, true);
expect(member).toBeNull();
expect(paidFlag(itemB.id, user.id)).toBe(0); // unchanged
});
});
@@ -596,32 +596,6 @@ describe('updateEntry', () => {
expect(result).toBeNull();
});
it('JOURNEY-SVC-034b: ignores injection column keys and mass-assignment attempts', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, {
title: 'Safe',
story: 'original',
entry_date: '2026-03-01',
});
// The keys come straight from the request body. A crafted key was previously
// interpolated as a raw SQL column name (`${key} = ?`), enabling subquery
// injection (full DB read) and mass-assignment of protected columns.
const malicious: Record<string, unknown> = {
title: 'Updated',
[`story = (SELECT password_hash FROM users WHERE id = ${user.id}), updated_at`]: 'x',
author_id: 999999,
};
const updated = updateEntry(entry.id, user.id, malicious as Parameters<typeof updateEntry>[2]);
expect(updated).not.toBeNull();
expect(updated!.title).toBe('Updated'); // legit field still applied
expect(updated!.story).toBe('original'); // injection key dropped — no hash leaked into story
expect(updated!.author_id).toBe(user.id); // mass-assignment blocked
});
});
describe('deleteEntry', () => {

Some files were not shown because too many files have changed in this diff Show More