Compare commits

..

44 Commits

Author SHA1 Message Date
github-actions[bot] 0f6be35870 chore: bump version to 2.9.13 [skip ci] 2026-04-11 13:26:44 +00:00
jubnl f47852d689 docs: improve FORCE_HTTPS, COOKIE_SECURE, TRUST_PROXY documentation
FORCE_HTTPS now documents all four effects (redirect, HSTS, CSP
upgrade-insecure-requests, secure cookie flag) and is clearly marked
optional. COOKIE_SECURE default updated to "auto" with explanation of
auto-derivation logic. TRUST_PROXY clarifies it's off in dev unless
set and is required for FORCE_HTTPS. charts/README.md gains FORCE_HTTPS
and TRUST_PROXY entries. README prose expanded to explain all three
vars and their interaction.
2026-04-11 15:26:19 +02:00
Maurice be248e1ad4 Update Discord link in README.md 2026-04-10 14:13:01 +02:00
github-actions[bot] e290c7c522 chore: bump version to 2.9.12 [skip ci] 2026-04-10 05:51:22 +00:00
jubnl f20eb6639f chore(workflow): remove delete tag workflow 2026-04-10 07:50:51 +02:00
github-actions[bot] d0176d7ed6 chore: bump version to 2.9.12 [skip ci] 2026-04-10 05:44:33 +00:00
jubnl 8402f3bcfd chore: add workflow to delete Docker tags 2026-04-10 07:44:10 +02:00
github-actions[bot] 6caa966a52 chore: bump version to 2.10.0 [skip ci] 2026-04-10 05:36:13 +00:00
Julien G. 098918b416 Merge pull request #514 from gravitysc/chart-releaser
Chart releaser
2026-04-10 07:36:00 +02:00
github-actions[bot] 28c7013252 chore: bump version to 2.9.12 [skip ci] 2026-04-09 15:48:10 +00:00
Maurice fa810c3bab Merge pull request #530 from mauriceboe/ci/contributor-workflow-automation-main
ci: add contributor workflow automation
2026-04-09 17:47:56 +02:00
Julien G. 93d5ab7fcd Merge pull request #532 from luojiyin1987/fix/force-https-documentation
docs: Clarify FORCE_HTTPS and TRUST_PROXY configuration
2026-04-09 13:53:22 +02:00
luojiyin 729526bd34 docs: Clarify FORCE_HTTPS and TRUST_PROXY configuration
- Add explicit warning about FORCE_HTTPS when accessing directly on http://host:3000
- Explain that FORCE_HTTPS=false is required for direct access without reverse proxy
- Clarify TRUST_PROXY usage only when behind actual reverse proxy
- Prevent common configuration issues causing infinite redirects

This resolves potential confusion where users might experience 301 redirects
to non-existent HTTPS endpoints when accessing the Docker container directly.
2026-04-09 11:49:53 +08:00
jubnl c13b28ae8f ci: add contributor workflow automation
- Add PR template with description, type of change, and contributing checklist
- Enforce target branch: label + comment + 24h auto-close for PRs targeting main
- Flag bad issue titles: label + comment + 24h auto-close instead of instant close
- Redirect feature requests to Discussions (instant close, unchanged)
- Add two scheduled workflows to close stale labeled issues and PRs after 24h
- Update CONTRIBUTING.md with tests and branch up-to-date requirements
2026-04-09 01:23:21 +02:00
Kessler Dev 8c7d1f8fa6 chore: use helm-publisher action for chart release 2026-04-08 13:28:22 +02:00
Kessler Dev dba655d6e8 chore: implement helm chart release automation to gh-pages 2026-04-08 13:01:14 +02:00
Kessler Dev cb8280249f chore(chart): use appVersion as default image tag 2026-04-08 12:45:16 +02:00
github-actions[bot] 504195a324 chore: bump version to 2.9.11 [skip ci] 2026-04-07 11:18:45 +00:00
jubnl 47b880221d fix(oidc): resolve login/logout loop in OIDC-only mode
Three distinct bugs caused infinite OIDC redirect loops:

1. After logout, navigating to /login with no signal to suppress the
   auto-redirect caused the login page to immediately re-trigger the
   OIDC flow. Fixed by passing `{ state: { noRedirect: true } }` via
   React Router's navigation state (not URL params, which were fragile
   due to async cleanup timing) from all logout call sites.

2. On the OIDC callback page (/login?oidc_code=...), App.tsx's
   mount-level loadUser() fired concurrently with the LoginPage's
   exchange fetch. The App-level call had no cookie yet and got a 401,
   which (if it resolved after the successful exchange loadUser()) would
   overwrite isAuthenticated back to false. Fixed by skipping loadUser()
   in App.tsx when the initial path is /login.

3. React 18 StrictMode double-invokes useEffect. The first run called
   window.history.replaceState to clean the oidc_code from the URL
   before starting the async exchange, so the second run saw no
   oidc_code and fell through to the getAppConfig auto-redirect, firing
   window.location.href = '/api/auth/oidc/login' before the exchange
   could complete. Fixed by adding a useRef guard to prevent
   double-execution and moving replaceState into the fetch callbacks so
   the URL is only cleaned after the exchange resolves.

Also adds login.oidcLoggedOut translation key in all 14 languages to
show "You have been logged out" instead of the generic OIDC-only
message when landing on /login after an intentional logout.

