mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1656ddcae2 | |||
| 22ad5d73f2 | |||
| 5367d24f9f | |||
| 17822aa9eb | |||
| 87e8a44764 | |||
| f8c77bff8e | |||
| 0b995cfd55 | |||
| 7a4c9998af | |||
| 26ade89bc8 | |||
| b150b576aa | |||
| a162289829 |
@@ -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/
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
|||||||
Generated
+76
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 151–250 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)) {
|
||||||
|
|||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user