Compare commits

..

7 Commits

Author SHA1 Message Date
sss3978 80fc37222d Merge 73fb48e3bb into 3ada075b1a 2026-04-23 10:19:24 +00:00
sss3978 73fb48e3bb Update index.test.ts 2026-04-23 19:19:20 +09:00
sss3978 d30132197e Update client/src/i18n/translations/ja.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-22 23:00:56 +09:00
sss3978 9bf4220054 Update ja.ts 2026-04-22 21:59:16 +09:00
sss3978 0d0ab5080c Update supportedLanguages.ts 2026-04-22 21:56:52 +09:00
sss3978 1084d40685 Update TranslationContext.tsx 2026-04-22 21:55:47 +09:00
sss3978 75ef928264 Create ja.ts 2026-04-22 21:54:10 +09:00
28 changed files with 2489 additions and 406 deletions
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
strategy: clone
strategy: init
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.0.5
version: 2.9.14
description: Minimal Helm chart for TREK app
appVersion: "3.0.5"
appVersion: "2.9.14"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "3.0.5",
"version": "2.9.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "3.0.5",
"version": "2.9.14",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.5",
"version": "2.9.14",
"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)', paddingBottom: 'var(--bottom-nav-h)' }}
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
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)', paddingBottom: 'var(--bottom-nav-h)' }}
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
onClick={onClose}
>
<div
+3 -2
View File
@@ -15,6 +15,7 @@ import ar from './translations/ar'
import br from './translations/br'
import cs from './translations/cs'
import pl from './translations/pl'
import ja from './translations/ja'
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
export { SUPPORTED_LANGUAGES }
@@ -23,7 +24,7 @@ type TranslationStrings = Record<string, string | { name: string; category: stri
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja,
}
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
@@ -38,7 +39,7 @@ export function getLocaleForLanguage(language: string): string {
export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en'
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja' ].includes(language) ? language : 'en'
}
export function isRtlLanguage(language: string): boolean {
+2 -1
View File
@@ -12,8 +12,9 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
{ value: 'ja', label: '日本語', locale: 'ja-JP' },
] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
File diff suppressed because it is too large Load Diff
+6 -27
View File
@@ -32,13 +32,6 @@ 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
@@ -63,13 +56,7 @@ 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.
*
* 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.
* Falls back to a download trigger if the popup is blocked.
*/
export async function openFile(url: string, filename?: string): Promise<void> {
assertRelativeUrl(url)
@@ -84,19 +71,11 @@ export async function openFile(url: string, filename?: string): Promise<void> {
return
}
// iOS PWA: target="_blank" would open Safari, which can't access the blob
if (isIosStandalone()) {
const win = window.open(blobUrl, '_blank', 'noreferrer')
if (win) {
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
} else {
// Popup blocked — fall back to download
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)
}
+1 -1
View File
@@ -91,7 +91,7 @@ describe('isRtlLanguage', () => {
describe('SUPPORTED_LANGUAGES', () => {
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
expect(SUPPORTED_LANGUAGES).toHaveLength(15)
expect(SUPPORTED_LANGUAGES).toHaveLength(16)
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
})
+16 -53
View File
@@ -74,42 +74,32 @@ describe('downloadFile', () => {
})
describe('openFile', () => {
it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => {
it('fetches with credentials:include and opens blob URL in new tab', async () => {
vi.stubGlobal('fetch', makeFetchMock(200))
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const mockWin = { closed: false }
const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window)
await openFile('/uploads/files/doc.pdf')
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
expect(URL.createObjectURL).toHaveBeenCalled()
// 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)
expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer')
// Revoke happens after 30s timeout
vi.runAllTimers()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
})
it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => {
it('falls back to anchor download when popup is blocked', 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', 'doc.pdf')
await openFile('/uploads/files/doc.pdf')
// Exactly ONE anchor click — opening the new tab. No fallback download.
expect(clickSpy).toHaveBeenCalledTimes(1)
expect(clickSpy).toHaveBeenCalled()
vi.runAllTimers()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
})
it('throws on 401 response', async () => {
@@ -118,55 +108,28 @@ describe('openFile', () => {
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => {
it('forces download for unsafe MIME types (HTML, SVG) 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', 'malicious.html')
await openFile('/uploads/files/malicious.html')
// Must NOT open inline — download anchor clicked instead
expect(openSpy).not.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')
expect(clickSpy).toHaveBeenCalled()
})
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))
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
vi.spyOn(window, 'open').mockReturnValue({} as Window)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await openFile('/uploads/files/malicious.svg')
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
}
expect(window.open).not.toHaveBeenCalled()
expect(clickSpy).toHaveBeenCalled()
})
})
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-server",
"version": "3.0.5",
"version": "2.9.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
"version": "3.0.5",
"version": "2.9.14",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "3.0.5",
"version": "2.9.14",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
-64
View File
@@ -2043,70 +2043,6 @@ 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) {
+1 -1
View File
@@ -112,7 +112,7 @@ router.get('/callback', async (req: Request, res: Response) => {
tokenData.id_token,
doc,
config.clientId,
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
config.issuer,
);
if (idVerify.ok !== true) {
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
+6 -22
View File
@@ -140,21 +140,11 @@ 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.
// 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}"`);
}
// 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}"`);
}
doc._issuer = url;
discoveryCache = doc;
@@ -323,6 +313,7 @@ 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>);
@@ -331,13 +322,6 @@ 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 };
}
+7 -67
View File
@@ -43,24 +43,6 @@ 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(`
@@ -178,26 +160,13 @@ 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,
resolvedDayId,
resolvedEndDayId,
day_id || null,
end_day_id ?? null,
place_id || null,
assignment_id || null,
title,
@@ -207,7 +176,7 @@ export function createReservation(tripId: string | number, data: CreateReservati
confirmation_number || null,
notes || null,
status || 'pending',
resolvedType,
type || 'other',
resolvedAccommodationId,
metadata ? JSON.stringify(metadata) : null,
needs_review ? 1 : 0
@@ -321,35 +290,6 @@ 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),
@@ -370,13 +310,13 @@ export function updateReservation(id: string | number, tripId: string | number,
WHERE id = ?
`).run(
title || null,
nextReservationTime,
nextReservationEndTime,
(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),
location !== undefined ? (location || null) : current.location,
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
notes !== undefined ? (notes || null) : current.notes,
nextDayId,
nextEndDayId,
day_id !== undefined ? (day_id || null) : current.day_id,
end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null,
place_id !== undefined ? (place_id || null) : current.place_id,
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
status || null,
@@ -84,9 +84,8 @@ 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;
// 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);
// 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);
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
@@ -123,7 +122,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, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
testDb.prepare('UPDATE users SET login_count = 5 WHERE id = ?').run(user.id);
const res = await request(app)
.get('/api/system-notices/active')
@@ -4,8 +4,6 @@
* 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 ──────────────────────────────────────────────────────────────────
@@ -52,7 +50,6 @@ import {
frontendUrl,
findOrCreateUser,
discover,
verifyIdToken,
} from '../../../src/services/oidcService';
const MOCK_CONFIG = {
@@ -219,59 +216,6 @@ 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) ─────────────────────────────────
@@ -516,66 +460,3 @@ 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);
});
});
+7 -7
View File
@@ -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](Reverse-Proxy) for the full explanation.
These three variables work together behind a TLS-terminating reverse proxy. See [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](OIDC-SSO).
For setup instructions, see [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](MCP-Overview).
For setup instructions, see [MCP-Overview].
| Variable | Description | Default |
|---|---|---|
@@ -129,7 +129,7 @@ For setup instructions, see [MCP-Overview](MCP-Overview).
## Related Pages
- [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
- [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
+5 -5
View File
@@ -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](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].
## 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](Reverse-Proxy) for complete proxy configuration examples.
See [Reverse-Proxy] for complete proxy configuration examples.
## Next Steps
- [Environment-Variables](Environment-Variables) — full variable reference
- [Reverse-Proxy](Reverse-Proxy) — HTTPS configuration
- [Updating](Updating) — how to pull a new image
- [Environment-Variables] — full variable reference
- [Reverse-Proxy] — HTTPS configuration
- [Updating] — how to pull a new image
+6 -6
View File
@@ -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](Environment-Variables) for the full list.
See [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](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], 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](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
- [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
+2 -2
View File
@@ -191,5 +191,5 @@ See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts
## Next Steps
- [Environment-Variables](Environment-Variables) — full variable reference
- [Reverse-Proxy](Reverse-Proxy) — proxy configuration for non-Kubernetes deployments
- [Environment-Variables] — full variable reference
- [Reverse-Proxy] — proxy configuration for non-Kubernetes deployments
+2 -2
View File
@@ -69,5 +69,5 @@ On first boot, TREK automatically creates an admin account. The credentials are
## Next Steps
- [Environment-Variables](Environment-Variables) — complete variable reference
- [Updating](Updating) — how to pull a new image on Unraid
- [Environment-Variables] — complete variable reference
- [Updating] — how to pull a new image on Unraid
+4 -4
View File
@@ -60,7 +60,7 @@ You will be prompted to change the password on first login.
## Next Steps
- [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
- [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
+3 -3
View File
@@ -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](Environment-Variables) for full documentation of these and all other variables.
See [Environment-Variables] for full documentation of these and all other variables.
## Next Steps
- [Environment-Variables](Environment-Variables) — full variable reference including OIDC
- [Install-Docker-Compose](Install-Docker-Compose) — production compose file with proxy-ready env vars
- [Environment-Variables] — full variable reference including OIDC
- [Install-Docker-Compose] — production compose file with proxy-ready env vars
+5 -5
View File
@@ -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](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] 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](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] 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](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
- [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