Closes #491
2026-04-07 13:18:24 +02:00
github-actions[bot] a6ea73eab6 chore: bump version to 2.9.10 [skip ci] 2026-04-06 10:57:06 +00:00
Maurice 4ba6005ca3 fix(dayplan): resolve duplicate reservation display, date off-by-one, and missing day_id on edit
- Exclude place-assigned reservations from timeline to prevent duplicate display
- Use selected day's date instead of today when entering time without date
- Pass day_id when updating reservations, not only when creating
2026-04-06 12:56:54 +02:00
github-actions[bot] 09ab829b17 chore: bump version to 2.9.9 [skip ci] 2026-04-06 09:32:20 +00:00
Maurice 66a057a070 fix(bookings): resolve date handling and file auth bugs
- Clear reservation_time fields when switching booking type to hotel (#459)
- Parse date-only reservation_end_time correctly on edit (#455)
- Show end date on booking cards for date-only values (#455)
- Add auth token to file download links in bookings (#454)
- Account for timezone offsets in flight time validation (#456)
2026-04-06 11:32:06 +02:00
github-actions[bot] f2ffea5ba4 chore: bump version to 2.9.8 [skip ci] 2026-04-05 22:09:41 +00:00
jubnl b0dee4dafb feat(mcp): add MCP_MAX_SESSION_PER_USER env var and document it everywhere 2026-04-06 00:09:22 +02:00
github-actions[bot] beb48af8ed chore: bump version to 2.9.7 [skip ci] 2026-04-05 21:38:56 +00:00
jubnl e2be3ec191 fix(atlas): replace fuzzy region matching with exact name_en check
Bidirectional substring matching in isVisitedFeature caused unrelated
regions to be highlighted as visited (e.g. selecting Nordrhein-Westfalen
also marked Nord France due to "nord" being a substring match).

Replace the fuzzy loop with an additional exact check against the Natural
Earth name_en property to cover English-vs-native name mismatches.
Also fix Nominatim field priority to prefer state over county so
reverse-geocoded places resolve to the correct admin-1 level.

Adds integration tests ATLAS-009 through ATLAS-011 covering mark/unmark
region endpoints and user isolation.

Fixes #446
2026-04-05 23:38:34 +02:00
github-actions[bot] 68a1f9683e chore: bump version to 2.9.6 [skip ci] 2026-04-05 21:26:44 +00:00
Maurice 5c57116a68 fix(dayplan): restore time-based auto-sort for places and free reorder for untimed
Timed places now auto-sort chronologically when a time is set.
Untimed places can be freely dragged between timed items.
Transports are inserted by time with per-day position override.
Fixes regression from multi-day spanning PR that removed timed/untimed split.
2026-04-05 23:26:35 +02:00
github-actions[bot] 48508b9df4 chore: bump version to 2.9.5 [skip ci] 2026-04-05 21:12:19 +00:00
jubnl c8250256a7 fix(streaming): end response on client disconnect during asset pipe
When a client disconnects mid-stream, headers are already sent so the
catch block now calls response.end() before returning, preventing the
socket from being left open and crashing the server. Fixes #445.
2026-04-05 23:11:57 +02:00
github-actions[bot] 6491e1f986 chore: bump version to 2.9.4 [skip ci] 2026-04-05 21:02:53 +00:00
Maurice 03757ed0af fix(dayplan): per-day transport positions for multi-day reservations
Reordering places on one day of a multi-day reservation no longer
affects the order on other days. Transport positions are now stored
per-day in a new reservation_day_positions table instead of a single
global day_plan_position on the reservation.
2026-04-05 23:02:42 +02:00
github-actions[bot] a676dbe881 chore: bump version to 2.9.3 [skip ci] 2026-04-05 20:46:34 +00:00
jubnl 411d8620ba fix(reservations): reset stale budget category when it no longer exists
If the budget category stored in reservation metadata was deleted, the
form would re-submit it on next save, resurrecting the deleted category.
Now validates against live budget items on form init and falls back to
auto-generation when the stored category is gone.

Closes #442
2026-04-05 22:46:16 +02:00
github-actions[bot] f45f56318a chore: bump version to 2.9.2 [skip ci] 2026-04-05 20:36:00 +00:00
jubnl 3ae0f3f819 Merge remote-tracking branch 'origin/main' 2026-04-05 22:35:41 +02:00
jubnl 306626ee1c fix(trip): redirect to plan tab when active tab's addon is disabled
If a user's last visited tab belongs to an addon that gets disabled while
they are away, re-opening the trip now resets the active tab to 'plan'
instead of rendering the inaccessible addon page.

Closes #441
2026-04-05 22:30:22 +02:00
jubnl 7e0fe3b1b9 fix(reservations): hide price/budget fields when budget addon is disabled
Closes #440
2026-04-05 22:30:13 +02:00
jubnl fdbc015dbf fix(memories): re-fetch EXIF info when navigating between lightbox photos
The navigateTo function was clearing lightboxInfo without re-fetching it,
causing the EXIF sidebar to disappear and nav button placement to break.
Mirrors the fetch logic already present in the thumbnail click handler.

Fixes #439
2026-04-05 22:30:05 +02:00
github-actions[bot] 7d8e3912b4 chore: bump version to 2.9.1 [skip ci] 2026-04-05 20:20:56 +00:00
jubnl 9ebca725ae fix(CSP): Paths that end in / match any path they are a prefix of. 2026-04-05 22:20:40 +02:00
github-actions[bot] 9718187490 chore: bump version to 2.9.0 [skip ci] 2026-04-05 19:38:21 +00:00
Julien G. aa0620e01f Merge pull request #421 from mauriceboe/dev
v2.9.0
2026-04-05 21:38:11 +02:00
61 changed files with 818 additions and 164 deletions
+21
View File
@@ -0,0 +1,21 @@
## Description
<!-- What does this PR do? Why? -->
## Related Issue or Discussion
<!-- This project requires an issue or an approved feature request before submitting a PR. -->
<!-- For bug fixes: Closes #ISSUE_NUMBER -->
<!-- For features: Addresses discussion #DISCUSSION_NUMBER -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main`
- [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
- [ ] I have updated documentation if needed
@@ -0,0 +1,71 @@
name: Close issues with unchanged bad titles
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
permissions:
issues: write
jobs:
close-stale:
runs-on: ubuntu-latest
steps:
- name: Close stale invalid-title issues
uses: actions/github-script@v7
with:
script: |
const badTitles = [
"[bug]", "bug report", "bug", "issue",
"help", "question", "test", "...", "untitled"
];
const { data: issues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'invalid-title',
state: 'open',
per_page: 100,
});
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const issue of issues) {
const createdAt = new Date(issue.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
const titleLower = issue.title.trim().toLowerCase();
if (!badTitles.includes(titleLower)) {
// Title was fixed — remove the label and move on
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'invalid-title',
});
continue;
}
// Still a bad title after 24h — close it
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: [
'## Issue closed',
'',
'This issue has been automatically closed because the title was not updated within 24 hours.',
'',
'Feel free to open a new issue with a descriptive title that summarizes the problem.',
].join('\n'),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
}
@@ -0,0 +1,66 @@
name: Close PRs with unchanged wrong base branch
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
permissions:
pull-requests: write
issues: write
jobs:
close-stale:
runs-on: ubuntu-latest
steps:
- name: Close stale wrong-base-branch PRs
uses: actions/github-script@v7
with:
script: |
const { data: pulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const pull of pulls) {
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
if (!hasLabel) continue;
const createdAt = new Date(pull.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
// Base was fixed — remove label and move on
if (pull.base.ref !== 'main') {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
name: 'wrong-base-branch',
});
continue;
}
// Still targeting main after 24h — close it
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
body: [
'## PR closed',
'',
'This PR has been automatically closed because the base branch was not updated to `dev` within 24 hours.',
'',
'Feel free to open a new PR targeting `dev`.',
].join('\n'),
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull.number,
state: 'closed',
});
}
+55 -30
View File
@@ -1,4 +1,4 @@
name: Close untitled issues
name: Flag issues with bad titles
on:
issues:
@@ -10,58 +10,83 @@ permissions:
jobs:
check-title:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Close if title is empty or generic
- name: Flag or redirect issue
uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title.trim();
const badTitles = [
"[bug]",
"bug report",
"bug",
"issue",
];
const featureRequestTitles = [
"feature request",
"[feature]",
"[feature request]",
"[enhancement]"
]
const titleLower = title.toLowerCase();
const badTitles = [
"[bug]", "bug report", "bug", "issue",
"help", "question", "test", "...", "untitled"
];
const featureRequestTitles = [
"feature request", "[feature]", "[feature request]", "[enhancement]"
];
if (badTitles.includes(titleLower)) {
// Ensure the label exists
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'invalid-title',
});
} catch {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'invalid-title',
color: 'e4e669',
description: 'Issue title does not meet quality standards',
});
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
labels: ['invalid-title'],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: "This issue was closed because no title was provided. Please re-open with a descriptive title that summarizes the problem."
body: [
'## Invalid title',
'',
`Your issue title \`${title}\` is too generic to be actionable.`,
'',
'Please edit the title to something descriptive that summarizes the problem — for example:',
'> _Map view crashes when zooming in on Safari 17_',
'',
'**This issue will be automatically closed in 24 hours if the title has not been updated.**',
].join('\n'),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
state: "closed",
state_reason: "not_planned"
});
} else if (featureRequestTitles.some(t => titleLower.startsWith(t))) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: "Feature requests should be made in the [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests) — not as issues. This issue has been closed."
body: [
'## Wrong place for feature requests',
'',
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
'',
'This issue has been closed. Feel free to re-submit your idea in the right place!',
].join('\n'),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
state: "closed",
state_reason: "not_planned"
state: 'closed',
state_reason: 'not_planned',
});
}
}
+19 -2
View File
@@ -54,14 +54,16 @@ jobs:
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$CURRENT → $NEW_VERSION ($BUMP)"
# Update both package.json files
# Update package.json files and Helm chart
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add server/package.json server/package-lock.json client/package.json client/package-lock.json
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
@@ -151,3 +153,18 @@ jobs:
- name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest
release-helm:
runs-on: ubuntu-latest
needs: version-bump
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Publish Helm chart
uses: stefanprodan/helm-gh-pages@v1.7.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
+100
View File
@@ -0,0 +1,100 @@
name: Enforce PR Target Branch
on:
pull_request:
types: [opened, reopened, edited, synchronize]
jobs:
check-target:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Flag or clear wrong base branch
uses: actions/github-script@v7
with:
script: |
const base = context.payload.pull_request.base.ref;
const labels = context.payload.pull_request.labels.map(l => l.name);
const prNumber = context.payload.pull_request.number;
// If the base was fixed, remove the label and let it through
if (base !== 'main') {
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
return;
}
// Base is main — check if this user is a maintainer
let permission = 'none';
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.pull_request.user.login,
});
permission = data.permission;
} catch (_) {
// User is not a collaborator — treat as 'none'
}
if (['admin', 'write'].includes(permission)) {
console.log(`User has '${permission}' permission, skipping.`);
return;
}
// Already labeled — avoid spamming on every push
if (labels.includes('wrong-base-branch')) {
core.setFailed("PR must target `dev`, not `main`.");
return;
}
// Ensure the label exists
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'wrong-base-branch',
});
} catch {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'wrong-base-branch',
color: 'd73a4a',
description: 'PR is targeting the wrong base branch',
});
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['wrong-base-branch'],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: [
'## Wrong target branch',
'',
'This PR targets `main`, but contributions must go through `dev` first.',
'',
'To fix this, click **Edit** next to the PR title and change the base branch to `dev`.',
'',
'**This PR will be automatically closed in 24 hours if the base branch has not been updated.**',
'',
'> _If you need to merge directly to `main`, contact a maintainer._',
].join('\n'),
});
core.setFailed("PR must target `dev`, not `main`.");
+4 -15
View File
@@ -9,6 +9,8 @@ Thanks for your interest in contributing! Please read these guidelines before op
3. **No breaking changes** — Backwards compatibility is non-negotiable
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
## Pull Requests
@@ -35,22 +37,9 @@ fix(maps): correct zoom level on Safari
feat(budget): add CSV export for expenses
```
## Development Setup
## Development Environment
```bash
git clone https://github.com/mauriceboe/TREK.git
cd TREK
# Server
cd server && npm install && npm run dev
# Client (separate terminal)
cd client && npm install && npm run dev
```
Server: `http://localhost:3001` | Client: `http://localhost:5173`
On first run, check the server logs for the auto-generated admin credentials.
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
## More Details
+17 -7
View File
@@ -9,7 +9,7 @@
</p>
<p align="center">
<a href="https://discord.gg/J27gr9GH"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
@@ -143,9 +143,9 @@ services:
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
@@ -161,6 +161,7 @@ services:
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
@@ -173,6 +174,14 @@ services:
start_period: 15s
```
This example is aimed at reverse-proxy deployments where nginx, Caddy, Traefik, or a similar proxy terminates TLS in front of TREK. The three HTTPS-related variables work together:
- **`FORCE_HTTPS`** is 100% optional. When set to `true` it does four things: adds an HTTP-to-HTTPS 301 redirect, sends an HSTS header (`max-age=31536000`), adds the CSP `upgrade-insecure-requests` directive, and forces the session cookie `secure` flag on. It only makes sense behind a TLS-terminating proxy.
- **`TRUST_PROXY`** tells Express how many proxies sit in front of TREK so it can read the real client IP from `X-Forwarded-For` and the protocol from `X-Forwarded-Proto`. Without it, `FORCE_HTTPS` redirects will loop because Express never sees the request as secure. In production (`NODE_ENV=production`) this defaults to `1` automatically; in development it is off unless explicitly set.
- **`COOKIE_SECURE`** is normally auto-derived — the session cookie is marked `secure` whenever `NODE_ENV=production` or `FORCE_HTTPS=true`. Setting `COOKIE_SECURE=false` is an escape hatch that disables the `secure` flag even in production (e.g. testing over plain HTTP on a LAN). Do not disable it in real deployments.
If you access TREK directly on `http://<host>:3000` with no reverse proxy, leave `FORCE_HTTPS` unset (or remove it) and remove `TRUST_PROXY` to avoid redirect loops to a non-existent HTTPS endpoint.
```bash
docker compose up -d
```
@@ -282,9 +291,9 @@ trek.yourdomain.com {
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` |
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
| **OIDC / SSO** | | |
@@ -303,6 +312,7 @@ trek.yourdomain.com {
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `5` |
## Optional API Keys
+3 -1
View File
@@ -32,5 +32,7 @@ See `values.yaml` for more options.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 0.1.0
version: 2.9.13
description: Minimal Helm chart for TREK app
appVersion: "latest"
appVersion: "2.9.13"
@@ -27,7 +27,7 @@ spec:
fsGroup: 1000
containers:
- name: trek
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.resources }}
resources:
@@ -1,7 +1,7 @@
image:
repository: mauriceboe/trek
tag: latest
# tag: latest
pullPolicy: IfNotPresent
# Optional image pull secrets for private registries
@@ -25,11 +25,11 @@ env:
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
# Also used as the base URL for links in email notifications and other external links.
# FORCE_HTTPS: "false"
# Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy.
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
# COOKIE_SECURE: "true"
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# TRUST_PROXY: "1"
# Number of trusted reverse proxies for X-Forwarded-For header parsing.
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
# ALLOW_INTERNAL_NETWORK: "false"
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
@@ -53,6 +53,8 @@ env:
# Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "60"
# Max MCP API requests per user per minute. Defaults to 60.
# MCP_MAX_SESSION_PER_USER: "5"
# Max concurrent MCP sessions per user. Defaults to 5.
# Secret environment variables stored in a Kubernetes Secret.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "2.8.4",
"version": "2.9.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "2.8.4",
"version": "2.9.13",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "2.8.4",
"version": "2.9.13",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -82,7 +82,7 @@ export default function App() {
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (!location.pathname.startsWith('/shared/')) {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) {
loadUser()
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
+1 -1
View File
@@ -248,7 +248,7 @@ export const reservationsApi = {
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
}
export const weatherApi = {
+1 -1
View File
@@ -53,7 +53,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
const handleLogout = () => {
logout()
navigate('/login')
navigate('/login', { state: { noRedirect: true } })
}
const toggleDarkMode = () => {
@@ -956,6 +956,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setLightboxUserId(photo.user_id)
setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
setLightboxInfoLoading(true)
apiClient.get(buildProviderAssetUrl(photo, 'info'))
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
}
const exifContent = lightboxInfo ? (
@@ -248,8 +248,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const getTransportForDay = (dayId: number) => {
const day = days.find(d => d.id === dayId)
if (!day?.date) return []
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
return reservations.filter(r => {
if (!r.reservation_time || r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDate = r.reservation_time.split('T')[0]
const endDate = getEndDate(r)
@@ -341,14 +343,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
initTransportPositions(dayId)
}
// Build base list: ALL places (timed and untimed) + notes sorted by order_index/sort_order
// Places keep their order_index ordering — only transports are inserted based on time.
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
const baseItems = [
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
].sort((a, b) => a.sortKey - b.sortKey)
// Only transports are inserted among base items based on time/position
// Transports are inserted among places based on time
const timedTransports = transport.map(r => ({
type: 'transport' as const,
data: r,
@@ -360,19 +361,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
}
// Insert transports among base items using persisted position or time-to-position mapping.
// Insert transports among places based on per-day position or time
const result = [...baseItems]
for (let ti = 0; ti < timedTransports.length; ti++) {
const timed = timedTransports[ti]
const minutes = timed.minutes
// Use persisted position if available
if (timed.data.day_plan_position != null) {
result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data })
// Use per-day position if explicitly set by user reorder
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
if (perDayPos != null) {
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
continue
}
// Find insertion position: after the last base item with time <= this transport's time
// Find insertion position: after the last place with time <= this transport's time
let insertAfterKey = -Infinity
for (const item of result) {
if (item.type === 'place') {
@@ -500,10 +502,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (transportUpdates.length) {
for (const tu of transportUpdates) {
const res = reservations.find(r => r.id === tu.id)
if (res) res.day_plan_position = tu.day_plan_position
if (res) {
res.day_plan_position = tu.day_plan_position
// Update per-day position for multi-day reservations
if (!res.day_positions) res.day_positions = {}
res.day_positions[dayId] = tu.day_plan_position
}
}
setTransportPosVersion(v => v + 1)
await reservationsApi.updatePositions(tripId, transportUpdates)
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
}
if (prevAssignmentIds.length) {
const capturedDayId = dayId
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import apiClient from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
@@ -9,6 +10,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { getAuthUrl } from '../../api/authUrl'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
const TYPE_OPTIONS = [
@@ -71,6 +73,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const { t, locale } = useTranslation()
const fileInputRef = useRef(null)
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
@@ -111,6 +114,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (rawEnd.includes('T')) {
endDate = rawEnd.split('T')[0]
endTime = rawEnd.split('T')[1]?.slice(0, 5) || ''
} else if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) {
endDate = rawEnd
endTime = ''
}
setForm({
title: reservation.title || '',
@@ -139,7 +145,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
price: meta.price || '',
budget_category: meta.budget_category || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
} else {
setForm({
@@ -164,6 +170,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const startDate = form.reservation_time.split('T')[0]
const startTime = form.reservation_time.split('T')[1] || '00:00'
const endTime = form.reservation_end_time || '00:00'
// For flights, compare in UTC using timezone offsets
if (form.type === 'flight') {
const parseOffset = (tz: string): number | null => {
if (!tz) return null
const m = tz.trim().match(/^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::(\d{2}))?$/i)
if (!m) return null
const sign = m[1] === '+' ? 1 : -1
return sign * (parseInt(m[2]) * 60 + parseInt(m[3] || '0'))
}
const depOffset = parseOffset(form.meta_departure_timezone)
const arrOffset = parseOffset(form.meta_arrival_timezone)
if (depOffset === null || arrOffset === null) return false
const depMinutes = new Date(`${startDate}T${startTime}`).getTime() - depOffset * 60000
const arrMinutes = new Date(`${form.end_date}T${endTime}`).getTime() - arrOffset * 60000
return arrMinutes <= depMinutes
}
const startFull = `${startDate}T${startTime}`
const endFull = `${form.end_date}T${endTime}`
return endFull <= startFull
@@ -196,11 +218,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
}
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.reservation_time, reservation_end_time: combinedEndTime,
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
reservation_end_time: form.type === 'hotel' ? null : combinedEndTime,
location: form.location, confirmation_number: form.confirmation_number,
notes: form.notes,
assignment_id: form.assignment_id || null,
@@ -208,9 +233,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
metadata: Object.keys(metadata).length > 0 ? metadata : null,
}
// Auto-create/update budget entry if price is set, or signal removal if cleared
saveData.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 }
if (isBudgetEnabled) {
saveData.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 }
}
// If hotel with place + days, pass hotel data for auto-creation or update
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = {
@@ -358,7 +385,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
onChange={t => {
const [d] = (form.reservation_time || '').split('T')
const date = d || new Date().toISOString().split('T')[0]
const selectedDay = days.find(dy => dy.id === selectedDayId)
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
set('reservation_time', t ? `${date}T${t}` : date)
}}
/>
@@ -559,7 +587,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
<a href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => {
// Always unlink, never delete the file
// Clear primary reservation_id if it points to this reservation
@@ -643,33 +671,37 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Price + Budget Category */}
<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) }}
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>
{/* Price + Budget Category — only shown when budget addon is enabled */}
{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) }}
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>
)}
</>
)}
{/* Actions */}
@@ -10,6 +10,7 @@ import {
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
} from 'lucide-react'
import { getAuthUrl } from '../../api/authUrl'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
interface AssignmentLookupEntry {
@@ -138,7 +139,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
{fmtDate(r.reservation_time)}
{r.reservation_end_time?.includes('T') && r.reservation_end_time.split('T')[0] !== r.reservation_time.split('T')[0] && (
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
<> {fmtDate(r.reservation_end_time)}</>
)}
</div>
@@ -252,7 +253,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
{attachedFiles.map(f => (
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<a key={f.id} href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</a>
@@ -575,7 +575,7 @@ export default function AccountTab(): React.ReactElement {
try {
await authApi.deleteOwnAccount()
logout()
navigate('/login')
navigate('/login', { state: { noRedirect: true } })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
setShowDeleteConfirm(false)
+1
View File
@@ -367,6 +367,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'فشل الدخول إلى العرض التجريبي',
'login.oidcSignIn': 'تسجيل الدخول عبر {name}',
'login.oidcOnly': 'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.',
'login.oidcLoggedOut': 'تم تسجيل خروجك. سجّل الدخول مجدداً عبر مزود SSO.',
'login.demoHint': 'جرّب العرض التجريبي دون الحاجة للتسجيل',
'login.mfaTitle': 'المصادقة الثنائية',
'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
+1
View File
@@ -362,6 +362,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Falha no login de demonstração',
'login.oidcSignIn': 'Entrar com {name}',
'login.oidcOnly': 'Login por senha desativado. Use o provedor SSO.',
'login.oidcLoggedOut': 'Você foi desconectado. Entre novamente usando o provedor SSO.',
'login.demoHint': 'Experimente a demonstração — sem cadastro',
'login.mfaTitle': 'Autenticação em duas etapas',
'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.',
+1
View File
@@ -362,6 +362,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Přihlášení do dema se nezdařilo',
'login.oidcSignIn': 'Přihlásit se přes {name}',
'login.oidcOnly': 'Ověřování heslem je zakázáno. Přihlaste se prosím přes SSO poskytovatele.',
'login.oidcLoggedOut': 'Byl jste odhlášen. Přihlaste se znovu přes SSO poskytovatele.',
'login.demoHint': 'Vyzkoušejte demo registrace není nutná',
'login.mfaTitle': 'Dvoufaktorové ověření',
'login.mfaSubtitle': 'Zadejte 6místný kód z vaší autentizační aplikace.',
+1
View File
@@ -362,6 +362,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Demo-Login fehlgeschlagen',
'login.oidcSignIn': 'Anmelden mit {name}',
'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.',
'login.oidcLoggedOut': 'Du wurdest abgemeldet. Melde dich erneut über deinen SSO-Anbieter an.',
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
+1
View File
@@ -383,6 +383,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Demo login failed',
'login.oidcSignIn': 'Sign in with {name}',
'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.',
'login.oidcLoggedOut': 'You have been logged out. Sign in again using your SSO provider.',
'login.demoHint': 'Try the demo — no registration needed',
'login.mfaTitle': 'Two-factor authentication',
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
+1
View File
@@ -1490,6 +1490,7 @@ const es: Record<string, string> = {
'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña',
'admin.oidcOnlyModeHint': 'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.',
'login.oidcOnly': 'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.',
'login.oidcLoggedOut': 'Has cerrado sesión. Vuelve a iniciar sesión con tu proveedor SSO.',
// Settings (2.6.2)
'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
+1
View File
@@ -369,6 +369,7 @@ const fr: Record<string, string> = {
'login.demoFailed': 'Échec de la connexion démo',
'login.oidcSignIn': 'Se connecter avec {name}',
'login.oidcOnly': 'L\'authentification par mot de passe est désactivée. Veuillez vous connecter via votre fournisseur SSO.',
'login.oidcLoggedOut': 'Vous avez été déconnecté. Reconnectez-vous via votre fournisseur SSO.',
'login.demoHint': 'Essayez la démo — aucune inscription nécessaire',
// Register
+1
View File
@@ -362,6 +362,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Demo bejelentkezés sikertelen',
'login.oidcSignIn': 'Bejelentkezés ezzel: {name}',
'login.oidcOnly': 'A jelszavas hitelesítés le van tiltva. Kérjük, jelentkezz be az SSO szolgáltatódon keresztül.',
'login.oidcLoggedOut': 'Kijelentkeztél. Jelentkezz be újra az SSO szolgáltatódon keresztül.',
'login.demoHint': 'Próbáld ki a demót — regisztráció nélkül',
'login.mfaTitle': 'Kétfaktoros hitelesítés',
'login.mfaSubtitle': 'Add meg a 6 jegyű kódot a hitelesítő alkalmazásból.',
+1
View File
@@ -362,6 +362,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Accesso demo fallito',
'login.oidcSignIn': 'Accedi con {name}',
'login.oidcOnly': 'L\'autenticazione tramite password è disabilitata. Accedi utilizzando il tuo provider SSO.',
'login.oidcLoggedOut': 'Sei stato disconnesso. Accedi nuovamente tramite il tuo provider SSO.',
'login.demoHint': 'Prova la demo — nessuna registrazione necessaria',
'login.mfaTitle': 'Autenticazione a due fattori',
'login.mfaSubtitle': 'Inserisci il codice a 6 cifre dalla tua app authenticator.',
+1
View File
@@ -369,6 +369,7 @@ const nl: Record<string, string> = {
'login.demoFailed': 'Demo-login mislukt',
'login.oidcSignIn': 'Inloggen met {name}',
'login.oidcOnly': 'Wachtwoordauthenticatie is uitgeschakeld. Log in via je SSO-provider.',
'login.oidcLoggedOut': 'Je bent uitgelogd. Log opnieuw in via je SSO-provider.',
'login.demoHint': 'Probeer de demo — geen registratie nodig',
// Register
+1
View File
@@ -329,6 +329,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'login.demoFailed': 'Nie udało się zalogować do wersji demonstracyjnej',
'login.oidcSignIn': 'Zaloguj się z {name}',
'login.oidcOnly': 'Uwierzytelnianie hasłem jest wyłączone. Zaloguj się za pomocą swojego dostawcy SSO.',
'login.oidcLoggedOut': 'Zostałeś wylogowany. Zaloguj się ponownie za pomocą swojego dostawcy SSO.',
'login.demoHint': 'Wypróbuj demo — nie wymaga rejestracji',
'login.mfaTitle': 'Uwierzytelnianie dwuskładnikowe',
'login.mfaSubtitle': 'Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.',
+1
View File
@@ -369,6 +369,7 @@ const ru: Record<string, string> = {
'login.demoFailed': 'Ошибка демо-входа',
'login.oidcSignIn': 'Войти через {name}',
'login.oidcOnly': 'Вход по паролю отключён. Используйте вашего провайдера SSO для входа.',
'login.oidcLoggedOut': 'Вы вышли из системы. Войдите снова через вашего провайдера SSO.',
'login.demoHint': 'Попробуйте демо — регистрация не требуется',
// Register
+1
View File
@@ -369,6 +369,7 @@ const zh: Record<string, string> = {
'login.demoFailed': '演示登录失败',
'login.oidcSignIn': '通过 {name} 登录',
'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。',
'login.oidcLoggedOut': '您已退出登录。请重新通过 SSO 提供商登录。',
'login.demoHint': '试用演示——无需注册',
// Register
+1
View File
@@ -353,6 +353,7 @@ const zhTw: Record<string, string> = {
'login.demoFailed': '演示登入失敗',
'login.oidcSignIn': '透過 {name} 登入',
'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。',
'login.oidcLoggedOut': '您已登出。請重新透過 SSO 提供商登入。',
'login.demoHint': '試用演示——無需註冊',
// Register
+1 -1
View File
@@ -1551,7 +1551,7 @@ docker run -d --name trek \\
await adminApi.rotateJwtSecret()
setShowRotateJwtModal(false)
logout()
navigate('/login')
navigate('/login', { state: { noRedirect: true } })
} catch {
toast.error(t('common.error'))
setRotatingJwt(false)
+6 -7
View File
@@ -480,15 +480,13 @@ export default function AtlasPage(): React.ReactElement {
}
}
// Match feature by ISO code OR region name
// Match feature by ISO code OR region name (native or English)
const isVisitedFeature = (f: any) => {
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
const name = (f.properties?.name || '').toLowerCase()
if (visitedRegionNames.has(name)) return true
// Fuzzy: check if any visited name is contained in feature name or vice versa
for (const vn of visitedRegionNames) {
if (name.includes(vn) || vn.includes(name)) return true
}
const nameEn = (f.properties?.name_en || '').toLowerCase()
if (nameEn && visitedRegionNames.has(nameEn)) return true
return false
}
@@ -535,15 +533,16 @@ export default function AtlasPage(): React.ReactElement {
},
onEachFeature: (feature, layer) => {
const regionName = feature?.properties?.name || ''
const regionNameEn = feature?.properties?.name_en || ''
const countryName = feature?.properties?.admin || ''
const regionCode = feature?.properties?.iso_3166_2 || ''
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
const visited = isVisitedFeature(feature)
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || regionPlaceCounts[regionNameEn.toLowerCase()] || 0
layer.on('click', () => {
if (!countryA2) return
if (visited) {
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode)
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode || r.name.toLowerCase() === regionNameEn.toLowerCase())
if (regionEntry?.manuallyMarked) {
setConfirmActionRef.current({
type: 'unmark-region',
+15 -7
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
@@ -29,10 +29,13 @@ export default function LoginPage(): React.ReactElement {
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const exchangeInitiated = useRef(false)
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
const location = useLocation()
const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect
const redirectTarget = useMemo(() => {
const params = new URLSearchParams(window.location.search)
@@ -63,11 +66,13 @@ export default function LoginPage(): React.ReactElement {
}
if (oidcCode) {
if (exchangeInitiated.current) return
exchangeInitiated.current = true
setIsLoading(true)
window.history.replaceState({}, '', '/login')
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' })
.then(r => r.json())
.then(async data => {
window.history.replaceState({}, '', '/login')
if (data.token) {
await loadUser()
navigate('/dashboard', { replace: true })
@@ -75,7 +80,10 @@ export default function LoginPage(): React.ReactElement {
setError(data.error || 'OIDC login failed')
}
})
.catch(() => setError('OIDC login failed'))
.catch(() => {
window.history.replaceState({}, '', '/login')
setError('OIDC login failed')
})
.finally(() => setIsLoading(false))
return
}
@@ -96,12 +104,12 @@ export default function LoginPage(): React.ReactElement {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite) {
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite && !noRedirect) {
window.location.href = '/api/auth/oidc/login'
}
}
})
}, [navigate, t])
}, [navigate, t, noRedirect])
const handleDemoLogin = async (): Promise<void> => {
setError('')
@@ -527,7 +535,7 @@ export default function LoginPage(): React.ReactElement {
{oidcOnly ? (
<>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>{t('login.title')}</h2>
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{t('login.oidcOnly')}</p>
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{noRedirect ? t('login.oidcLoggedOut') : t('login.oidcOnly')}</p>
{error && (
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
{error}
+9 -1
View File
@@ -137,6 +137,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
return saved || 'plan'
})
useEffect(() => {
const validTabIds = TRIP_TABS.map(t => t.id)
if (!validTabIds.includes(activeTab)) {
setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
}
}, [enabledAddons])
const handleTabChange = (tabId: string): void => {
setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
@@ -423,7 +431,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false)
if (data.type === 'hotel') {
+5 -4
View File
@@ -22,10 +22,10 @@ services:
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
- TRUST_PROXY=1 # Number of trusted proxies (for X-Forwarded-For / real client IP)
- ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
# - APP_URL=https://trek.example.com # Public base URL — required when OIDC is enabled (must match the redirect URI registered with your IdP); also used as base URL for links in email notifications
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
@@ -39,6 +39,7 @@ services:
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
+4 -3
View File
@@ -9,9 +9,9 @@ TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
FORCE_HTTPS=false # Redirect HTTP → HTTPS behind a TLS proxy
COOKIE_SECURE=true # Set to false to allow session cookies over HTTP (e.g. plain-IP or non-HTTPS setups). Defaults to true in production.
TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy.
COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP.
TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS.
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
APP_URL=https://trek.example.com # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP
@@ -29,6 +29,7 @@ OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes
DEMO_MODE=false # Demo mode - resets data hourly
# MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
# MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
# Initial admin account — only used on first boot when no users exist yet.
# If both are set the admin account is created with these credentials.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-server",
"version": "2.8.4",
"version": "2.9.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
"version": "2.8.4",
"version": "2.9.13",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "2.8.4",
"version": "2.9.13",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
+1 -1
View File
@@ -84,7 +84,7 @@ export function createApp(): express.Application {
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
"https://router.project-osrm.org/route/v1"
"https://router.project-osrm.org/route/v1/"
],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"],
+21
View File
@@ -843,6 +843,27 @@ function runMigrations(db: Database.Database): void {
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
for (const b of bagsWithUser) ins.run(b.id, b.user_id);
},
// Migration: Per-day positions for multi-day reservations
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS reservation_day_positions (
reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
position REAL NOT NULL,
PRIMARY KEY (reservation_id, day_id)
);
`);
// Migrate existing global positions to per-day entries
const reservations = db.prepare('SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL').all() as any[];
const ins = db.prepare('INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
for (const r of reservations) {
const startDate = r.reservation_time?.split('T')[0];
const endDate = r.reservation_end_time?.split('T')[0] || startDate;
if (!startDate) continue;
const matchingDays = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?').all(r.trip_id, startDate, endDate) as { id: number }[];
for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position);
}
},
];
if (currentVersion < migrations.length) {
+2 -1
View File
@@ -18,7 +18,8 @@ interface McpSession {
const sessions = new Map<string, McpSession>();
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const MAX_SESSIONS_PER_USER = 5;
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 5;
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 60; // requests per minute per user
+3 -2
View File
@@ -91,10 +91,11 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
updatePositions(tripId, positions);
const { day_id } = req.body;
updatePositions(tripId, positions, day_id);
res.json({ success: true });
broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string);
broadcast(tripId, 'reservation:positions', { positions, day_id }, req.headers['x-socket-id'] as string);
});
router.put('/:id', authenticate, (req: Request, res: Response) => {
+28
View File
@@ -168,6 +168,34 @@ export function getParticipants(assignmentId: string | number) {
export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) {
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
.run(placeTime ?? null, endTime ?? null, id);
// Auto-sort: reorder timed assignments chronologically within the day
if (placeTime) {
const assignment = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id) as { day_id: number } | undefined;
if (assignment) {
const dayAssignments = db.prepare(`
SELECT da.id, COALESCE(da.assignment_time, p.place_time) as effective_time
FROM day_assignments da
JOIN places p ON da.place_id = p.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC
`).all(assignment.day_id) as { id: number; effective_time: string | null }[];
// Separate timed and untimed, sort timed by time
const timed = dayAssignments.filter(a => a.effective_time).sort((a, b) => {
const ta = a.effective_time!.includes(':') ? a.effective_time! : '99:99';
const tb = b.effective_time!.includes(':') ? b.effective_time! : '99:99';
return ta.localeCompare(tb);
});
const untimed = dayAssignments.filter(a => !a.effective_time);
// Interleave: timed in chronological order, untimed keep relative position
const reordered = [...timed, ...untimed];
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
reordered.forEach((a, i) => update.run(i, a.id));
}
}
return getAssignmentWithPlace(Number(id));
}
+1 -1
View File
@@ -421,7 +421,7 @@ async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInf
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
regionCode = regionCode.replace(/[A-Z]$/i, '');
}
const regionName = data.address?.county || data.address?.state || data.address?.province || data.address?.region || data.address?.city || null;
const regionName = data.address?.state || data.address?.province || data.address?.region || data.address?.county || data.address?.city || null;
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
const info: RegionInfo = {
country_code: countryCode,
@@ -178,7 +178,10 @@ export async function pipeAsset(url: string, response: Response, headers?: Recor
await pipeline(Readable.fromWeb(resp.body as any), response);
}
} catch (error) {
if (response.headersSent) return;
if (response.headersSent) {
response.end();
return;
}
if (error instanceof SsrfBlockedError) {
response.status(400).json({ error: error.message });
} else {
+53 -12
View File
@@ -6,7 +6,7 @@ export function verifyTripAccess(tripId: string | number, userId: number) {
}
export function listReservations(tripId: string | number) {
return db.prepare(`
const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r
@@ -16,7 +16,27 @@ export function listReservations(tripId: string | number) {
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
WHERE r.trip_id = ?
ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(tripId);
`).all(tripId) as any[];
// Attach per-day positions for multi-day reservations
const dayPositions = db.prepare(`
SELECT rdp.reservation_id, rdp.day_id, rdp.position
FROM reservation_day_positions rdp
JOIN reservations r ON rdp.reservation_id = r.id
WHERE r.trip_id = ?
`).all(tripId) as { reservation_id: number; day_id: number; position: number }[];
const posMap = new Map<number, Record<number, number>>();
for (const dp of dayPositions) {
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
}
for (const r of reservations) {
r.day_positions = posMap.get(r.id) || null;
}
return reservations;
}
export function getReservationWithJoins(id: string | number) {
@@ -117,14 +137,35 @@ export function createReservation(tripId: string | number, data: CreateReservati
return { reservation, accommodationCreated };
}
export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[]) {
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
for (const item of items) {
stmt.run(item.day_plan_position, item.id, tripId);
}
});
updateMany(positions);
export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[], dayId?: number | string) {
if (dayId) {
// Per-day positions for multi-day reservations
const stmt = db.prepare('INSERT OR REPLACE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
for (const item of items) {
stmt.run(item.id, dayId, item.day_plan_position);
}
});
updateMany(positions);
} else {
// Legacy: update global position
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
for (const item of items) {
stmt.run(item.day_plan_position, item.id, tripId);
}
});
updateMany(positions);
}
}
export function getDayPositions(tripId: string | number, dayId: number | string) {
return db.prepare(`
SELECT rdp.reservation_id, rdp.position
FROM reservation_day_positions rdp
JOIN reservations r ON rdp.reservation_id = r.id
WHERE r.trip_id = ? AND rdp.day_id = ?
`).all(tripId, dayId) as { reservation_id: number; position: number }[];
}
export function getReservation(id: string | number, tripId: string | number) {
@@ -193,8 +234,8 @@ export function updateReservation(id: string | number, tripId: string | number,
WHERE id = ?
`).run(
title || null,
reservation_time !== undefined ? (reservation_time || null) : current.reservation_time,
reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time,
(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,
+181
View File
@@ -202,3 +202,184 @@ describe('Bucket list', () => {
expect(res.status).toBe(404);
});
});
describe('Mark/unmark region', () => {
it('ATLAS-009 — POST /region/:code/mark marks a region as visited', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('ATLAS-009 — POST /region/:code/mark without name returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ country_code: 'DE' });
expect(res.status).toBe(400);
});
it('ATLAS-009 — POST /region/:code/mark without country_code returns 400', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen' });
expect(res.status).toBe(400);
});
it('ATLAS-009 — marking a region also auto-marks the parent country', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const stats = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
expect(codes).toContain('DE');
});
it('ATLAS-009 — marking the same region twice is idempotent', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const res = await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
expect(res.status).toBe(200);
});
it('ATLAS-010 — GET /regions returns marked regions grouped by country', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
await request(app)
.post('/api/addons/atlas/region/DE-BY/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Bayern', country_code: 'DE' });
const res = await request(app)
.get('/api/addons/atlas/regions')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('regions');
const deRegions = res.body.regions['DE'] as any[];
expect(deRegions).toBeDefined();
const codes = deRegions.map((r: any) => r.code);
expect(codes).toContain('DE-NW');
expect(codes).toContain('DE-BY');
});
it('ATLAS-011 — DELETE /region/:code/mark unmarks a region', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const del = await request(app)
.delete('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const res = await request(app)
.get('/api/addons/atlas/regions')
.set('Cookie', authCookie(user.id));
const deRegions = res.body.regions['DE'] as any[] | undefined;
const codes = (deRegions || []).map((r: any) => r.code);
expect(codes).not.toContain('DE-NW');
});
it('ATLAS-011 — unmark last region in country also unmarks the parent country', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
await request(app)
.delete('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id));
const stats = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
expect(codes).not.toContain('DE');
});
it('ATLAS-011 — unmark one region keeps country when another region remains', async () => {
const { user } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
await request(app)
.post('/api/addons/atlas/region/DE-BY/mark')
.set('Cookie', authCookie(user.id))
.send({ name: 'Bayern', country_code: 'DE' });
await request(app)
.delete('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user.id));
const stats = await request(app)
.get('/api/addons/atlas/stats')
.set('Cookie', authCookie(user.id));
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
expect(codes).toContain('DE');
});
it('ATLAS-011 — regions are isolated between users', async () => {
const { user: user1 } = createUser(testDb);
const { user: user2 } = createUser(testDb);
await request(app)
.post('/api/addons/atlas/region/DE-NW/mark')
.set('Cookie', authCookie(user1.id))
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
const res = await request(app)
.get('/api/addons/atlas/regions')
.set('Cookie', authCookie(user2.id));
expect(res.status).toBe(200);
const deRegions = res.body.regions['DE'] as any[] | undefined;
expect(deRegions).toBeUndefined();
});
});
+4 -3
View File
@@ -35,9 +35,9 @@
<Config Name="LOG_LEVEL" Target="LOG_LEVEL" Default="info" Mode="" Description="Log verbosity: info = concise user actions, debug = verbose admin-level details." Type="Variable" Display="advanced" Required="false" Mask="false">info</Config>
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Redirect HTTP to HTTPS when TREK is behind a TLS-terminating reverse proxy." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Set to false to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Not recommended to disable in production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Number of trusted reverse proxies for X-Forwarded-For IP detection." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Auto-derived (true in production or when FORCE_HTTPS=true). Set to false to force session cookies over plain HTTP. Not recommended for production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production; off in development unless set. Required for FORCE_HTTPS." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<!-- Initial Setup -->
@@ -58,4 +58,5 @@
<!-- Other -->
<Config Name="DEMO_MODE" Target="DEMO_MODE" Default="false" Mode="" Description="Enable demo mode (resets all data hourly). Not intended for regular use." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="MCP_RATE_LIMIT" Target="MCP_RATE_LIMIT" Default="60" Mode="" Description="Max MCP API requests per user per minute." Type="Variable" Display="advanced" Required="false" Mask="false">60</Config>
<Config Name="MCP_MAX_SESSION_PER_USER" Target="MCP_MAX_SESSION_PER_USER" Default="5" Mode="" Description="Max concurrent MCP sessions per user." Type="Variable" Display="advanced" Required="false" Mask="false">5</Config>
</Container>