Compare commits

...

11 Commits

Author SHA1 Message Date
Maurice 1656ddcae2 test(days): bump over-long-time assertion to the new 250 limit (#1252)
Follow-up to raising the day-note time cap to 250: the unit test still sent 151 chars expecting a 400, which now passes validation and fell through to an unmocked service call.
2026-06-19 18:09:04 +02:00
Maurice 22ad5d73f2 fix(chart): allow setting storageClassName on PVCs (#1261)
The PVC templates rendered no storageClassName and values exposed no key, so clusters without a default StorageClass (or needing a specific class) couldn't install. Add persistence.{data,uploads}.storageClassName, omitted when empty so the default class is still used.
2026-06-19 17:56:42 +02:00
Maurice 5367d24f9f fix(pdf): show photos for OSM places in the trip PDF (#1130)
The PDF photo pre-fetch only fired for places with a google_place_id, so OSM/Nominatim places (osm_id only) fell back to category icons even though they show photos in-app. Recover osm_id from the full places pool (the assignment projection drops it) and key the photo off google_place_id || osm_id || coords, matching the UI.
2026-06-19 17:56:41 +02:00
Maurice 17822aa9eb fix(days): align note time limit to 250 and keep toasts above modal blur (#1252)
The day-note 'time' field capped at 150 server-side while the dialog and shared schema allow 250, so 151-250 char notes 400'd with a confusing 'time must be 150...' message. Raise the controller and MCP limits to 250. Also lift the toast container above modal overlays so the error toast isn't rendered behind the modal's backdrop blur.
2026-06-19 17:56:41 +02:00
Maurice 87e8a44764 fix(dashboard): count archived trips in travel stats (#1264)
The trips/days widgets filtered out archived trips while places, countries and flight distance did not, so archiving a trip zeroed only those two. Drop the is_archived filter so all stats stay consistent.
2026-06-19 17:56:26 +02:00
Maurice f8c77bff8e fix(security): allow same-origin PDF previews under CSP (#1253)
Firefox/Chrome enforce object-src, so object-src 'none' blocked the inline <object> PDF preview (worked only in Safari). Relax to 'self' for same-origin file previews.
2026-06-19 17:56:26 +02:00
Maurice 0b995cfd55 chore: add ca_profile.xml for Unraid Community Apps submission 2026-06-19 17:22:40 +02:00
Neil Soult 7a4c9998af fix(atlas): gzip-compress responses so large country GeoJSON loads behind reverse proxies (#1262)
The admin-0 country GeoJSON served at /api/addons/atlas/countries/geo is
~30 MB uncompressed. With no compression in the request pipeline the
transfer aborts (~8s, net::ERR_FAILED despite a 200) behind reverse
proxies / Cloudflare Tunnel, so the Atlas map never colours visited
countries. LAN is unaffected.

Add the `compression` middleware to the shared applyGlobalMiddleware
pipeline (gzip brings ~30 MB down to ~4 MB). text/event-stream is
excluded so the /mcp StreamableHTTP (SSE) transport is not buffered.

Adds BOOT-008 asserting content-encoding: gzip on the geo endpoint.

Fixes #1254

Co-authored-by: pai <pai@stabpablo.eu>
2026-06-19 16:19:12 +02:00
jubnl 26ade89bc8 chore: dockerignore spec.ts files 2026-06-19 13:59:16 +02:00
jubnl b150b576aa fix(budget): scale category bars relative to top category 2026-06-19 13:52:15 +02:00
jubnl a162289829 fix(budget): accept comma decimal separator in expense amounts
The expense Total Amount, per-person "Who paid", and settlement amount
inputs used type="number" with a bare parseFloat. On desktop the number
input normalized comma→dot for free, but mobile keyboards drop the comma
before onChange fires, so parseFloat("39,99") silently became 39.

Switch the three inputs to type="text" inputMode="decimal" and normalize
comma→dot in their onChange handlers, matching the pattern already used
by the other budget inputs (BudgetPanelInlineEditCell, BudgetPanelAddItemRow,
DashboardPage). Both comma and dot now work on every device.

Closes #1256
2026-06-19 13:16:29 +02:00
19 changed files with 223 additions and 31 deletions
+1
View File
@@ -32,6 +32,7 @@ server/tests/
server/vitest.config.ts server/vitest.config.ts
server/reset-admin.js server/reset-admin.js
**/*.test.ts **/*.test.ts
**/*.spec.ts
wiki/ wiki/
scripts/ scripts/
charts/ charts/
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0"?>
<CommunityApplications>
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<DonateText>Support TREK development</DonateText>
</CommunityApplications>
+1 -1
View File
@@ -39,7 +39,7 @@ See `values.yaml` for more options.
## Notes ## Notes
- Ingress is off by default. Enable and configure hosts for your domain. - Ingress is off by default. Enable and configure hosts for your domain.
- PVCs require a default StorageClass or specify one as needed. - PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed. - `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC. - `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.1.1 version: 3.1.2
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.1.1" appVersion: "3.1.2"
+6
View File
@@ -8,6 +8,9 @@ metadata:
spec: spec:
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce
{{- with .Values.persistence.data.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources: resources:
requests: requests:
storage: {{ .Values.persistence.data.size }} storage: {{ .Values.persistence.data.size }}
@@ -21,6 +24,9 @@ metadata:
spec: spec:
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce
{{- with .Values.persistence.uploads.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources: resources:
requests: requests:
storage: {{ .Values.persistence.uploads.size }} storage: {{ .Values.persistence.uploads.size }}
+3
View File
@@ -98,8 +98,11 @@ persistence:
enabled: true enabled: true
data: data:
size: 1Gi size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: ""
uploads: uploads:
size: 1Gi size: 1Gi
storageClassName: ""
resources: resources:
requests: requests:
@@ -107,7 +107,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' })) await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner') await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[] const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
await waitFor(() => expect(nums()[1].value).toBe('50')) await waitFor(() => expect(nums()[1].value).toBe('50'))
expect(nums()[2].value).toBe('50') expect(nums()[2].value).toBe('50')
@@ -125,6 +125,30 @@ describe('CostsPanel — settlements in the ledger', () => {
])) ]))
}) })
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(39.99)
})
it('marks an expense with no payer as Unfinished', async () => { it('marks an expense with no payer as Unfinished', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] } const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use( server.use(
+11 -7
View File
@@ -632,14 +632,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
function CategoryBreakdown() { function CategoryBreakdown() {
const tot: Record<string, number> = {} const tot: Record<string, number> = {}
let grand = 0 for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0)) const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div> if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
// Bars are scaled relative to the most expensive category (the top row fills the
// bar), not to the trip grand total — makes the relative ranking readable.
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map(c => { {rows.map(c => {
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0 const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
return ( return (
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}> <div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} /> <span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
@@ -754,8 +756,8 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
</div> </div>
<div> <div>
<label className={labelCls}>{t('costs.amount')}</label> <label className={labelCls}>{t('costs.amount')}</label>
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount} <input type="text" inputMode="decimal" placeholder="0.00" value={amount}
onChange={e => setAmount(e.target.value)} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} /> onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -833,10 +835,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
} }
const onTotalChange = (v: string) => { const onTotalChange = (v: string) => {
v = v.replace(',', '.')
setTotal(v) setTotal(v)
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0)) setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
} }
const onPaidChange = (id: number, v: string) => { const onPaidChange = (id: number, v: string) => {
v = v.replace(',', '.')
const nextDirty = new Set(dirty); nextDirty.add(id) const nextDirty = new Set(dirty); nextDirty.add(id)
setDirty(nextDirty) setDirty(nextDirty)
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum)) setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
@@ -896,7 +900,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<label className={labelCls}>{t('costs.totalAmount')}</label> <label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}> <div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span> <span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={total} <input type="text" inputMode="decimal" placeholder="0.00" value={total}
onChange={e => onTotalChange(e.target.value)} onChange={e => onTotalChange(e.target.value)}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} /> className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
</div> </div>
@@ -956,7 +960,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{on ? ( {on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}> <div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span> <span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={paid[p.id] || ''} <input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
onChange={e => onPaidChange(p.id, e.target.value)} onChange={e => onPaidChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} /> className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div> </div>
+22
View File
@@ -323,6 +323,28 @@ describe('downloadTripPDF', () => {
expect(photoCalled).toBe(true) expect(photoCalled).toBe(true)
}) })
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
let fetchedId: string | null = null
server.use(
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
fetchedId = params.placeId as string
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
}),
)
// The assignment projection drops osm_id; the full place in `places` carries it.
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
const args = {
...richArgs,
places: [osmPlace],
assignments: {
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
} as any,
}
await downloadTripPDF(args)
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
expect(fetchedId).toBe('node/240109189')
})
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => { it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
const args = { const args = {
...minimalArgs, ...minimalArgs,
+18 -10
View File
@@ -97,21 +97,29 @@ function dayCost(assignments, dayId, locale) {
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
} }
// Pre-fetch Google Place photos for all assigned places // Pre-fetch place photos for all assigned places.
async function fetchPlacePhotos(assignments: AssignmentsMap) { // Assignment places are a server-side projection that drops osm_id, so we recover
// the full place from the trip's places pool and key the photo off the same id the
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
// places fell back to category icons in the PDF even though they show photos in-app.
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
const photoMap = {} // placeId → photoUrl const photoMap = {} // placeId → photoUrl
// The assignment projection drops osm_id, so recover it from the full places pool.
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean) const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()] const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
// Assignment places are a server-side projection that omits osm_id, so photo const toFetch = unique
// pre-fetch keys off the google_place_id that the projection does carry. .map(p => ({ p, osm_id: osmById.get(p.id) }))
const toFetch = unique.filter(p => !p.image_url && p.google_place_id) .filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null)))
await Promise.allSettled( await Promise.allSettled(
toFetch.map(async (place) => { toFetch.map(async ({ p, osm_id }) => {
// Same key the app UI uses: google_place_id || osm_id || coords.
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
try { try {
const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name) const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name)
if (data.photoUrl) photoMap[place.id] = data.photoUrl if (data.photoUrl) photoMap[p.id] = data.photoUrl
} catch {} } catch {}
}) })
) )
@@ -141,8 +149,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed //retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
const accommodations = await accommodationsApi.list(trip.id); const accommodations = await accommodationsApi.list(trip.id);
// Pre-fetch place photos from Google // Pre-fetch place photos (Google, OSM and coords-only places)
const photoMap = await fetchPlacePhotos(assignments) const photoMap = await fetchPlacePhotos(assignments, places)
const totalAssigned = new Set( const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean) Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
+3 -1
View File
@@ -102,7 +102,9 @@ export function ToastContainer() {
`}</style> `}</style>
<div style={{ <div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)', position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8, // Above modal overlays (which sit around z-index 10000 with a backdrop-filter
// blur) so error toasts paint on top and stay legible instead of blurred behind.
zIndex: 100000, display: 'flex', flexDirection: 'column-reverse', gap: 8,
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px', pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
}}> }}>
{toasts.map(toast => ( {toasts.map(toast => (
+76
View File
@@ -6603,6 +6603,17 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -8875,6 +8886,60 @@
"node": ">= 12.0.0" "node": ">= 12.0.0"
} }
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -14776,6 +14841,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -20480,6 +20554,7 @@
"archiver": "^6.0.1", "archiver": "^6.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"compression": "^1.8.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
@@ -20512,6 +20587,7 @@
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/compression": "^1.8.0",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^4.17.25", "@types/express": "^4.17.25",
+2
View File
@@ -30,6 +30,7 @@
"archiver": "^6.0.1", "archiver": "^6.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"compression": "^1.8.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
@@ -72,6 +73,7 @@
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/compression": "^1.8.0",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^4.17.25", "@types/express": "^4.17.25",
+2 -2
View File
@@ -230,7 +230,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
dayId: z.number().int().positive(), dayId: z.number().int().positive(),
text: z.string().min(1).max(500), text: z.string().min(1).max(500),
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'), time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
icon: z.string().optional().describe('Emoji icon for the note'), icon: z.string().optional().describe('Emoji icon for the note'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -255,7 +255,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
dayId: z.number().int().positive(), dayId: z.number().int().positive(),
noteId: z.number().int().positive(), noteId: z.number().int().positive(),
text: z.string().min(1).max(500).optional(), text: z.string().min(1).max(500).optional(),
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'), time: z.string().max(250).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
icon: z.string().optional().describe('Emoji icon for the note'), icon: z.string().optional().describe('Emoji icon for the note'),
}, },
annotations: TOOL_ANNOTATIONS_WRITE, annotations: TOOL_ANNOTATIONS_WRITE,
+19 -1
View File
@@ -1,4 +1,5 @@
import express, { Request, Response, NextFunction } from 'express'; import express, { Request, Response, NextFunction } from 'express';
import compression from 'compression';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
@@ -28,6 +29,21 @@ export function applyGlobalMiddleware(
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1); app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
} }
// Compress responses (gzip via Accept-Encoding). The Atlas admin-0 country
// GeoJSON is ~30 MB uncompressed, which stalls/aborts (~8s → net::ERR_FAILED)
// behind reverse proxies and Cloudflare Tunnel (#1254); gzip brings it to ~4 MB.
// SSE responses (the /mcp StreamableHTTP transport) must NOT be buffered, so
// they are excluded explicitly.
app.use(
compression({
filter: (req, res) => {
const type = res.getHeader('Content-Type');
if (typeof type === 'string' && type.includes('text/event-stream')) return false;
return compression.filter(req, res);
},
}),
);
const allowedOrigins = process.env.ALLOWED_ORIGINS const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean) ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
: null; : null;
@@ -103,7 +119,9 @@ export function applyGlobalMiddleware(
workerSrc: ["'self'", "blob:"], workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"], childSrc: ["'self'", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"], fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"], // 'self' so same-origin file previews can embed PDFs via <object>/<embed>
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
objectSrc: ["'self'"],
frameSrc: ["'none'"], frameSrc: ["'none'"],
frameAncestors: ["'self'"], frameAncestors: ["'self'"],
// Restrict <form> submission targets (form-action has no default-src // Restrict <form> submission targets (form-action has no default-src
+4 -3
View File
@@ -17,9 +17,10 @@ import { CurrentUser } from '../auth/current-user.decorator';
type DayNoteBody = { text?: string; time?: string; icon?: string; sort_order?: number }; type DayNoteBody = { text?: string; time?: string; icon?: string; sort_order?: number };
// Mirrors the legacy validateStringLengths({ text: 500, time: 150 }) middleware, // Runs BEFORE the trip-access check, so an over-long field 400s first. The `time`
// which runs BEFORE the trip-access check — so an over-long field 400s first. // cap matches the shared dayNote schema (max 250) and the note dialog's counter;
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 150 }; // it was 150 here, which rejected valid 151250 char notes with a confusing error.
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 250 };
function validateLengths(body: Record<string, unknown>): void { function validateLengths(body: Record<string, unknown>): void {
for (const [field, max] of Object.entries(MAX_LENGTHS)) { for (const [field, max] of Object.entries(MAX_LENGTHS)) {
+4 -1
View File
@@ -935,13 +935,16 @@ export function getTravelStats(userId: number) {
WHERE t.user_id = ? OR tm.user_id = ? WHERE t.user_id = ? OR tm.user_id = ?
`).all(userId, userId) as { address: string | null; lat: number | null; lng: number | null }[]; `).all(userId, userId) as { address: string | null; lat: number | null; lng: number | null }[];
// Archived trips still count here, matching the places, countries and flight
// distance widgets (which never filtered on is_archived) so the dashboard stats
// stay consistent — archiving a trip no longer zeroes out trips/days.
const tripStats = db.prepare(` const tripStats = db.prepare(`
SELECT COUNT(DISTINCT t.id) as trips, SELECT COUNT(DISTINCT t.id) as trips,
COUNT(DISTINCT d.id) as days COUNT(DISTINCT d.id) as days
FROM trips t FROM trips t
LEFT JOIN days d ON d.trip_id = t.id LEFT JOIN days d ON d.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0 WHERE (t.user_id = ? OR tm.user_id = ?)
`).get(userId, userId) as { trips: number; days: number } | undefined; `).get(userId, userId) as { trips: number; days: number } | undefined;
const cities = new Set<string>(); const cities = new Set<string>();
@@ -122,4 +122,17 @@ describe('BOOTSTRAP (F6) — unified NestJS app serves the whole surface', () =>
else process.env.NODE_ENV = prev; else process.env.NODE_ENV = prev;
} }
}); });
it('BOOT-008 — large responses are gzip-compressed (Atlas country GeoJSON, #1254)', async () => {
// The admin-0 country GeoJSON is multi-MB; without compression it stalls
// behind reverse proxies / Cloudflare Tunnel. Proves applyGlobalMiddleware
// gzips it on the wire.
const { user } = createUser(testDb);
const res = await request(instance)
.get('/api/addons/atlas/countries/geo')
.set('Accept-Encoding', 'gzip')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.headers['content-encoding']).toBe('gzip');
});
}); });
@@ -127,8 +127,8 @@ describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes
}); });
it('400 on an over-long time', () => { it('400 on an over-long time', () => {
expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(151) }))).toEqual({ expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(251) }))).toEqual({
status: 400, body: { error: 'time must be 150 characters or less' }, status: 400, body: { error: 'time must be 250 characters or less' },
}); });
}); });