mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
Compare commits
19 Commits
v3.0.0-pre.59
...
v3.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd | |||
| ec1ed60117 | |||
| ed4c21eade | |||
| 9093948ff6 | |||
| 2cea4d73aa | |||
| a2a6f52e6e | |||
| 0978b40b6d | |||
| 6155b6dc86 | |||
| 314486325e | |||
| 523bca3a20 | |||
| d5be528d4b | |||
| 3ada075b1a | |||
| afce302b59 | |||
| 8e8433fa9d | |||
| ff42fa0b8c | |||
| ccea7f7a65 |
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Publish to GitHub wiki
|
||||
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||
with:
|
||||
strategy: init
|
||||
strategy: clone
|
||||
|
||||
@@ -127,19 +127,23 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
|
||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||
- **Documents** — file attachments on trips, places, and reservations
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
|
||||
- **Dashboard widgets** — currency converter and timezone clocks
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🤖 AI / MCP
|
||||
|
||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
|
||||
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
|
||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
|
||||
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
|
||||
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||
@@ -152,7 +156,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
@@ -172,7 +176,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`. The first user to register becomes admin.
|
||||
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -338,7 +342,8 @@ server {
|
||||
ssl_certificate /etc/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/privkey.pem;
|
||||
|
||||
client_max_body_size 50m;
|
||||
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
|
||||
client_max_body_size 500m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
@@ -355,6 +360,7 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 2.9.14
|
||||
version: 3.0.4
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "2.9.14"
|
||||
appVersion: "3.0.4"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.9.14",
|
||||
"version": "3.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "2.9.14",
|
||||
"version": "3.0.4",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.9.14",
|
||||
"version": "3.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Plane, Train, Car, Ship } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -7,6 +7,8 @@ import AirportSelect, { type Airport } from './AirportSelect'
|
||||
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -75,6 +77,8 @@ const defaultForm = {
|
||||
arrival_time: '',
|
||||
confirmation_number: '',
|
||||
notes: '',
|
||||
price: '',
|
||||
budget_category: '',
|
||||
meta_airline: '',
|
||||
meta_flight_number: '',
|
||||
meta_train_number: '',
|
||||
@@ -94,6 +98,13 @@ interface TransportModalProps {
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const budgetCategories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||
return Array.from(cats).sort()
|
||||
}, [budgetItems])
|
||||
const [form, setForm] = useState({ ...defaultForm })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
@@ -126,6 +137,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
price: meta.price || '',
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
})
|
||||
if (type === 'flight') {
|
||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||
@@ -139,7 +152,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
}
|
||||
}, [isOpen, reservation, selectedDayId])
|
||||
}, [isOpen, reservation, selectedDayId, budgetItems])
|
||||
|
||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
@@ -173,6 +186,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
if (form.price) metadata.price = form.price
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
}
|
||||
|
||||
const startDate = startDay?.date ?? null
|
||||
const endDate = (endDay ?? startDay)?.date ?? null
|
||||
@@ -200,6 +217,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
await onSave(payload)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
@@ -422,6 +444,40 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||
placeholder="0.00"
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
|
||||
<CustomSelect
|
||||
value={form.budget_category}
|
||||
onChange={v => set('budget_category', v)}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||
]}
|
||||
placeholder={t('reservations.budgetCategoryAuto')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.price && parseFloat(form.price) > 0 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
|
||||
{t('reservations.budgetHint')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2941,6 +2941,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
||||
{[
|
||||
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
|
||||
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid },
|
||||
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
||||
@@ -448,7 +448,7 @@ export default function JourneyPublicPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
{/* Hero */}
|
||||
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
|
||||
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}>
|
||||
{journey.cover_image && (
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
||||
)}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "2.9.14",
|
||||
"version": "3.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-server",
|
||||
"version": "2.9.14",
|
||||
"version": "3.0.4",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"archiver": "^6.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "2.9.14",
|
||||
"version": "3.0.4",
|
||||
"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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ If a toggle fails (e.g., network error), it rolls back to its previous state.
|
||||
|
||||
Some addons require credentials or environment variables before they are functional:
|
||||
|
||||
- **Journey** — requires photo provider credentials (Immich or Synology Photos) configured per-user in their personal Settings. See [Photo-Providers](Photo-Providers).
|
||||
- **Journey** — works without any external integration. To embed photos from Immich or Synology Photos, enable the corresponding photo-provider toggle listed under Journey, then configure credentials per-user in **Settings → Integrations**. See [Photo-Providers](Photo-Providers).
|
||||
- **MCP** — requires `APP_URL` to be set so OAuth redirect URIs resolve correctly.
|
||||
|
||||
## Related pages
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-7
@@ -30,17 +30,23 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
|
||||
- **Public Share Links** — share a read-only view of any trip
|
||||
|
||||
### Addons _(admin-toggleable)_
|
||||
- **Lists** — packing lists and to-dos with templates, member assignments, optional bag tracking
|
||||
- **Budget Planner** — expense tracker with category breakdown, splits, multi-currency
|
||||
- **Documents** — file manager for trips, places, and reservations
|
||||
- **Collab** — group chat, shared notes, polls, day-by-day attendance
|
||||
- **Vacay** — personal vacation day planner with calendar view, public holidays, and carry-over tracking
|
||||
- **Atlas** — interactive world map, bucket list, travel stats, continent breakdown
|
||||
- **Journey** — travel journal linking entries to trips, with contributor roles
|
||||
- **Memories** — photo-focused trip memories
|
||||
- **Collab** — group chat, shared notes, polls, and activity sign-ups
|
||||
- **Dashboard Widgets** — currency converter and timezone clock, toggled per user
|
||||
- **Journey** — magazine-style travel journal with entries, photos (via Immich/Synology Photos), maps, and moods
|
||||
- **Naver List Import** — import places from shared Naver Maps lists
|
||||
- **MCP** — expose TREK to AI assistants via the Model Context Protocol (OAuth 2.1)
|
||||
|
||||
> Dashboard widgets (currency converter and timezone clock) are per-user preferences, not an admin-toggleable addon — see [Dashboard-Widgets](Dashboard-Widgets).
|
||||
|
||||
### AI / MCP Integration
|
||||
- **MCP Server** — built-in Model Context Protocol server with OAuth 2.1 authentication
|
||||
- **80+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
|
||||
- **24 OAuth Scopes** — granular permissions across 13 permission groups
|
||||
- **150+ Tools** — create trips, plan itineraries, manage budgets, send messages, and more
|
||||
- **30 Resources** — read-only `trek://` URIs for trips, days, places, budget, packing, journeys, and more
|
||||
- **27 OAuth Scopes** — granular permissions across 13 permission groups
|
||||
- **Pre-built Prompts** — `trip-summary`, `packing-list`, and `budget-overview` context loaders
|
||||
|
||||
### Admin
|
||||
@@ -48,7 +54,7 @@ TREK is a self-hosted, real-time collaborative travel planner licensed under AGP
|
||||
- Addon management, API key storage, scheduled auto-backups
|
||||
- System notices for onboarding and announcements
|
||||
|
||||
> **Admin:** Most configuration lives in the Admin Panel. The first user to register becomes the admin automatically.
|
||||
> **Admin:** Most configuration lives in the Admin Panel. On first boot TREK seeds an admin account automatically — credentials come from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if set, otherwise a random password is printed to the container log.
|
||||
|
||||
## Get Started
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
TREK can browse your personal photo library on Immich or Synology Photos and attach selected photos to trips. TREK never copies the original files — it stores only a reference (provider name + asset ID) and proxies all image streams through its own server, so your provider credentials are never sent to the browser.
|
||||
|
||||
> **Admin:** Two things must be enabled for photo providers to appear in Settings: the **Memories addon** and the **individual photo provider** (Immich or Synology Photos). Both are toggled separately in **Admin → Addons**. See [Admin-Addons](Admin-Addons). If your provider is on a local or private network, the server must be configured to allow internal network access. See [Internal-Network-Access](Internal-Network-Access).
|
||||
> **Admin:** Enable at least one photo provider (Immich or Synology Photos) in **Admin → Addons** — photo provider toggles appear as sub-items under the **Journey** addon. Once a provider is on, a Photo Providers section appears in each user's **Settings → Integrations**. If your provider runs on a local or private network, the server must be configured to allow internal network access. See [Admin-Addons](Admin-Addons) and [Internal-Network-Access](Internal-Network-Access).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Tags and Categories
|
||||
|
||||
TREK has a labeling system: **Global Place Categories** (admin-managed, shared across all users).
|
||||
TREK has two independent labelling systems for places:
|
||||
|
||||
- **Global Place Categories** — admin-managed, shared across every user on the instance (e.g. `Restaurant`, `Museum`).
|
||||
- **Personal Tags** — user-scoped, private labels (e.g. `hidden gem`, `kid-friendly`).
|
||||
|
||||
<!-- TODO: screenshot: tag list on place detail -->
|
||||
|
||||
@@ -24,6 +26,23 @@ Categories appear in:
|
||||
|
||||
> **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories). Only admins can create, edit, or delete categories. All users can read them.
|
||||
|
||||
## Personal Tags
|
||||
|
||||
Tags are private labels owned by each user. They attach to individual places via a many-to-many relationship (`place_tags` table), so the same tag can be applied to as many places as you like, and a single place can carry multiple tags.
|
||||
|
||||
**Fields per tag:**
|
||||
|
||||
- **Name** — free-form text.
|
||||
- **Color** — hex value displayed alongside the tag name. Default: `#10b981` (emerald).
|
||||
|
||||
Tags are scoped to their creator — other trip members do not see your tags, and different users can create tags with identical names without conflict. Deleting a tag automatically removes it from every place it was attached to.
|
||||
|
||||
### Where to manage them
|
||||
|
||||
At the moment tags are exposed primarily through the MCP API — AI assistants connected to your instance can list, create, update, and delete tags (`list_tags`, `create_tag`, `update_tag`, `delete_tag`) and attach them to places through the place endpoints. A dedicated web UI for tag management is not yet available; the filter `tag` parameter on the places API / MCP resource does support filtering places by a tag ID once one exists.
|
||||
|
||||
> **AI / MCP:** See [MCP-Tools-and-Resources](MCP-Tools-and-Resources) for the full tag tool list.
|
||||
|
||||
## When to use which
|
||||
|
||||
| Use case | Use |
|
||||
|
||||
+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