mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 311647fd46 | |||
| 28dbd86d03 | |||
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd | |||
| ec1ed60117 | |||
| ed4c21eade | |||
| 9093948ff6 | |||
| 2cea4d73aa | |||
| a2a6f52e6e | |||
| 0978b40b6d | |||
| 6155b6dc86 | |||
| 314486325e |
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Publish to GitHub wiki
|
||||
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||
with:
|
||||
strategy: init
|
||||
strategy: clone
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.0
|
||||
version: 3.0.5
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.0"
|
||||
appVersion: "3.0.5"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.5",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
|
||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
||||
}
|
||||
|
||||
// navigator.standalone is true only on iOS when running as an
|
||||
// add-to-home-screen PWA. In that context, target="_blank" hands off to
|
||||
// Safari, which cannot access blob URLs sandboxed to the WebView.
|
||||
function isIosStandalone(): boolean {
|
||||
return (navigator as any).standalone === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a protected file using cookie auth (credentials: include) and
|
||||
* triggers a browser download. Works inside PWA standalone mode because the
|
||||
@@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
||||
* (including text/html and image/svg+xml which can execute script) are forced
|
||||
* to download so that an uploaded file cannot run code in the TREK origin.
|
||||
*
|
||||
* Falls back to a download trigger if the popup is blocked.
|
||||
* Uses a synthetic <a target="_blank" rel="noopener noreferrer"> click rather
|
||||
* than window.open(). window.open() called with the "noreferrer"/"noopener"
|
||||
* window feature returns null per spec, which previously made the popup-block
|
||||
* fallback trigger a download in the *current* tab on top of the new-tab open
|
||||
* — i.e. the file opened twice. The anchor approach avoids that ambiguity:
|
||||
* the new tab is opened by the browser's normal link-handling path, and no
|
||||
* spurious in-page download is triggered.
|
||||
*/
|
||||
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
assertRelativeUrl(url)
|
||||
@@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const win = window.open(blobUrl, '_blank', 'noreferrer')
|
||||
if (win) {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
} else {
|
||||
// Popup blocked — fall back to download
|
||||
// iOS PWA: target="_blank" would open Safari, which can't access the blob
|
||||
if (isIosStandalone()) {
|
||||
triggerAnchorDownload(blobUrl, filename)
|
||||
return
|
||||
}
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
// Keep the blob URL alive long enough for the new tab to load it, then
|
||||
// clean up the DOM node and revoke the URL.
|
||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000)
|
||||
}
|
||||
|
||||
@@ -74,32 +74,42 @@ describe('downloadFile', () => {
|
||||
})
|
||||
|
||||
describe('openFile', () => {
|
||||
it('fetches with credentials:include and opens blob URL in new tab', async () => {
|
||||
it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => {
|
||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||
const mockWin = { closed: false }
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window)
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/doc.pdf')
|
||||
|
||||
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
|
||||
expect(URL.createObjectURL).toHaveBeenCalled()
|
||||
expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer')
|
||||
// Must NOT call window.open — that path returns null when noreferrer is
|
||||
// set, which previously caused the file to also open in the current tab.
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The anchor used to open the new tab must be target=_blank, must NOT
|
||||
// carry a `download` attribute (otherwise it would download in-page
|
||||
// instead of opening), and must use rel=noopener noreferrer.
|
||||
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.target).toBe('_blank')
|
||||
expect(anchor.rel).toBe('noopener noreferrer')
|
||||
expect(anchor.hasAttribute('download')).toBe(false)
|
||||
|
||||
// Revoke happens after 30s timeout
|
||||
vi.runAllTimers()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('falls back to anchor download when popup is blocked', async () => {
|
||||
it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => {
|
||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||
vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/doc.pdf')
|
||||
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
vi.runAllTimers()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||
// Exactly ONE anchor click — opening the new tab. No fallback download.
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws on 401 response', async () => {
|
||||
@@ -108,28 +118,55 @@ describe('openFile', () => {
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forces download for unsafe MIME types (HTML, SVG) instead of opening inline', async () => {
|
||||
it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => {
|
||||
const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
|
||||
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/malicious.html')
|
||||
await openFile('/uploads/files/malicious.html', 'malicious.html')
|
||||
|
||||
// Must NOT open inline — download anchor clicked instead
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.download).toBe('malicious.html')
|
||||
})
|
||||
|
||||
it('forces download for SVG MIME type', async () => {
|
||||
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
|
||||
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
|
||||
vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/malicious.svg')
|
||||
|
||||
expect(window.open).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to download in iOS PWA standalone mode (blob URL inaccessible to Safari)', async () => {
|
||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
// Simulate iOS PWA (Add-to-Home-Screen) context
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
|
||||
|
||||
try {
|
||||
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
||||
|
||||
// Single anchor click — and it must be a DOWNLOAD anchor (no target=_blank),
|
||||
// because target="_blank" in iOS PWA would hand off to Safari which cannot
|
||||
// read the in-WebView blob URL.
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.target).toBe('')
|
||||
expect(anchor.download).toBe('doc.pdf')
|
||||
} finally {
|
||||
// Clean up the non-standard iOS-only property we forced above.
|
||||
delete (navigator as any).standalone
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-server",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.5",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"archiver": "^6.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.5",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
|
||||
@@ -2043,6 +2043,70 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)');
|
||||
},
|
||||
// Migration 122: Correct stale day_id / end_day_id on non-transport
|
||||
// reservations. Migration 110 only backfilled transport types; tours,
|
||||
// restaurants, events and "other" bookings kept a stale day_id from
|
||||
// older code paths that often defaulted to the first day of the trip.
|
||||
// Starting with v3.0.0 the planner renders reservations by day_id
|
||||
// instead of reservation_time, so those stale rows show up on the
|
||||
// wrong day. This migration nulls out day_id / end_day_id values that
|
||||
// don't match the reservation's time and then backfills them from
|
||||
// reservation_time / reservation_end_time.
|
||||
() => {
|
||||
db.exec(`
|
||||
UPDATE reservations
|
||||
SET day_id = NULL
|
||||
WHERE reservation_time IS NOT NULL
|
||||
AND day_id IS NOT NULL
|
||||
AND type != 'hotel'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM days d
|
||||
WHERE d.id = reservations.day_id
|
||||
AND d.date = substr(reservations.reservation_time, 1, 10)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
UPDATE reservations
|
||||
SET end_day_id = NULL
|
||||
WHERE reservation_end_time IS NOT NULL
|
||||
AND end_day_id IS NOT NULL
|
||||
AND type != 'hotel'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM days d
|
||||
WHERE d.id = reservations.end_day_id
|
||||
AND d.date = substr(reservations.reservation_end_time, 1, 10)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
UPDATE reservations
|
||||
SET day_id = (
|
||||
SELECT d.id FROM days d
|
||||
WHERE d.trip_id = reservations.trip_id
|
||||
AND d.date = substr(reservations.reservation_time, 1, 10)
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE type != 'hotel'
|
||||
AND reservation_time IS NOT NULL
|
||||
AND day_id IS NULL
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
UPDATE reservations
|
||||
SET end_day_id = (
|
||||
SELECT d.id FROM days d
|
||||
WHERE d.trip_id = reservations.trip_id
|
||||
AND d.date = substr(reservations.reservation_end_time, 1, 10)
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE type != 'hotel'
|
||||
AND reservation_end_time IS NOT NULL
|
||||
AND end_day_id IS NULL
|
||||
AND substr(reservations.reservation_end_time, 1, 10)
|
||||
!= substr(reservations.reservation_time, 1, 10)
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -112,7 +112,7 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
tokenData.id_token,
|
||||
doc,
|
||||
config.clientId,
|
||||
config.issuer,
|
||||
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
|
||||
);
|
||||
if (idVerify.ok !== true) {
|
||||
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
|
||||
|
||||
@@ -140,11 +140,21 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
|
||||
const doc = (await res.json()) as OidcDiscoveryDoc;
|
||||
// Validate that the discovery doc's issuer matches the operator-configured
|
||||
// one. A MITM or compromised doc could otherwise supply a crafted issuer
|
||||
// that passes jwt.verify() because we used doc.issuer as the expected value.
|
||||
if (doc.issuer && doc.issuer !== issuer) {
|
||||
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
|
||||
// Validate that the discovery doc's issuer matches the operator-configured one.
|
||||
// When no custom discoveryUrl is set, a mismatch signals a MITM or misconfiguration
|
||||
// and we reject. When the operator explicitly overrides the discovery URL (e.g.
|
||||
// Authentik realm paths), the discovery doc's issuer is the canonical value —
|
||||
// trust it and warn rather than blocking login.
|
||||
const docIssuer = doc.issuer?.replace(/\/+$/, '') ?? '';
|
||||
if (docIssuer && docIssuer !== issuer) {
|
||||
if (discoveryUrl) {
|
||||
console.warn(
|
||||
`[OIDC] Discovery doc issuer "${doc.issuer}" differs from configured OIDC_ISSUER "${issuer}". ` +
|
||||
`Using discovery doc issuer for id_token verification (custom OIDC_DISCOVERY_URL is set).`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
|
||||
}
|
||||
}
|
||||
doc._issuer = url;
|
||||
discoveryCache = doc;
|
||||
@@ -313,7 +323,6 @@ export async function verifyIdToken(
|
||||
try {
|
||||
const verified = jwt.verify(idToken, publicKey, {
|
||||
algorithms: [alg as jwt.Algorithm],
|
||||
issuer: expectedIssuer,
|
||||
audience: clientId,
|
||||
});
|
||||
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
|
||||
@@ -322,6 +331,13 @@ export async function verifyIdToken(
|
||||
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
|
||||
}
|
||||
|
||||
// Normalize trailing slash before issuer comparison — some IdPs (e.g. Authentik)
|
||||
// include a trailing slash in the id_token iss claim.
|
||||
const tokenIssuer = typeof claims['iss'] === 'string' ? claims['iss'].replace(/\/+$/, '') : '';
|
||||
if (tokenIssuer !== expectedIssuer) {
|
||||
return { ok: false, error: `signature_or_claim_mismatch: jwt issuer invalid. expected: ${expectedIssuer}` };
|
||||
}
|
||||
|
||||
return { ok: true, claims };
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,24 @@ function loadEndpoints(reservationId: number): ReservationEndpoint[] {
|
||||
).all(reservationId) as ReservationEndpoint[];
|
||||
}
|
||||
|
||||
// Resolve the day row whose date matches the date portion of an ISO-ish
|
||||
// timestamp. Used to keep `day_id` / `end_day_id` in sync with
|
||||
// `reservation_time` / `reservation_end_time` so non-transport bookings
|
||||
// (tours, restaurants, events, ...) end up on the right day in the UI,
|
||||
// which now filters by day_id instead of reservation_time.
|
||||
function resolveDayIdFromTime(
|
||||
tripId: string | number,
|
||||
time: string | null | undefined,
|
||||
): number | null {
|
||||
if (!time) return null;
|
||||
const datePart = time.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
|
||||
const row = db
|
||||
.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1')
|
||||
.get(tripId, datePart) as { id: number } | undefined;
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
|
||||
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
|
||||
const insert = db.prepare(`
|
||||
@@ -160,13 +178,26 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
||||
}
|
||||
}
|
||||
|
||||
// Derive day_id / end_day_id from reservation_time when the client
|
||||
// didn't explicitly set them (non-hotel bookings only — hotels store
|
||||
// their date range on the linked day_accommodation).
|
||||
const resolvedType = type || 'other';
|
||||
let resolvedDayId: number | null = day_id ?? null;
|
||||
if (resolvedDayId == null && resolvedType !== 'hotel' && reservation_time) {
|
||||
resolvedDayId = resolveDayIdFromTime(tripId, reservation_time);
|
||||
}
|
||||
let resolvedEndDayId: number | null = end_day_id ?? null;
|
||||
if (resolvedEndDayId == null && resolvedType !== 'hotel' && reservation_end_time) {
|
||||
resolvedEndDayId = resolveDayIdFromTime(tripId, reservation_end_time);
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
tripId,
|
||||
day_id || null,
|
||||
end_day_id ?? null,
|
||||
resolvedDayId,
|
||||
resolvedEndDayId,
|
||||
place_id || null,
|
||||
assignment_id || null,
|
||||
title,
|
||||
@@ -176,7 +207,7 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
||||
confirmation_number || null,
|
||||
notes || null,
|
||||
status || 'pending',
|
||||
type || 'other',
|
||||
resolvedType,
|
||||
resolvedAccommodationId,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
needs_review ? 1 : 0
|
||||
@@ -290,6 +321,35 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedType = (type ?? current.type) || 'other';
|
||||
const nextReservationTime = resolvedType === 'hotel'
|
||||
? null
|
||||
: (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time);
|
||||
const nextReservationEndTime = resolvedType === 'hotel'
|
||||
? null
|
||||
: (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time);
|
||||
|
||||
// day_id / end_day_id: honour an explicit value from the client,
|
||||
// otherwise derive from the (possibly updated) reservation_time so the
|
||||
// planner renders the booking on the correct day.
|
||||
let nextDayId: number | null;
|
||||
if (day_id !== undefined) {
|
||||
nextDayId = day_id || null;
|
||||
} else if (reservation_time !== undefined && resolvedType !== 'hotel') {
|
||||
nextDayId = resolveDayIdFromTime(tripId, nextReservationTime);
|
||||
} else {
|
||||
nextDayId = current.day_id ?? null;
|
||||
}
|
||||
|
||||
let nextEndDayId: number | null;
|
||||
if (end_day_id !== undefined) {
|
||||
nextEndDayId = end_day_id ?? null;
|
||||
} else if (reservation_end_time !== undefined && resolvedType !== 'hotel') {
|
||||
nextEndDayId = resolveDayIdFromTime(tripId, nextReservationEndTime);
|
||||
} else {
|
||||
nextEndDayId = (current as any).end_day_id ?? null;
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE reservations SET
|
||||
title = COALESCE(?, title),
|
||||
@@ -310,13 +370,13 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title || null,
|
||||
(type ?? current.type) === 'hotel' ? null : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time),
|
||||
(type ?? current.type) === 'hotel' ? null : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time),
|
||||
nextReservationTime,
|
||||
nextReservationEndTime,
|
||||
location !== undefined ? (location || null) : current.location,
|
||||
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
||||
notes !== undefined ? (notes || null) : current.notes,
|
||||
day_id !== undefined ? (day_id || null) : current.day_id,
|
||||
end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null,
|
||||
nextDayId,
|
||||
nextEndDayId,
|
||||
place_id !== undefined ? (place_id || null) : current.place_id,
|
||||
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
|
||||
status || null,
|
||||
|
||||
@@ -84,8 +84,9 @@ describe('GET /api/system-notices/active', () => {
|
||||
|
||||
it('returns empty array for non-first-login user with no applicable notices', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// login_count > 1 means firstLogin condition does not match for any notice
|
||||
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
|
||||
// login_count > 1 means firstLogin condition does not match for any notice;
|
||||
// first_seen_version >= 3.0.0 means existingUserBeforeVersion('3.0.0') also does not match
|
||||
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
@@ -122,7 +123,7 @@ describe('GET /api/system-notices/active', () => {
|
||||
SYSTEM_NOTICES.push(TEST_NOTICE);
|
||||
try {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
|
||||
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/system-notices/active')
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import { generateKeyPairSync } from 'crypto';
|
||||
import jwtLib from 'jsonwebtoken';
|
||||
|
||||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
frontendUrl,
|
||||
findOrCreateUser,
|
||||
discover,
|
||||
verifyIdToken,
|
||||
} from '../../../src/services/oidcService';
|
||||
|
||||
const MOCK_CONFIG = {
|
||||
@@ -216,6 +219,59 @@ describe('discover', () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
await expect(discover('https://bad-issuer.example.com')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-037: accepts mismatched doc issuer when discoveryUrl is explicit', async () => {
|
||||
const doc = {
|
||||
issuer: 'https://auth.example.com/application/o/myapp/',
|
||||
authorization_endpoint: 'https://auth.example.com/application/o/myapp/authorize/',
|
||||
token_endpoint: 'https://auth.example.com/application/o/token/',
|
||||
userinfo_endpoint: 'https://auth.example.com/application/o/userinfo/',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const result = await discover(
|
||||
'https://auth.example.com',
|
||||
'https://auth.example.com/application/o/myapp/.well-known/openid-configuration',
|
||||
);
|
||||
|
||||
expect(result.issuer).toBe(doc.issuer);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('differs from configured OIDC_ISSUER'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-038: throws on mismatched doc issuer when discoveryUrl is omitted', async () => {
|
||||
const doc = {
|
||||
issuer: 'https://evil.example.com',
|
||||
authorization_endpoint: 'https://unique-2.example.com/auth',
|
||||
token_endpoint: 'https://unique-2.example.com/token',
|
||||
userinfo_endpoint: 'https://unique-2.example.com/userinfo',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||||
|
||||
await expect(discover('https://unique-2.example.com')).rejects.toThrow(
|
||||
'OIDC discovery issuer mismatch',
|
||||
);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-039: trailing-slash-only mismatch with explicit discoveryUrl does not warn', async () => {
|
||||
const doc = {
|
||||
issuer: 'https://auth.example.com/',
|
||||
authorization_endpoint: 'https://auth.example.com/auth',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
userinfo_endpoint: 'https://auth.example.com/userinfo',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await discover(
|
||||
'https://auth.example.com',
|
||||
'https://auth.example.com/.well-known/openid-configuration',
|
||||
);
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
|
||||
@@ -460,3 +516,66 @@ describe('getUserInfo', () => {
|
||||
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
|
||||
});
|
||||
});
|
||||
|
||||
// ── verifyIdToken ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('verifyIdToken', () => {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
|
||||
const ISSUER = 'https://auth.example.com/application/o/trek';
|
||||
const CLIENT_ID = 'trek-client';
|
||||
const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json';
|
||||
|
||||
function mockJwks() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ keys: [jwk] }),
|
||||
}));
|
||||
}
|
||||
|
||||
function makeToken(iss: string, overrides: object = {}) {
|
||||
return jwtLib.sign(
|
||||
{ sub: 'user-sub', email: 'user@example.com', ...overrides },
|
||||
privateKey,
|
||||
{ algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' }
|
||||
);
|
||||
}
|
||||
|
||||
const doc = { jwks_uri: JWKS_URI } as any;
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); });
|
||||
|
||||
it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => {
|
||||
mockJwks();
|
||||
const token = makeToken(ISSUER);
|
||||
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-034: accepts token whose iss has a trailing slash (Authentik)', async () => {
|
||||
mockJwks();
|
||||
const token = makeToken(ISSUER + '/');
|
||||
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-035: rejects token with wrong issuer', async () => {
|
||||
mockJwks();
|
||||
const token = makeToken('https://evil.example.com');
|
||||
const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER);
|
||||
expect(result.ok).toBe(false);
|
||||
expect((result as any).error).toMatch('jwt issuer invalid');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-036: rejects token with wrong audience', async () => {
|
||||
mockJwks();
|
||||
const token = makeToken(ISSUER, {});
|
||||
const wrongAudToken = jwtLib.sign(
|
||||
{ sub: 'user-sub', iss: ISSUER },
|
||||
privateKey,
|
||||
{ algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' }
|
||||
);
|
||||
const result = await verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ Verified in `server/src/config.ts` (line 107):
|
||||
|
||||
## HTTPS / Proxy
|
||||
|
||||
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy] for the full explanation.
|
||||
These three variables work together behind a TLS-terminating reverse proxy. See [Reverse-Proxy](Reverse-Proxy) for the full explanation.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
@@ -62,7 +62,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
|
||||
|
||||
## OIDC / SSO
|
||||
|
||||
For setup instructions, see [OIDC-SSO].
|
||||
For setup instructions, see [OIDC-SSO](OIDC-SSO).
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
@@ -110,7 +110,7 @@ Both variables must be set together. If either is omitted, the account is create
|
||||
|
||||
## MCP
|
||||
|
||||
For setup instructions, see [MCP-Overview].
|
||||
For setup instructions, see [MCP-Overview](MCP-Overview).
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
@@ -129,7 +129,7 @@ For setup instructions, see [MCP-Overview].
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Reverse-Proxy] — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
|
||||
- [OIDC-SSO] — complete OIDC configuration guide
|
||||
- [MCP-Overview] — MCP server setup and rate limiting
|
||||
- [Encryption-Key-Rotation] — rotating the `ENCRYPTION_KEY` without losing data
|
||||
- [Reverse-Proxy](Reverse-Proxy) — HTTPS proxy setup and the `FORCE_HTTPS` / `TRUST_PROXY` / `COOKIE_SECURE` trio
|
||||
- [OIDC-SSO](OIDC-SSO) — complete OIDC configuration guide
|
||||
- [MCP-Overview](MCP-Overview) — MCP server setup and rate limiting
|
||||
- [Encryption-Key-Rotation](Encryption-Key-Rotation) — rotating the `ENCRYPTION_KEY` without losing data
|
||||
|
||||
@@ -93,7 +93,7 @@ ALLOWED_ORIGINS=https://trek.example.com
|
||||
APP_URL=https://trek.example.com
|
||||
```
|
||||
|
||||
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables].
|
||||
Uncomment and fill in the OIDC, initial setup, or MCP variables as needed. For a full description of every variable, see [Environment-Variables](Environment-Variables).
|
||||
|
||||
## Start TREK
|
||||
|
||||
@@ -111,10 +111,10 @@ docker compose logs -f
|
||||
|
||||
This compose file is designed for deployments where a reverse proxy (nginx, Caddy, Traefik) terminates TLS in front of TREK. To enable HTTPS redirects and secure cookies, uncomment `FORCE_HTTPS=true` and `TRUST_PROXY=1`.
|
||||
|
||||
See [Reverse-Proxy] for complete proxy configuration examples.
|
||||
See [Reverse-Proxy](Reverse-Proxy) for complete proxy configuration examples.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Environment-Variables] — full variable reference
|
||||
- [Reverse-Proxy] — HTTPS configuration
|
||||
- [Updating] — how to pull a new image
|
||||
- [Environment-Variables](Environment-Variables) — full variable reference
|
||||
- [Reverse-Proxy](Reverse-Proxy) — HTTPS configuration
|
||||
- [Updating](Updating) — how to pull a new image
|
||||
|
||||
@@ -32,7 +32,7 @@ Pass additional `-e` flags for timezone and CORS/email link support:
|
||||
-e ALLOWED_ORIGINS=https://trek.example.com \
|
||||
```
|
||||
|
||||
See [Environment-Variables] for the full list.
|
||||
See [Environment-Variables](Environment-Variables) for the full list.
|
||||
|
||||
## Volume Reference
|
||||
|
||||
@@ -66,11 +66,11 @@ docker logs trek
|
||||
|
||||
## Limitations of `docker run`
|
||||
|
||||
A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose], which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file.
|
||||
A bare `docker run` command has no built-in secret management and is harder to reproduce after a system reboot. For production, see [Install-Docker-Compose](Install-Docker-Compose), which adds security hardening (`read_only`, `cap_drop`, `cap_add`, `no-new-privileges`, `tmpfs`) and makes it easy to manage environment variables through a `.env` file.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Reverse-Proxy] — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag
|
||||
- [Install-Docker-Compose] — recommended for production
|
||||
- [Environment-Variables] — full list of configurable variables
|
||||
- [Updating] — how to pull a new image without losing data
|
||||
- [Reverse-Proxy](Reverse-Proxy) — HTTPS is required for PWA install and the `trek_session` cookie `secure` flag
|
||||
- [Install-Docker-Compose](Install-Docker-Compose) — recommended for production
|
||||
- [Environment-Variables](Environment-Variables) — full list of configurable variables
|
||||
- [Updating](Updating) — how to pull a new image without losing data
|
||||
|
||||
@@ -191,5 +191,5 @@ See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Environment-Variables] — full variable reference
|
||||
- [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments
|
||||
- [Environment-Variables](Environment-Variables) — full variable reference
|
||||
- [Reverse-Proxy](Reverse-Proxy) — proxy configuration for non-Kubernetes deployments
|
||||
|
||||
@@ -69,5 +69,5 @@ On first boot, TREK automatically creates an admin account. The credentials are
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Environment-Variables] — complete variable reference
|
||||
- [Updating] — how to pull a new image on Unraid
|
||||
- [Environment-Variables](Environment-Variables) — complete variable reference
|
||||
- [Updating](Updating) — how to pull a new image on Unraid
|
||||
|
||||
+4
-4
@@ -60,7 +60,7 @@ You will be prompted to change the password on first login.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Install-Docker-Compose] — production setup with security hardening
|
||||
- [Reverse-Proxy] — put TREK behind HTTPS (required for PWA install and secure cookies)
|
||||
- [Environment-Variables] — full configuration reference
|
||||
- [Admin-Panel-Overview] — explore what the admin panel can do
|
||||
- [Install-Docker-Compose](Install-Docker-Compose) — production setup with security hardening
|
||||
- [Reverse-Proxy](Reverse-Proxy) — put TREK behind HTTPS (required for PWA install and secure cookies)
|
||||
- [Environment-Variables](Environment-Variables) — full configuration reference
|
||||
- [Admin-Panel-Overview](Admin-Panel-Overview) — explore what the admin panel can do
|
||||
|
||||
@@ -98,9 +98,9 @@ Four variables control how TREK behaves behind a proxy. They work as a group:
|
||||
|
||||
If you access TREK directly on `http://<host>:3000` without a proxy, leave `FORCE_HTTPS` unset and do not set `TRUST_PROXY`.
|
||||
|
||||
See [Environment-Variables] for full documentation of these and all other variables.
|
||||
See [Environment-Variables](Environment-Variables) for full documentation of these and all other variables.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Environment-Variables] — full variable reference including OIDC
|
||||
- [Install-Docker-Compose] — production compose file with proxy-ready env vars
|
||||
- [Environment-Variables](Environment-Variables) — full variable reference including OIDC
|
||||
- [Install-Docker-Compose](Install-Docker-Compose) — production compose file with proxy-ready env vars
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@ How to update TREK to a newer version without losing data.
|
||||
|
||||
## Before You Update
|
||||
|
||||
Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups] for details.
|
||||
Back up your data first. Go to Admin Panel → Backups and create a manual backup, or copy your `./data` and `./uploads` directories to a safe location. See [Backups](Backups) for details.
|
||||
|
||||
## Docker Compose (Recommended)
|
||||
|
||||
@@ -42,7 +42,7 @@ TREK runs any pending database migrations automatically at startup. No manual mi
|
||||
|
||||
If you are upgrading from a version that predates the dedicated `ENCRYPTION_KEY` (i.e. you have no `ENCRYPTION_KEY` environment variable set), TREK automatically falls back to `./data/.jwt_secret` on startup and immediately promotes it to `./data/.encryption_key`. No manual steps are required — the transition is handled at first boot after the upgrade.
|
||||
|
||||
If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation] for the full procedure.
|
||||
If you want to rotate to a new key at any point (not required for a normal update), see [Encryption-Key-Rotation](Encryption-Key-Rotation) for the full procedure.
|
||||
|
||||
## Unraid
|
||||
|
||||
@@ -50,6 +50,6 @@ In the Unraid Docker tab, click the TREK container and select **Update**. Unraid
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Backups] — schedule automatic backups so you always have a restore point before updates
|
||||
- [Encryption-Key-Rotation] — if you need to rotate or migrate the encryption key
|
||||
- [Install-Docker-Compose] — switch to Compose for easier future updates
|
||||
- [Backups](Backups) — schedule automatic backups so you always have a restore point before updates
|
||||
- [Encryption-Key-Rotation](Encryption-Key-Rotation) — if you need to rotate or migrate the encryption key
|
||||
- [Install-Docker-Compose](Install-Docker-Compose) — switch to Compose for easier future updates
|
||||
|
||||
Reference in New Issue
Block a user