diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml
index 440b6b7a..3526e38e 100644
--- a/.github/workflows/wiki.yml
+++ b/.github/workflows/wiki.yml
@@ -23,4 +23,4 @@ jobs:
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
- strategy: init
+ strategy: clone
diff --git a/README.md b/README.md
index 03f6da23..f3dfae0f 100644
--- a/README.md
+++ b/README.md
@@ -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
#### ๐ค 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`).
@@ -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;
}
}
```
@@ -394,6 +400,7 @@ Caddy handles TLS and WebSockets automatically.
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
+| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
diff --git a/TRADEMARKS.md b/TRADEMARKS.md
new file mode 100644
index 00000000..3483065e
--- /dev/null
+++ b/TRADEMARKS.md
@@ -0,0 +1,121 @@
+# Trademark Policy
+
+## Introduction
+
+This is the TREK project's policy for the use of our trademarks. While TREK is
+available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
+license does not include a license to use our trademarks.
+
+This policy describes how you may use our trademarks. Our goal is to strike a
+balance between: 1) our need to ensure that our trademarks remain reliable
+indicators of the software we release; and 2) our community members' desire to
+be full participants in the TREK project.
+
+## Our trademarks
+
+This policy covers the name "TREK" as well as any associated logos, trade dress,
+goodwill, or designs (our "Marks").
+
+## In general
+
+Whenever you use our Marks, you must always do so in a way that does not mislead
+anyone about exactly who is the source of the software. For example, you cannot
+say you are distributing TREK when you're distributing a modified version of it,
+because people would think they would be getting the same software that they
+can get directly from us when they aren't. You also cannot use our Marks on
+your website in a way that suggests that your website is an official TREK
+website or that we endorse your website. But, if true, you can say you like
+TREK, that you participate in the TREK community, that you are providing an
+unmodified version of TREK, or that you wrote a guide describing how to use
+TREK.
+
+This fundamental requirement, that it is always clear to people what they are
+getting and from whom, is reflected throughout this policy. It should also
+serve as your guide if you are not sure about how you are using the Marks.
+
+In addition:
+
+* You may not use or register, in whole or in part, the Marks as part of your
+ own trademark, service mark, domain name, company name, trade name, product
+ name or service name.
+* Trademark law does not allow your use of names or trademarks that are too
+ similar to ours. You therefore may not use an obvious variation of any of our
+ Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
+ abbreviation for a similar or compatible product or service.
+* You agree that you will not acquire any rights in the Marks and that any
+ goodwill generated by your use of the Marks and participation in our
+ community inures solely to our benefit.
+
+## Distribution of unmodified source code or unmodified executable code we have compiled
+
+When you redistribute an unmodified copy of TREK, you are not changing the
+quality or nature of it. Therefore, you may retain the Marks we have placed on
+the software to identify your redistribution. This kind of use only applies if
+you are redistributing an official TREK distribution that has not been changed
+in any way.
+
+## Distribution of executable code that you have compiled, or modified code
+
+You may use the word mark "TREK", but not any TREK logos, to truthfully
+describe the origin of the software that you are providing, that is, that the
+code you are distributing is a modification of TREK. You may say, for example,
+that "this software is derived from the source code for TREK."
+
+Of course, you can place your own trademarks or logos on versions of the
+software to which you have made substantive modifications, because by modifying
+the software, you have become the origin of that exact version. In that case,
+you should not use our Marks.
+
+However, you may use our Marks for the distribution of code (source or
+executable) on the condition that any executable is built from an official TREK
+source code release and that any modifications are limited to switching on or
+off features already included in the software, translations into other
+languages, and incorporating minor bug-fix patches. Use of our Marks on any
+further modification is not permitted.
+
+## Mobile wrappers, hosted instances, and forks
+
+The following clarifications apply specifically to common ways TREK is
+redistributed:
+
+* **Self-hosted instances of unmodified TREK.** You may refer to your instance
+ as "a TREK instance" or "running TREK." You may not name the service itself
+ in a way that suggests it is the official TREK ("TREK Cloud," "TREK
+ Official," etc.).
+* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
+ TREK.** You may describe your app as "a mobile client for TREK" or "for use
+ with TREK." You may not publish it on app stores under the name "TREK" or a
+ confusingly similar name, and you may not use the TREK logo as the app icon
+ unless your wrapper distributes only an unmodified, official TREK instance
+ and you have obtained permission.
+* **Forks of the TREK source code.** Forks that diverge from upstream must use
+ a different name. You may state that your fork is "based on TREK" or "a fork
+ of TREK," but the project name itself must be your own.
+
+## Statements about your software's relation to TREK
+
+You may use the word mark, but not TREK logos, to truthfully describe the
+relationship between your software and ours. The word mark "TREK" should be
+used after a verb or preposition that describes the relationship between your
+software and ours. So you may say, for example, "Bob's app for TREK" but may
+not say "Bob's TREK app." Some other examples that may work for you are:
+
+* [Your software] uses TREK
+* [Your software] is powered by TREK
+* [Your software] runs on TREK
+* [Your software] for use with TREK
+* [Your software] for TREK
+
+## Questions and permission requests
+
+If you are not sure whether your intended use of the Marks is permitted under
+this policy, or if you would like to request explicit permission for a use that
+is not covered, please open an issue on the TREK GitHub repository or contact
+the maintainers directly.
+
+---
+
+These guidelines are based on the
+[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
+under a
+[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
\ No newline at end of file
diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml
index 71765000..a06ed5a9 100644
--- a/charts/trek/Chart.yaml
+++ b/charts/trek/Chart.yaml
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
-version: 2.9.14
+version: 3.0.9
description: Minimal Helm chart for TREK app
-appVersion: "2.9.14"
+appVersion: "3.0.9"
diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml
index af3a7182..33efce0c 100644
--- a/charts/trek/templates/configmap.yaml
+++ b/charts/trek/templates/configmap.yaml
@@ -22,6 +22,9 @@ data:
{{- if .Values.env.FORCE_HTTPS }}
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
{{- end }}
+ {{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
+ HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
+ {{- end }}
{{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }}
diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml
index 42c86b1f..0f19d230 100644
--- a/charts/trek/values.yaml
+++ b/charts/trek/values.yaml
@@ -30,6 +30,8 @@ env:
# Also used as the base URL for links in email notifications and other external links.
# FORCE_HTTPS: "false"
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
+ # HSTS_INCLUDE_SUBDOMAINS: "false"
+ # When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true"
# 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"
diff --git a/client/package-lock.json b/client/package-lock.json
index 1763c702..646cd40a 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "trek-client",
- "version": "2.9.14",
+ "version": "3.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
- "version": "2.9.14",
+ "version": "3.0.9",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
@@ -8907,9 +8907,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.9",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
- "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
diff --git a/client/package.json b/client/package.json
index 9efbb68c..04e502f9 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "trek-client",
- "version": "2.9.14",
+ "version": "3.0.9",
"private": true,
"type": "module",
"scripts": {
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 1322b8bb..378ddeab 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -356,9 +356,13 @@ export const journeyApi = {
// Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
+ uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
+ addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
- linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
+ linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data),
+ unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
+ deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
updatePhoto: (photoId: number, data: Record
) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx
index e442a0ff..a91a977e 100644
--- a/client/src/components/Budget/BudgetPanel.tsx
+++ b/client/src/components/Budget/BudgetPanel.tsx
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
}
const handleRenameCategory = async (oldName, newName) => {
if (!newName.trim() || newName.trim() === oldName) return
- const items = grouped[oldName] || []
+ const items = grouped.get(oldName) || []
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
}
const handleAddCategory = () => {
diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx
index 50be5edf..766f8b7f 100644
--- a/client/src/components/Journey/MobileEntryView.tsx
+++ b/client/src/components/Journey/MobileEntryView.tsx
@@ -25,20 +25,22 @@ const WEATHER_CONFIG: Record = {
cold: { icon: Snowflake, label: 'Cold' },
}
-function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
+function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string {
+ if (builder) return builder(p.photo_id)
return `/api/photos/${p.photo_id}/${size}`
}
interface Props {
entry: JourneyEntry
readOnly?: boolean
+ publicPhotoUrl?: (photoId: number) => string
onClose: () => void
onEdit: () => void
onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
}
-export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) {
+export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) {
const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
@@ -85,7 +87,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
{photos.length > 0 && (
onPhotoClick(photos, 0)}
@@ -102,7 +104,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
{photos.map((p, i) => (
onPhotoClick(photos, i)}
diff --git a/client/src/components/Layout/DemoBanner.tsx b/client/src/components/Layout/DemoBanner.tsx
index ba77cd40..c4068017 100644
--- a/client/src/components/Layout/DemoBanner.tsx
+++ b/client/src/components/Layout/DemoBanner.tsx
@@ -266,17 +266,22 @@ export default function DemoBanner(): React.ReactElement | null {
return (
setDismissed(true)}>
) => e.stopPropagation()}>
{/* Header */}
@@ -367,8 +372,10 @@ export default function DemoBanner(): React.ReactElement | null {
{/* Footer */}
diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts
index 46549188..77e33eac 100644
--- a/client/src/components/PDF/TripPDF.test.ts
+++ b/client/src/components/PDF/TripPDF.test.ts
@@ -78,6 +78,7 @@ const transportReservation = {
id: 400,
title: 'Flight to Rome',
type: 'flight',
+ day_id: 10,
reservation_time: '2025-06-01T14:30:00',
confirmation_number: 'ABC123',
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx
index 040bb711..1a5a3316 100644
--- a/client/src/components/PDF/TripPDF.tsx
+++ b/client/src/components/PDF/TripPDF.tsx
@@ -140,23 +140,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const totalCost = Object.values(assignments || {})
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
+ // Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
+ const pdfGetDayOrder = (d: Day) => d.day_number
+ const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
+ const startId = r.day_id
+ const endId = r.end_day_id ?? startId
+ if (!startId || startId === endId) return 'single'
+ if (dayId === startId) return 'start'
+ if (dayId === endId) return 'end'
+ return 'middle'
+ }
+ const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
+ const phase = pdfGetSpanPhase(r, dayId)
+ if (phase === 'end') return r.reservation_end_time || null
+ if (phase === 'middle') return null
+ return r.reservation_time || null
+ }
+ const pdfGetSpanLabel = (r: any, phase: string): string | null => {
+ if (phase === 'single') return null
+ if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
+ if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
+ return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
+ }
+ const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
+ if (r.type === 'hotel') return false
+ const startId = r.day_id
+ const endId = r.end_day_id ?? startId
+ if (startId == null) return false
+ if (endId !== startId) {
+ const startDay = sorted.find(d => d.id === startId)
+ const endDay = sorted.find(d => d.id === endId)
+ const thisDay = sorted.find(d => d.id === dayId)
+ if (!startDay || !endDay || !thisDay) return false
+ return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
+ }
+ return startId === dayId
+ })
+
// Build day HTML
const daysHtml = sorted.map((day, di) => {
const assigned = assignments[String(day.id)] || []
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc)
- // Reservations for this day (hotel rendered via accommodations block)
- const dayReservations = (reservations || []).filter(r => {
- if (!r.reservation_time || r.type === 'hotel') return false
- return day.date && r.reservation_time.split('T')[0] === day.date
- })
+ // Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
+ const dayReservations = pdfGetTransportForDay(day.id)
+ .filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
dayReservations.forEach(r => {
- const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
+ const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
merged.push({ type: 'reservation', k: pos, data: r })
})
merged.sort((a, b) => a.k - b.k)
@@ -177,13 +212,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' ยท ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' ยท ')
const locationLine = r.location || meta.location || ''
- const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
+ const phase = pdfGetSpanPhase(r, day.id)
+ const spanLabel = pdfGetSpanLabel(r, phase)
+ const displayTime = pdfGetDisplayTime(r, day.id)
+ const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
+ const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
return `
${icon}
-
${escHtml(r.title)}${time ? ` ${time} ` : ''}
+
${titleHtml}${time ? ` ${time} ` : ''}
${subtitle ? `
${escHtml(subtitle)}
` : ''}
${locationLine ? `
${escHtml(locationLine)}
` : ''}
${r.confirmation_number ? `
Code: ${escHtml(r.confirmation_number)}
` : ''}
diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx
index 8ef2282b..9487f402 100644
--- a/client/src/components/Planner/DayDetailPanel.tsx
+++ b/client/src/components/Planner/DayDetailPanel.tsx
@@ -66,7 +66,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
- const fmtTime = (v) => formatTime12(v, is12h)
+ const fmtTime = (v) => {
+ if (!v) return v
+ if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
+ return formatTime12(v, is12h)
+ }
const unit = isFahrenheit ? 'ยฐF' : 'ยฐC'
const collapsed = collapsedProp
const toggleCollapse = () => onToggleCollapse?.()
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 64049e89..b1eda2d3 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -1576,7 +1576,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.reservation_time?.includes('T') && (
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
- {res.reservation_end_time && ` โ ${res.reservation_end_time}`}
+ {res.reservation_end_time && ` โ ${(() => {
+ const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
+ return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
+ })()}`}
)}
{(() => {
diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx
index 7fa8a7e8..f5ec6f13 100644
--- a/client/src/components/Planner/ReservationModal.tsx
+++ b/client/src/components/Planner/ReservationModal.tsx
@@ -182,6 +182,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
let combinedEndTime = form.reservation_end_time
if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
+ } else if (form.reservation_end_time && form.reservation_time) {
+ combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index 00c105c2..770d36a5 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -236,7 +236,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('reservations.date')}
{fmtDate(r.reservation_time)}
- {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] && (
+ {(() => {
+ const endDatePart = r.reservation_end_time
+ ? r.reservation_end_time.includes('T')
+ ? r.reservation_end_time.split('T')[0]
+ : /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
+ ? r.reservation_end_time
+ : null
+ : null
+ return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
+ })() && (
<> โ {fmtDate(r.reservation_end_time)}>
)}
diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx
index 83ca6a58..259bf29e 100644
--- a/client/src/components/Planner/TransportModal.tsx
+++ b/client/src/components/Planner/TransportModal.tsx
@@ -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
()
+ 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({})
@@ -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 }} />
+ {/* Price + Budget Category */}
+ {isBudgetEnabled && (
+ <>
+
+
+ {t('reservations.price')}
+ { 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} />
+
+
+ {t('reservations.budgetCategory')}
+ set('budget_category', v)}
+ options={[
+ { value: '', label: t('reservations.budgetCategoryAuto') },
+ ...budgetCategories.map(c => ({ value: c, label: c })),
+ ]}
+ placeholder={t('reservations.budgetCategoryAuto')}
+ size="sm"
+ />
+
+
+ {form.price && parseFloat(form.price) > 0 && (
+
+ {t('reservations.budgetHint')}
+
+ )}
+ >
+ )}
+
)
diff --git a/client/src/components/shared/ConfirmDialog.tsx b/client/src/components/shared/ConfirmDialog.tsx
index 31cd9295..d75ad190 100644
--- a/client/src/components/shared/ConfirmDialog.tsx
+++ b/client/src/components/shared/ConfirmDialog.tsx
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
return (
{
it('renders the empty gallery state when journey has no photos', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
- // Override with entries that have no photos
+ // Override with entries that have no photos and empty gallery
const emptyEntry = {
...mockJourneyDetail.entries[0],
photos: [],
};
setupDefaultHandlers({
entries: [emptyEntry],
+ gallery: [],
stats: { entries: 1, photos: 0, places: 1 },
});
@@ -1981,10 +2000,9 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
});
- // The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
- // The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US
- const dateOverlay = document.querySelector('[class*="opacity-0"]');
- expect(dateOverlay).toBeTruthy();
+ // Gallery photos render in a grid; each photo has a group container
+ const photos = document.querySelectorAll('[class*="aspect-square"]');
+ expect(photos.length).toBeGreaterThanOrEqual(1);
});
});
@@ -2022,6 +2040,11 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({
entries: [immichEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 },
+ gallery: [{
+ id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null,
+ asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
+ caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
+ }],
});
render(
);
@@ -2056,6 +2079,11 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({
entries: [synologyEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 },
+ gallery: [{
+ id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
+ asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
+ caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
+ }],
});
render(
);
@@ -3265,25 +3293,14 @@ describe('JourneyDetailPage', () => {
// โโ FE-PAGE-JOURNEYDETAIL-141 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
- it('uploading files in gallery creates an entry and uploads photos', async () => {
+ it('uploading files in gallery calls gallery upload API', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
- let createCalled = false;
let uploadCalled = false;
server.use(
- http.post('/api/journeys/1/entries', () => {
- createCalled = true;
- return HttpResponse.json({
- id: 99, journey_id: 1, author_id: 1, type: 'entry',
- entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null,
- location_lat: null, location_lng: null, mood: null, weather: null,
- tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
- entry_time: null, photos: [], created_at: now, updated_at: now,
- });
- }),
- http.post('/api/journeys/entries/99/photos', () => {
+ http.post('/api/journeys/1/gallery/photos', () => {
uploadCalled = true;
- return HttpResponse.json([]);
+ return HttpResponse.json({ photos: [] });
}),
);
@@ -3304,9 +3321,6 @@ describe('JourneyDetailPage', () => {
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
await user.upload(fileInput, testFile);
- await waitFor(() => {
- expect(createCalled).toBe(true);
- });
await waitFor(() => {
expect(uploadCalled).toBe(true);
});
@@ -3320,9 +3334,9 @@ describe('JourneyDetailPage', () => {
let deleteCalled = false;
server.use(
- http.delete('/api/journeys/photos/100', () => {
+ http.delete('/api/journeys/1/gallery/100', () => {
deleteCalled = true;
- return HttpResponse.json({ success: true });
+ return new HttpResponse(null, { status: 204 });
}),
);
diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx
index 4c2c3643..e7aa51f9 100644
--- a/client/src/pages/JourneyDetailPage.tsx
+++ b/client/src/pages/JourneyDetailPage.tsx
@@ -27,7 +27,7 @@ import {
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile'
-import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
+import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [
@@ -80,7 +80,7 @@ function formatDate(d: string, locale?: string): { weekday: string; month: strin
}
}
-function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
+function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string {
return `/api/photos/${p.photo_id}/${size}`
}
@@ -341,7 +341,7 @@ export default function JourneyDetailPage() {
)
}
- const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
+ const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton'))
const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort()
@@ -458,7 +458,7 @@ export default function JourneyDetailPage() {
className={
isMobile
? ''
- : 'flex-1 overflow-y-auto journey-feed-scroll'
+ : 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll'
}
>
@@ -693,10 +693,11 @@ export default function JourneyDetailPage() {
>
setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
+ onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onRefresh={() => loadJourney(Number(id))}
/>
@@ -733,7 +734,7 @@ export default function JourneyDetailPage() {
entry={editingEntry}
journeyId={current.id}
tripDates={tripDates}
- galleryPhotos={current.entries.flatMap(e => e.photos || [])}
+ galleryPhotos={current.gallery || []}
onClose={() => setEditingEntry(null)}
onSave={async (data) => {
let entryId = editingEntry.id
@@ -971,12 +972,13 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
// โโ Gallery View โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: {
+function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
entries: JourneyEntry[]
+ gallery: GalleryPhoto[]
journeyId: number
userId: number
trips: JourneyTrip[]
- onPhotoClick: (photos: JourneyPhoto[], index: number) => void
+ onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onRefresh: () => void
}) {
const { t } = useTranslation()
@@ -1009,19 +1011,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
})()
}, [])
- const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
- const seenPhotoIds = new Map
() // photo_id โ index in allPhotos
- for (const e of entries) {
- for (const p of e.photos) {
- const existing = seenPhotoIds.get(p.photo_id)
- if (existing === undefined) {
- seenPhotoIds.set(p.photo_id, allPhotos.length)
- allPhotos.push({ photo: p, entry: e })
- } else if (e.title === 'Gallery' && allPhotos[existing].entry.title !== 'Gallery') {
- allPhotos[existing] = { photo: p, entry: e }
- }
- }
- }
+ const allPhotos = gallery
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
@@ -1037,22 +1027,9 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
if (!files?.length) return
setGalleryUploading(true)
try {
- // find existing "Gallery" entry or create one. The stored title is the
- // literal 'Gallery' (server-side checks look for this exact string) โ
- // do not send a translated label here.
- let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
- let entryId = galleryEntry?.id
- if (!entryId) {
- const entry = await journeyApi.createEntry(journeyId, {
- title: 'Gallery',
- entry_date: new Date().toISOString().split('T')[0],
- type: 'entry',
- })
- entryId = entry.id
- }
const formData = new FormData()
for (const f of files) formData.append('photos', f)
- await journeyApi.uploadPhotos(entryId, formData)
+ await journeyApi.uploadGalleryPhotos(journeyId, formData)
toast.success(t('journey.photosUploaded', { count: files.length }))
onRefresh()
} catch {
@@ -1063,25 +1040,24 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
e.target.value = ''
}
- const handleDeletePhoto = async (photoId: number) => {
+ const handleDeletePhoto = async (galleryPhotoId: number) => {
const store = useJourneyStore.getState()
if (!store.current) return
- const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId)
- if (!target) return
- const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id)
- // Optimistic update โ remove every row with this photo_id
- const updated = {
- ...store.current,
- entries: store.current.entries.map(e => ({
- ...e,
- photos: e.photos.filter(p => p.photo_id !== target.photo_id),
- })).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
- }
- useJourneyStore.setState({ current: updated })
+ // Optimistic update โ remove from gallery and all entry photo lists
+ useJourneyStore.setState({
+ current: {
+ ...store.current,
+ gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
+ entries: store.current.entries.map(e => ({
+ ...e,
+ photos: e.photos.filter(p => p.id !== galleryPhotoId),
+ })),
+ },
+ })
try {
- await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id)))
+ await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
} catch {
toast.error(t('common.error'))
onRefresh()
@@ -1132,11 +1108,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
) : (
- {allPhotos.map(({ photo, entry }, i) => (
+ {allPhotos.map((photo, i) => (
onPhotoClick(allPhotos.map(a => a.photo), i)}
+ onClick={() => onPhotoClick(allPhotos, i)}
>
{photo.caption}
)}
-
-
- {new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
-
-
))}
@@ -1182,25 +1153,19 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
userId={userId}
entries={entriesWithContent}
trips={trips}
- existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
+ existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
onClose={() => setShowPicker(false)}
onAdd={async (groups, entryId) => {
- let targetId = entryId
- if (!targetId) {
- try {
- const entry = await journeyApi.createEntry(journeyId, {
- title: 'Gallery',
- entry_date: new Date().toISOString().split('T')[0],
- type: 'entry',
- })
- targetId = entry.id
- } catch { return }
- }
let added = 0
for (const group of groups) {
try {
- const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
- added += result.added || 0
+ if (entryId) {
+ const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
+ added += result.added || 0
+ } else {
+ const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
+ added += result.added || 0
+ }
} catch {}
}
if (added > 0) {
@@ -2201,7 +2166,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
entry: JourneyEntry
journeyId: number
tripDates: Set
- galleryPhotos: JourneyPhoto[]
+ galleryPhotos: GalleryPhoto[]
onClose: () => void
onSave: (data: Record) => Promise
onUploadPhotos: (entryId: number, formData: FormData) => Promise
@@ -2227,7 +2192,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const [cons, setCons] = useState(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
- const [photos, setPhotos] = useState(entry.photos || [])
+ const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState([])
const [pendingLinkIds, setPendingLinkIds] = useState([])
const [showGalleryPick, setShowGalleryPick] = useState(false)
@@ -2254,8 +2219,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
pendingLinkIds.length > 0
)
- const uniqueGalleryPhotos = Array.from(new Map(galleryPhotos.map(gp => [gp.photo_id, gp])).values())
- const availableGalleryPhotos = uniqueGalleryPhotos.filter(gp => !photos.some(p => p.photo_id === gp.photo_id))
+ const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
@@ -2421,8 +2385,13 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
{
e.stopPropagation()
- await journeyApi.deletePhoto(p.id)
setPhotos(prev => prev.filter(x => x.id !== p.id))
+ if (entry.id > 0) {
+ // unlink from entry; gallery row is preserved
+ try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
+ } else {
+ setPendingLinkIds(prev => prev.filter(id => id !== p.id))
+ }
}}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
diff --git a/client/src/pages/JourneyPublicPage.test.tsx b/client/src/pages/JourneyPublicPage.test.tsx
index 1702a7d0..460e82c4 100644
--- a/client/src/pages/JourneyPublicPage.test.tsx
+++ b/client/src/pages/JourneyPublicPage.test.tsx
@@ -56,6 +56,21 @@ vi.mock('../components/Journey/PhotoLightbox', () => ({
),
}));
+vi.mock('../components/Journey/MobileMapTimeline', () => ({
+ default: ({ onEntryClick }: any) => (
+
+ onEntryClick({ id: 10, title: 'Shibuya Crossing', story: 'The most famous crossing in the world.', entry_date: '2026-03-15', entry_time: '14:00', location_name: 'Shibuya, Tokyo', photos: [] })}>
+ Open Entry
+
+
+ ),
+}));
+
+const mockIsMobile = { value: false };
+vi.mock('../hooks/useIsMobile', () => ({
+ useIsMobile: () => mockIsMobile.value,
+}));
+
import JourneyPublicPage from './JourneyPublicPage';
// โโ Fixtures โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -106,6 +121,9 @@ const mockJourneyData = {
share_gallery: true,
share_map: true,
},
+ gallery: [
+ { id: 100, journey_id: 1, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance', shared: 1, sort_order: 0, created_at: 0 },
+ ],
stats: {
entries: 2,
photos: 1,
@@ -136,6 +154,7 @@ function setup404() {
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
+ mockIsMobile.value = false;
});
// โโ Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -340,6 +359,11 @@ describe('JourneyPublicPage', () => {
],
},
],
+ gallery: [
+ { id: 200, journey_id: 1, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A', shared: 1, sort_order: 0, created_at: 0 },
+ { id: 201, journey_id: 1, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B', shared: 1, sort_order: 1, created_at: 0 },
+ { id: 202, journey_id: 1, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C', shared: 1, sort_order: 2, created_at: 0 },
+ ],
stats: { entries: 1, photos: 3, places: 0 },
};
@@ -391,6 +415,40 @@ describe('JourneyPublicPage', () => {
expect(statsContainer!.textContent).toContain('7');
});
+ // FE-PAGE-PUBLICJOURNEY-019 โ bug #828
+ it('FE-PAGE-PUBLICJOURNEY-019: mobile public share does not show standalone Map tab', async () => {
+ mockIsMobile.value = true;
+ setupSuccess();
+ render( );
+ await waitFor(() => {
+ expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ });
+
+ const buttons = screen.getAllByRole('button');
+ const mapBtn = buttons.find(btn => btn.textContent && /^map$/i.test(btn.textContent.trim()));
+ expect(mapBtn).toBeUndefined();
+ });
+
+ // FE-PAGE-PUBLICJOURNEY-020 โ bug #826
+ it('FE-PAGE-PUBLICJOURNEY-020: mobile public share opens entry details on card click', async () => {
+ const user = userEvent.setup();
+ mockIsMobile.value = true;
+ setupSuccess();
+ render( );
+ await waitFor(() => {
+ expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
+ });
+
+ // The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked
+ const openBtn = screen.getByText('Open Entry');
+ await user.click(openBtn);
+
+ // MobileEntryView should slide in with the entry title
+ await waitFor(() => {
+ expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
+ });
+ });
+
// FE-PAGE-PUBLICJOURNEY-016
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
const user = userEvent.setup();
diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx
index b7a9772a..c006a384 100644
--- a/client/src/pages/JourneyPublicPage.tsx
+++ b/client/src/pages/JourneyPublicPage.tsx
@@ -14,6 +14,7 @@ import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody'
import PhotoLightbox from '../components/Journey/PhotoLightbox'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
+import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile'
import { formatLocationName } from '../utils/formatters'
import { DAY_COLORS } from '../components/Journey/dayColors'
@@ -44,6 +45,17 @@ interface PublicPhoto {
caption?: string | null
}
+interface PublicGalleryPhoto {
+ id: number
+ journey_id: number
+ photo_id: number
+ provider?: string
+ asset_id?: string | null
+ owner_id?: number | null
+ file_path?: string | null
+ caption?: string | null
+}
+
const MOOD_CONFIG: Record = {
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
@@ -60,7 +72,7 @@ const WEATHER_CONFIG: Record = {
cold: { icon: Snowflake, label: 'Cold' },
}
-function photoUrl(p: PublicPhoto, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
+function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
}
@@ -96,6 +108,7 @@ export default function JourneyPublicPage() {
const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef(null)
const [activeEntryId, setActiveEntryId] = useState(null)
+ const [viewingEntry, setViewingEntry] = useState(null)
const handleMarkerClick = useCallback((entryId: string) => {
setActiveEntryId(entryId)
@@ -113,21 +126,19 @@ export default function JourneyPublicPage() {
}, [token])
const entries = (data?.entries || []) as PublicEntry[]
+ const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
const perms = data?.permissions || {}
const journey = data?.journey || {}
const stats = data?.stats || {}
- const timelineEntries = useMemo(
- () => entries.filter(e => e.title !== '[Trip Photos]' && e.title !== 'Gallery'),
- [entries],
- )
+ const timelineEntries = useMemo(() => entries, [entries])
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo(
() => timelineEntries.filter(e => e.location_lat && e.location_lng),
[timelineEntries],
)
- const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
+ const allPhotos = gallery
// Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
@@ -189,7 +200,7 @@ export default function JourneyPublicPage() {
const availableViews = [
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
- !desktopTwoColumn && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
+ !desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
// Shared timeline renderer used in both layout modes
@@ -306,7 +317,7 @@ export default function JourneyPublicPage() {
)}
{/* Content */}
-
+
setViewingEntry(entry)}>
{/* Title (only when no single photo โ photo has it in overlay) */}
{photos.length !== 1 && entry.title && (
{entry.title}
@@ -402,11 +413,11 @@ export default function JourneyPublicPage() {
// Shared gallery renderer
const renderGallery = () => (
- {allPhotos.map(({ photo }, idx) => (
+ {allPhotos.map((photo, idx) => (
setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
+ onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
>
@@ -437,7 +448,7 @@ export default function JourneyPublicPage() {
return (
{/* Hero */}
-
+
{journey.cover_image && (
)}
@@ -499,7 +510,7 @@ export default function JourneyPublicPage() {
// โโ Desktop two-column: scrollable timeline feed + sticky map โโโโโโโโโโ
{/* Left: feed */}
-
+
{renderTabs(availableViews)}
{view === 'timeline' && perms.share_timeline && renderTimeline()}
{view === 'gallery' && perms.share_gallery && renderGallery()}
@@ -563,7 +574,7 @@ export default function JourneyPublicPage() {
mapEntries={sidebarMapItems as any}
dark={document.documentElement.classList.contains('dark')}
readOnly
- onEntryClick={() => {}}
+ onEntryClick={(entry) => setViewingEntry(entry as any)}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)"
/>
@@ -607,6 +618,26 @@ export default function JourneyPublicPage() {
onClose={() => setLightbox(null)}
/>
)}
+
+ {/* Mobile entry detail view (public share) */}
+ {viewingEntry && (
+ `/api/public/journey/${token}/photos/${photoId}/original`}
+ onClose={() => setViewingEntry(null)}
+ onEdit={() => {}}
+ onDelete={() => {}}
+ onPhotoClick={(photos, idx) => setLightbox({
+ photos: photos.map(p => ({
+ id: String(p.id),
+ src: photoUrl(p as any, token!, 'original'),
+ caption: (p as any).caption ?? null,
+ })),
+ index: idx,
+ })}
+ />
+ )}
)
}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index dd514b38..f4d3dde8 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -343,7 +343,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [tripId])
useEffect(() => {
- if (tripId) tripActions.loadReservations(tripId)
+ if (tripId) {
+ tripActions.loadReservations(tripId)
+ tripActions.loadBudgetItems?.(tripId)
+ }
}, [tripId])
useTripWebSocket(tripId)
@@ -1106,7 +1109,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left'
- ?
{ handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
+ ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
: { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
}
diff --git a/client/src/store/journeyStore.test.ts b/client/src/store/journeyStore.test.ts
index 564969ec..d5c7a2a3 100644
--- a/client/src/store/journeyStore.test.ts
+++ b/client/src/store/journeyStore.test.ts
@@ -355,6 +355,37 @@ describe('journeyStore', () => {
expect(useJourneyStore.getState().loading).toBe(false);
});
+ // โโ reorderEntries โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => {
+ const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 });
+ const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 });
+ const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 });
+ const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] });
+ useJourneyStore.setState({ current: detail });
+
+ server.use(
+ http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true }))
+ );
+ await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]);
+ const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
+ expect(ids).toEqual([202, 201, 203]);
+ });
+
+ it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => {
+ const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 });
+ const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 });
+ const detail = buildJourneyDetail({ id: 56, entries: [a, b] });
+ useJourneyStore.setState({ current: detail });
+
+ server.use(
+ http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 }))
+ );
+ await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy();
+ const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
+ expect(ids).toEqual([211, 212]);
+ });
+
// โโ clear โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
it('FE-STORE-JOURNEY-015: clear resets state', () => {
diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts
index 305d9511..c2edfa69 100644
--- a/client/src/store/journeyStore.ts
+++ b/client/src/store/journeyStore.ts
@@ -57,6 +57,24 @@ export interface JourneyPhoto {
height?: number | null
}
+export interface GalleryPhoto {
+ id: number
+ journey_id: number
+ photo_id: number
+ caption?: string | null
+ shared: number
+ sort_order: number
+ created_at: number
+ // Joined from trek_photos for display
+ provider?: string
+ asset_id?: string | null
+ owner_id?: number | null
+ file_path?: string | null
+ thumbnail_path?: string | null
+ width?: number | null
+ height?: number | null
+}
+
export interface JourneyTrip {
trip_id: number
added_at: number
@@ -79,6 +97,7 @@ export interface JourneyContributor {
export interface JourneyDetail extends Journey {
entries: JourneyEntry[]
+ gallery: GalleryPhoto[]
trips: JourneyTrip[]
contributors: JourneyContributor[]
stats: { entries: number; photos: number; places: number }
@@ -103,6 +122,9 @@ interface JourneyState {
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise
uploadPhotos: (entryId: number, formData: FormData) => Promise
+ uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise
+ unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise
+ deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise
deletePhoto: (photoId: number) => Promise
clear: () => void
@@ -201,10 +223,8 @@ export const useJourneyStore = create((set, get) => ({
)
entries.sort((a, b) => {
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
- const atime = a.entry_time || ''
- const btime = b.entry_time || ''
- if (atime !== btime) return atime.localeCompare(btime)
- return (a.sort_order || 0) - (b.sort_order || 0)
+ if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
+ return a.id - b.id
})
return { current: { ...s.current, entries } }
})
@@ -228,12 +248,55 @@ export const useJourneyStore = create((set, get) => ({
entries: s.current.entries.map(e =>
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
),
+ gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
},
}
})
return photos
},
+ uploadGalleryPhotos: async (journeyId, formData) => {
+ const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
+ const photos: GalleryPhoto[] = data.photos || []
+ set(s => {
+ if (!s.current || s.current.id !== journeyId) return s
+ return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
+ })
+ return photos
+ },
+
+ unlinkPhoto: async (entryId, journeyPhotoId) => {
+ await journeyApi.unlinkPhoto(entryId, journeyPhotoId)
+ set(s => {
+ if (!s.current) return s
+ return {
+ current: {
+ ...s.current,
+ entries: s.current.entries.map(e =>
+ e.id === entryId ? { ...e, photos: (e.photos || []).filter(p => p.id !== journeyPhotoId) } : e
+ ),
+ },
+ }
+ })
+ },
+
+ deleteGalleryPhoto: async (journeyId, journeyPhotoId) => {
+ await journeyApi.deleteGalleryPhoto(journeyId, journeyPhotoId)
+ set(s => {
+ if (!s.current) return s
+ return {
+ current: {
+ ...s.current,
+ gallery: (s.current.gallery || []).filter(p => p.id !== journeyPhotoId),
+ entries: s.current.entries.map(e => ({
+ ...e,
+ photos: (e.photos || []).filter(p => p.id !== journeyPhotoId),
+ })),
+ },
+ }
+ })
+ },
+
deletePhoto: async (photoId) => {
await journeyApi.deletePhoto(photoId)
set(s => {
diff --git a/client/src/utils/fileDownload.ts b/client/src/utils/fileDownload.ts
index b9904472..10e05fd0 100644
--- a/client/src/utils/fileDownload.ts
+++ b/client/src/utils/fileDownload.ts
@@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
}
+// navigator.standalone is true only on iOS when running as an
+// add-to-home-screen PWA. In that context, target="_blank" hands off to
+// Safari, which cannot access blob URLs sandboxed to the WebView.
+function isIosStandalone(): boolean {
+ return (navigator as any).standalone === true
+}
+
/**
* Fetches a protected file using cookie auth (credentials: include) and
* triggers a browser download. Works inside PWA standalone mode because the
@@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise click rather
+ * than window.open(). window.open() called with the "noreferrer"/"noopener"
+ * window feature returns null per spec, which previously made the popup-block
+ * fallback trigger a download in the *current* tab on top of the new-tab open
+ * โ i.e. the file opened twice. The anchor approach avoids that ambiguity:
+ * the new tab is opened by the browser's normal link-handling path, and no
+ * spurious in-page download is triggered.
*/
export async function openFile(url: string, filename?: string): Promise {
assertRelativeUrl(url)
@@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise {
return
}
- const win = window.open(blobUrl, '_blank', 'noreferrer')
- if (win) {
- setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
- } else {
- // Popup blocked โ fall back to download
+ // iOS PWA: target="_blank" would open Safari, which can't access the blob
+ if (isIosStandalone()) {
triggerAnchorDownload(blobUrl, filename)
+ return
}
+
+ const a = document.createElement('a')
+ a.href = blobUrl
+ a.target = '_blank'
+ a.rel = 'noopener noreferrer'
+ document.body.appendChild(a)
+ a.click()
+ // Keep the blob URL alive long enough for the new tab to load it, then
+ // clean up the DOM node and revoke the URL.
+ setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000)
}
diff --git a/client/tests/unit/utils/fileDownload.test.ts b/client/tests/unit/utils/fileDownload.test.ts
index 89632017..b5a8833c 100644
--- a/client/tests/unit/utils/fileDownload.test.ts
+++ b/client/tests/unit/utils/fileDownload.test.ts
@@ -74,32 +74,42 @@ describe('downloadFile', () => {
})
describe('openFile', () => {
- it('fetches with credentials:include and opens blob URL in new tab', async () => {
+ it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => {
vi.stubGlobal('fetch', makeFetchMock(200))
- const mockWin = { closed: false }
- const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window)
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await openFile('/uploads/files/doc.pdf')
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
expect(URL.createObjectURL).toHaveBeenCalled()
- expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer')
+ // Must NOT call window.open โ that path returns null when noreferrer is
+ // set, which previously caused the file to also open in the current tab.
+ expect(openSpy).not.toHaveBeenCalled()
+ expect(clickSpy).toHaveBeenCalledTimes(1)
+
+ // The anchor used to open the new tab must be target=_blank, must NOT
+ // carry a `download` attribute (otherwise it would download in-page
+ // instead of opening), and must use rel=noopener noreferrer.
+ const appendCalls = (document.body.appendChild as ReturnType).mock.calls
+ const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
+ expect(anchor.target).toBe('_blank')
+ expect(anchor.rel).toBe('noopener noreferrer')
+ expect(anchor.hasAttribute('download')).toBe(false)
// Revoke happens after 30s timeout
vi.runAllTimers()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
})
- it('falls back to anchor download when popup is blocked', async () => {
+ it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => {
vi.stubGlobal('fetch', makeFetchMock(200))
- vi.spyOn(window, 'open').mockReturnValue(null)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
- await openFile('/uploads/files/doc.pdf')
+ await openFile('/uploads/files/doc.pdf', 'doc.pdf')
- expect(clickSpy).toHaveBeenCalled()
- vi.runAllTimers()
- expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
+ // Exactly ONE anchor click โ opening the new tab. No fallback download.
+ expect(clickSpy).toHaveBeenCalledTimes(1)
})
it('throws on 401 response', async () => {
@@ -108,28 +118,55 @@ describe('openFile', () => {
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
- it('forces download for unsafe MIME types (HTML, SVG) instead of opening inline', async () => {
+ it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => {
const htmlBlob = new Blob([''], { type: 'text/html' })
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
- await openFile('/uploads/files/malicious.html')
+ await openFile('/uploads/files/malicious.html', 'malicious.html')
// Must NOT open inline โ download anchor clicked instead
expect(openSpy).not.toHaveBeenCalled()
- expect(clickSpy).toHaveBeenCalled()
+ expect(clickSpy).toHaveBeenCalledTimes(1)
+
+ const appendCalls = (document.body.appendChild as ReturnType).mock.calls
+ const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
+ expect(anchor.download).toBe('malicious.html')
})
it('forces download for SVG MIME type', async () => {
const svgBlob = new Blob([' '], { type: 'image/svg+xml' })
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
- vi.spyOn(window, 'open').mockReturnValue({} as Window)
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
await openFile('/uploads/files/malicious.svg')
- expect(window.open).not.toHaveBeenCalled()
- expect(clickSpy).toHaveBeenCalled()
+ expect(openSpy).not.toHaveBeenCalled()
+ expect(clickSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('falls back to download in iOS PWA standalone mode (blob URL inaccessible to Safari)', async () => {
+ vi.stubGlobal('fetch', makeFetchMock(200))
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+ // Simulate iOS PWA (Add-to-Home-Screen) context
+ Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
+
+ try {
+ await openFile('/uploads/files/doc.pdf', 'doc.pdf')
+
+ // Single anchor click โ and it must be a DOWNLOAD anchor (no target=_blank),
+ // because target="_blank" in iOS PWA would hand off to Safari which cannot
+ // read the in-WebView blob URL.
+ expect(clickSpy).toHaveBeenCalledTimes(1)
+ const appendCalls = (document.body.appendChild as ReturnType).mock.calls
+ const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
+ expect(anchor.target).toBe('')
+ expect(anchor.download).toBe('doc.pdf')
+ } finally {
+ // Clean up the non-standard iOS-only property we forced above.
+ delete (navigator as any).standalone
+ }
})
})
diff --git a/docker-compose.yml b/docker-compose.yml
index e0d84418..a72cbecd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,6 +24,7 @@ services:
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
+# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
# - 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.
diff --git a/server/.env.example b/server/.env.example
index ba9da901..7fb96267 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -13,6 +13,7 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy.
+# HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP.
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.
diff --git a/server/package-lock.json b/server/package-lock.json
index 82696908..0c011b40 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "trek-server",
- "version": "2.9.14",
+ "version": "3.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
- "version": "2.9.14",
+ "version": "3.0.9",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
@@ -18,6 +18,7 @@
"express": "^4.18.3",
"fast-xml-parser": "^5.5.10",
"helmet": "^8.1.0",
+ "jimp": "^1.6.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
@@ -29,7 +30,7 @@
"typescript": "^6.0.2",
"undici": "^7.0.0",
"unzipper": "^0.12.3",
- "uuid": "^9.0.0",
+ "uuid": "^14.0.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
},
@@ -132,6 +133,16 @@
"node": ">=18"
}
},
+ "node_modules/@borewit/text-codec": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
+ "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -673,6 +684,583 @@
"node": ">=8"
}
},
+ "node_modules/@jimp/core": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz",
+ "integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/file-ops": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "await-to-js": "^3.0.0",
+ "exif-parser": "^0.1.12",
+ "file-type": "^21.3.3",
+ "mime": "3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/core/node_modules/mime": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
+ "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/@jimp/diff": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz",
+ "integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/plugin-resize": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "pixelmatch": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/file-ops": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz",
+ "integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/js-bmp": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz",
+ "integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "bmp-ts": "^1.0.9"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/js-gif": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz",
+ "integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "gifwrap": "^0.10.1",
+ "omggif": "^1.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/js-jpeg": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz",
+ "integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "jpeg-js": "^0.4.4"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/js-png": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz",
+ "integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "pngjs": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/js-png/node_modules/pngjs": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
+ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.19.0"
+ }
+ },
+ "node_modules/@jimp/js-tiff": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz",
+ "integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "utif2": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-blit": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz",
+ "integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-blit/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-blur": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz",
+ "integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/utils": "1.6.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-circle": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz",
+ "integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/types": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-circle/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-color": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz",
+ "integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "tinycolor2": "^1.6.0",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-color/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-contain": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz",
+ "integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/plugin-blit": "1.6.1",
+ "@jimp/plugin-resize": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-contain/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-cover": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz",
+ "integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/plugin-crop": "1.6.1",
+ "@jimp/plugin-resize": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-cover/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-crop": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz",
+ "integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-crop/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-displace": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz",
+ "integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-displace/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-dither": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz",
+ "integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/types": "1.6.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-fisheye": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz",
+ "integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-fisheye/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-flip": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz",
+ "integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/types": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-flip/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-hash": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz",
+ "integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/js-bmp": "1.6.1",
+ "@jimp/js-jpeg": "1.6.1",
+ "@jimp/js-png": "1.6.1",
+ "@jimp/js-tiff": "1.6.1",
+ "@jimp/plugin-color": "1.6.1",
+ "@jimp/plugin-resize": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "any-base": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-mask": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz",
+ "integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/types": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-mask/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-print": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz",
+ "integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/js-jpeg": "1.6.1",
+ "@jimp/js-png": "1.6.1",
+ "@jimp/plugin-blit": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "parse-bmfont-ascii": "^1.0.6",
+ "parse-bmfont-binary": "^1.0.6",
+ "parse-bmfont-xml": "^1.1.6",
+ "simple-xml-to-json": "^1.2.2",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-print/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-quantize": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz",
+ "integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==",
+ "license": "MIT",
+ "dependencies": {
+ "image-q": "^4.0.0",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-quantize/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-resize": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz",
+ "integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-resize/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-rotate": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz",
+ "integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/plugin-crop": "1.6.1",
+ "@jimp/plugin-resize": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-rotate/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/plugin-threshold": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz",
+ "integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/plugin-color": "1.6.1",
+ "@jimp/plugin-hash": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1",
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/plugin-threshold/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/types": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz",
+ "integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==",
+ "license": "MIT",
+ "dependencies": {
+ "zod": "^3.23.8"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jimp/types/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@jimp/utils": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz",
+ "integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/types": "1.6.1",
+ "tinycolor2": "^1.6.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1027,6 +1615,18 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@nodable/entities": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
+ "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodable"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
@@ -1448,6 +2048,29 @@
"win32"
]
},
+ "node_modules/@tokenizer/inflate": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
+ "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "token-types": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/@tokenizer/token": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
+ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
+ "license": "MIT"
+ },
"node_modules/@types/archiver": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz",
@@ -1994,6 +2617,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/any-base": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz",
+ "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==",
+ "license": "MIT"
+ },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -2097,6 +2726,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/await-to-js": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz",
+ "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/b4a": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
@@ -2287,6 +2925,12 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"license": "MIT"
},
+ "node_modules/bmp-ts": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz",
+ "integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==",
+ "license": "MIT"
+ },
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -3109,6 +3753,11 @@
"node": ">=18.0.0"
}
},
+ "node_modules/exif-parser": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
+ "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
+ },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -3243,9 +3892,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
- "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
+ "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
"funding": [
{
"type": "github",
@@ -3258,9 +3907,9 @@
}
},
"node_modules/fast-xml-parser": {
- "version": "5.5.12",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.12.tgz",
- "integrity": "sha512-nUR0q8PPfoA/svPM43Gup7vLOZWppaNrYgGmrVqrAVJa7cOH4hMG6FX9M4mQ8dZA1/ObGZHzES7Ed88hxEBSJg==",
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz",
+ "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==",
"funding": [
{
"type": "github",
@@ -3269,7 +3918,8 @@
],
"license": "MIT",
"dependencies": {
- "fast-xml-builder": "^1.1.4",
+ "@nodable/entities": "^2.1.0",
+ "fast-xml-builder": "^1.1.5",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.2.3"
},
@@ -3277,6 +3927,24 @@
"fxparser": "src/cli/cli.js"
}
},
+ "node_modules/file-type": {
+ "version": "21.3.4",
+ "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
+ "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/inflate": "^0.4.1",
+ "strtok3": "^10.3.4",
+ "token-types": "^6.1.1",
+ "uint8array-extras": "^1.4.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/file-type?sponsor=1"
+ }
+ },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -3519,6 +4187,16 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
+ "node_modules/gifwrap": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz",
+ "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==",
+ "license": "MIT",
+ "dependencies": {
+ "image-q": "^4.0.0",
+ "omggif": "^1.0.10"
+ }
+ },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
@@ -3710,6 +4388,21 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/image-q": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz",
+ "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "16.9.1"
+ }
+ },
+ "node_modules/image-q/node_modules/@types/node": {
+ "version": "16.9.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz",
+ "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
+ "license": "MIT"
+ },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -3894,6 +4587,44 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
+ "node_modules/jimp": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz",
+ "integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jimp/core": "1.6.1",
+ "@jimp/diff": "1.6.1",
+ "@jimp/js-bmp": "1.6.1",
+ "@jimp/js-gif": "1.6.1",
+ "@jimp/js-jpeg": "1.6.1",
+ "@jimp/js-png": "1.6.1",
+ "@jimp/js-tiff": "1.6.1",
+ "@jimp/plugin-blit": "1.6.1",
+ "@jimp/plugin-blur": "1.6.1",
+ "@jimp/plugin-circle": "1.6.1",
+ "@jimp/plugin-color": "1.6.1",
+ "@jimp/plugin-contain": "1.6.1",
+ "@jimp/plugin-cover": "1.6.1",
+ "@jimp/plugin-crop": "1.6.1",
+ "@jimp/plugin-displace": "1.6.1",
+ "@jimp/plugin-dither": "1.6.1",
+ "@jimp/plugin-fisheye": "1.6.1",
+ "@jimp/plugin-flip": "1.6.1",
+ "@jimp/plugin-hash": "1.6.1",
+ "@jimp/plugin-mask": "1.6.1",
+ "@jimp/plugin-print": "1.6.1",
+ "@jimp/plugin-quantize": "1.6.1",
+ "@jimp/plugin-resize": "1.6.1",
+ "@jimp/plugin-rotate": "1.6.1",
+ "@jimp/plugin-threshold": "1.6.1",
+ "@jimp/types": "1.6.1",
+ "@jimp/utils": "1.6.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
@@ -3903,6 +4634,12 @@
"url": "https://github.com/sponsors/panva"
}
},
+ "node_modules/jpeg-js": {
+ "version": "0.4.4",
+ "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
+ "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
@@ -4465,6 +5202,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/omggif": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
+ "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
+ "license": "MIT"
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -4540,6 +5283,34 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/parse-bmfont-ascii": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz",
+ "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==",
+ "license": "MIT"
+ },
+ "node_modules/parse-bmfont-binary": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz",
+ "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==",
+ "license": "MIT"
+ },
+ "node_modules/parse-bmfont-xml": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz",
+ "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==",
+ "license": "MIT",
+ "dependencies": {
+ "xml-parse-from-string": "^1.0.0",
+ "xml2js": "^0.5.0"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -4642,6 +5413,27 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pixelmatch": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz",
+ "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==",
+ "license": "ISC",
+ "dependencies": {
+ "pngjs": "^6.0.0"
+ },
+ "bin": {
+ "pixelmatch": "bin/pixelmatch"
+ }
+ },
+ "node_modules/pixelmatch/node_modules/pngjs": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
+ "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.13.0"
+ }
+ },
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
@@ -4661,9 +5453,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.9",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
- "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
@@ -5005,6 +5797,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/sax": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
+ "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -5254,6 +6055,15 @@
"node": ">=10"
}
},
+ "node_modules/simple-xml-to-json": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz",
+ "integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.12.2"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -5412,6 +6222,22 @@
],
"license": "MIT"
},
+ "node_modules/strtok3": {
+ "version": "10.3.5",
+ "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
+ "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/token": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
@@ -5649,6 +6475,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tinycolor2": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
+ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
+ "license": "MIT"
+ },
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
@@ -5756,6 +6588,24 @@
"node": ">=0.6"
}
},
+ "node_modules/token-types": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
+ "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@borewit/text-codec": "^0.2.1",
+ "@tokenizer/token": "^0.3.0",
+ "ieee754": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
"node_modules/touch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
@@ -5836,6 +6686,18 @@
"dev": true,
"license": "CC0-1.0"
},
+ "node_modules/uint8array-extras": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
+ "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -5890,6 +6752,15 @@
"node-int64": "^0.4.0"
}
},
+ "node_modules/utif2": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz",
+ "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.11"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -5906,16 +6777,16 @@
}
},
"node_modules/uuid": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
- "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
+ "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
- "uuid": "dist/bin/uuid"
+ "uuid": "dist-node/bin/uuid"
}
},
"node_modules/vary": {
@@ -6240,6 +7111,34 @@
}
}
},
+ "node_modules/xml-parse-from-string": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
+ "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==",
+ "license": "MIT"
+ },
+ "node_modules/xml2js": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
+ "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
diff --git a/server/package.json b/server/package.json
index 197aac87..6b8f49ce 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "trek-server",
- "version": "2.9.14",
+ "version": "3.0.9",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
@@ -23,6 +23,7 @@
"express": "^4.18.3",
"fast-xml-parser": "^5.5.10",
"helmet": "^8.1.0",
+ "jimp": "^1.6.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^4.2.1",
@@ -34,7 +35,7 @@
"typescript": "^6.0.2",
"undici": "^7.0.0",
"unzipper": "^0.12.3",
- "uuid": "^9.0.0",
+ "uuid": "^14.0.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
},
diff --git a/server/src/app.ts b/server/src/app.ts
index bae6a820..d21c0d3c 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -124,6 +124,7 @@ export function createApp(): express.Application {
},
crossOriginEmbedderPolicy: false,
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
if (shouldForceHttps) {
@@ -372,8 +373,10 @@ export function createApp(): express.Application {
} else {
console.error('Unhandled error:', err);
}
- const status = err.statusCode || 500;
- res.status(status).json({ error: 'Internal server error' });
+ const status = err.statusCode || err.status || 500;
+ // Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
+ const message = status < 500 ? err.message : 'Internal server error';
+ res.status(status).json({ error: message });
});
return app;
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 91a2c203..29640339 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -1946,6 +1946,190 @@ function runMigrations(db: Database.Database): void {
)
`);
},
+ // Migration 121: Journey gallery refactor โ decouple photo ownership from
+ // entries. journey_photos becomes a per-journey gallery (one row per unique
+ // photo per journey). A new junction table journey_entry_photos links
+ // gallery photos to the entries that reference them, allowing the same
+ // photo to appear in multiple entries without duplication. Synthetic
+ // wrapper entries ('Gallery', '[Trip Photos]') created by the old model
+ // are removed โ the gallery table replaces them.
+ () => {
+ const hasOld = db.prepare(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'"
+ ).get();
+ const hasBackup = db.prepare(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos_old'"
+ ).get();
+ if (hasOld && !hasBackup) {
+ db.exec('ALTER TABLE journey_photos RENAME TO journey_photos_old');
+ }
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS journey_photos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
+ photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
+ caption TEXT,
+ shared INTEGER DEFAULT 0,
+ sort_order INTEGER DEFAULT 0,
+ provider TEXT,
+ asset_id TEXT,
+ owner_id INTEGER,
+ created_at INTEGER NOT NULL,
+ UNIQUE(journey_id, photo_id)
+ )
+ `);
+
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS journey_entry_photos (
+ entry_id INTEGER NOT NULL REFERENCES journey_entries(id) ON DELETE CASCADE,
+ journey_photo_id INTEGER NOT NULL REFERENCES journey_photos(id) ON DELETE CASCADE,
+ sort_order INTEGER DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ PRIMARY KEY(entry_id, journey_photo_id)
+ )
+ `);
+
+ if (hasOld || hasBackup) {
+ // Backfill gallery: deduplicate by (journey_id, photo_id), keeping
+ // the earliest row (MIN(id) = earliest created_at on AUTOINCREMENT).
+ db.exec(`
+ INSERT OR IGNORE INTO journey_photos
+ (journey_id, photo_id, caption, shared, sort_order, created_at)
+ SELECT
+ je.journey_id,
+ jpo.photo_id,
+ jpo.caption,
+ jpo.shared,
+ jpo.sort_order,
+ jpo.created_at
+ FROM journey_photos_old jpo
+ JOIN journey_entries je ON je.id = jpo.entry_id
+ WHERE jpo.id IN (
+ SELECT MIN(jpo2.id)
+ FROM journey_photos_old jpo2
+ JOIN journey_entries je2 ON je2.id = jpo2.entry_id
+ GROUP BY je2.journey_id, jpo2.photo_id
+ )
+ `);
+
+ // Backfill junction: one row per (entry_id, photo_id), resolved to
+ // the new gallery ids.
+ db.exec(`
+ INSERT OR IGNORE INTO journey_entry_photos
+ (entry_id, journey_photo_id, sort_order, created_at)
+ SELECT
+ jpo.entry_id,
+ jp.id,
+ jpo.sort_order,
+ jpo.created_at
+ FROM journey_photos_old jpo
+ JOIN journey_entries je ON je.id = jpo.entry_id
+ JOIN journey_photos jp
+ ON jp.journey_id = je.journey_id
+ AND jp.photo_id = jpo.photo_id
+ `);
+
+ db.exec('DROP TABLE journey_photos_old');
+ }
+
+ // Remove synthetic wrapper entries replaced by the gallery model.
+ // ON DELETE CASCADE on journey_entry_photos cleans up junction rows.
+ db.prepare(
+ "DELETE FROM journey_entries WHERE title IN ('Gallery', '[Trip Photos]')"
+ ).run();
+
+ db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)');
+ 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)
+ `);
+ },
+ // #846: make sort_order authoritative within a day. Previous ORDER BY put
+ // entry_time before sort_order, silently ignoring reorder clicks when two
+ // same-date entries had different times. Backfill renumbers using the old
+ // effective key (entry_time ASC, id ASC) so existing journeys retain their
+ // current visual order.
+ () => {
+ db.exec(`
+ WITH ranked AS (
+ SELECT id,
+ ROW_NUMBER() OVER (
+ PARTITION BY journey_id, entry_date
+ ORDER BY entry_time ASC, id ASC
+ ) - 1 AS rn
+ FROM journey_entries
+ )
+ UPDATE journey_entries
+ SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id)
+ `);
+ db.exec(
+ 'CREATE INDEX IF NOT EXISTS idx_journey_entries_order ' +
+ 'ON journey_entries(journey_id, entry_date, sort_order)'
+ );
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts
index 6433ff45..1336bd50 100644
--- a/server/src/routes/journey.ts
+++ b/server/src/routes/journey.ts
@@ -9,6 +9,7 @@ import * as svc from '../services/journeyService';
import { db } from '../db/database';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService';
+import { getAllowedExtensions } from '../services/fileService';
const router = express.Router();
@@ -25,9 +26,26 @@ const storage = multer.diskStorage({
},
});
+const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
+ if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
+ const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
+ err.statusCode = 400;
+ return cb(err);
+ }
+ const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
+ const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
+ if (!allowed.includes('*') && !allowed.includes(ext)) {
+ const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
+ err.statusCode = 400;
+ return cb(err);
+ }
+ cb(null, true);
+};
+
const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 },
+ fileFilter: imageFilter,
});
// โโ Static prefix routes (MUST come before /:id) โโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -104,10 +122,11 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
try {
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
if (immichId) {
+ // photo.id is now the gallery photo id (journey_photos.id)
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
- photo.provider = 'immich' as any;
- photo.asset_id = immichId;
- photo.owner_id = authReq.user.id;
+ (photo as any).provider = 'immich';
+ (photo as any).asset_id = immichId;
+ (photo as any).owner_id = authReq.user.id;
}
} catch {}
}
@@ -141,16 +160,25 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
res.status(201).json(photo);
});
-// Link an existing photo to a (different) entry
+// Link a gallery photo to an entry
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
- const { photo_id } = req.body || {};
- if (!photo_id) return res.status(400).json({ error: 'photo_id required' });
- const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(photo_id), authReq.user.id);
+ // Accept journey_photo_id (new) or photo_id (legacy alias) for backwards compat
+ const journeyPhotoId = (req.body || {}).journey_photo_id ?? (req.body || {}).photo_id;
+ if (!journeyPhotoId) return res.status(400).json({ error: 'journey_photo_id required' });
+ const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(journeyPhotoId), authReq.user.id);
if (!result) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json(result);
});
+// Unlink a photo from a specific entry (gallery row is preserved)
+router.delete('/entries/:entryId/photos/:journeyPhotoId', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const ok = svc.unlinkPhotoFromEntry(Number(req.params.entryId), Number(req.params.journeyPhotoId), authReq.user.id);
+ if (!ok) return res.status(404).json({ error: 'Not found or not allowed' });
+ res.status(204).end();
+});
+
router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
@@ -158,34 +186,65 @@ router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) =>
res.json(result);
});
+// Hard-delete: removes gallery row + cascades to all entry links + deletes file if unreferenced
router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
if (!photo) return res.status(404).json({ error: 'Photo not found' });
- // delete local file
if (photo.file_path) {
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
try { fs.unlinkSync(fullPath); } catch {}
}
- // only delete from Immich if the photo was UPLOADED through TREK (has local file)
- // photos imported from Immich (no file_path) are just references โ don't touch Immich
- if (photo.provider === 'immich' && photo.asset_id && photo.file_path) {
- try {
- const { getImmichCredentials } = await import('../services/memories/immichService');
- const creds = getImmichCredentials(authReq.user.id);
- if (creds) {
- const { safeFetch } = await import('../utils/ssrfGuard');
- await safeFetch(`${creds.immich_url}/api/assets`, {
- method: 'DELETE',
- headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
- body: JSON.stringify({ ids: [photo.asset_id] }),
- });
- }
- } catch {}
- }
res.json({ success: true });
});
+// โโ Gallery (prefix /:id/gallery โ before /:id) โโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+// Upload photos directly to the journey gallery (no entry association)
+router.post('/:id/gallery/photos', authenticate, upload.array('photos', 20), async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const files = req.files as Express.Multer.File[];
+ if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
+
+ const filePaths = files.map(f => ({ path: `journey/${f.filename}` }));
+ const photos = svc.uploadGalleryPhotos(Number(req.params.id), authReq.user.id, filePaths);
+ if (!photos.length) return res.status(403).json({ error: 'Not allowed' });
+ res.status(201).json({ photos });
+});
+
+// Add provider photos to gallery only (no entry link)
+router.post('/:id/gallery/provider-photos', authenticate, (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const { provider, asset_id, asset_ids, passphrase } = req.body || {};
+ const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
+
+ if (Array.isArray(asset_ids) && provider) {
+ const added: any[] = [];
+ for (const id of asset_ids) {
+ const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, String(id), undefined, pp);
+ if (photo) added.push(photo);
+ }
+ return res.status(201).json({ photos: added, added: added.length });
+ }
+
+ if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
+ const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, asset_id, undefined, pp);
+ if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
+ res.status(201).json(photo);
+});
+
+// Hard-delete a gallery photo (removes from all entries)
+router.delete('/:id/gallery/:journeyPhotoId', authenticate, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ const photo = svc.deleteGalleryPhoto(Number(req.params.journeyPhotoId), authReq.user.id);
+ if (!photo) return res.status(404).json({ error: 'Photo not found or not allowed' });
+ if (photo.file_path) {
+ const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
+ try { fs.unlinkSync(fullPath); } catch {}
+ }
+ res.status(204).end();
+});
+
// โโ Journeys /:id (parameterized routes AFTER static prefixes) โโโโโโโโโโโ
router.get('/:id', authenticate, (req: Request, res: Response) => {
diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts
index dcd21a78..4a86a340 100644
--- a/server/src/routes/oidc.ts
+++ b/server/src/routes/oidc.ts
@@ -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';
diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts
index 1a1c91aa..afa6c7b3 100644
--- a/server/src/services/journeyService.ts
+++ b/server/src/services/journeyService.ts
@@ -7,12 +7,22 @@ function ts(): number {
return Date.now();
}
-// Joined SELECT for journey_photos + trek_photos โ returns fields matching JourneyPhoto interface
+// Per-entry photo view: join journey_entry_photos โ journey_photos (gallery) โ trek_photos.
+// id = gp.id (gallery photo id) โ used by clients for linkPhoto/updatePhoto/unlink/delete.
const JP_SELECT = `
- jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
- tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
+ gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
+ tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
`;
-const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id';
+const JP_JOIN = `journey_entry_photos jep
+ JOIN journey_photos gp ON gp.id = jep.journey_photo_id
+ JOIN trek_photos tp ON tp.id = gp.photo_id`;
+
+// Per-journey gallery view: journey_photos โ trek_photos (no entry context).
+const GALLERY_SELECT = `
+ gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
+ tp.provider, tp.asset_id, tp.owner_id, tp.file_path, tp.thumbnail_path, tp.width, tp.height
+`;
+const GALLERY_JOIN = 'journey_photos gp JOIN trek_photos tp ON tp.id = gp.photo_id';
function broadcastJourneyEvent(journeyId: number, event: string, data: Record, excludeSocketId?: string | number) {
const contributors = db.prepare(
@@ -58,7 +68,7 @@ export function listJourneys(userId: number) {
return db.prepare(`
SELECT DISTINCT j.*,
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
- (SELECT COUNT(DISTINCT jp.photo_id) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
+ (SELECT COUNT(*) FROM journey_photos jp WHERE jp.journey_id = j.id) as photo_count,
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
@@ -110,11 +120,11 @@ export function getJourneyFull(journeyId: number, userId: number) {
if (!journey) return null;
const entries = db.prepare(
- 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
+ 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
- `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
+ `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`
).all(journeyId) as JourneyPhoto[];
// group photos by entry
@@ -123,12 +133,11 @@ export function getJourneyFull(journeyId: number, userId: number) {
(photosByEntry[p.entry_id] ||= []).push(p);
}
+ const gallery = db.prepare(
+ `SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? ORDER BY gp.sort_order ASC, gp.id ASC`
+ ).all(journeyId);
+
const enrichedEntries = entries
- .filter(e => {
- // hide empty Gallery entries (no photos, no story)
- if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
- return true;
- })
.map(e => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
@@ -160,7 +169,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
// stats
const entryCount = entries.filter(e => e.type === 'entry').length;
- const photoCount = new Set(photos.map(p => p.photo_id)).size;
+ const photoCount = (gallery as any[]).length;
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare(
@@ -183,6 +192,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
return {
...journey,
entries: enrichedEntries,
+ gallery,
trips,
contributors,
stats: { entries: entryCount, photos: photoCount, places: places.length },
@@ -296,12 +306,21 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
).all(journeyId, tripId) as { source_place_id: number }[];
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
+ // Track next sort_order per date so synced skeletons get unique, sequential positions.
+ const dateMaxOrder = new Map();
+ const maxRows = db.prepare(
+ 'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date'
+ ).all(journeyId) as { entry_date: string; m: number }[];
+ for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m);
+
for (const place of places) {
if (existingPlaceIds.has(place.id)) continue;
existingPlaceIds.add(place.id);
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
const entryTime = place.assignment_time || place.place_time || null;
+ const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
+ dateMaxOrder.set(entryDate, nextOrder);
db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
@@ -310,51 +329,27 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
journeyId, tripId, place.id, authorId,
place.name, entryDate, entryTime,
place.address || place.name, place.lat || null, place.lng || null,
- place.day_number || 0, now, now
+ nextOrder, now, now
);
}
}
-// import trip_photos into journey when a trip is linked
+// import trip_photos into journey gallery when a trip is linked
function syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = db.prepare(
- 'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
- ).all(tripId) as { photo_id: number; user_id: number; shared: number }[];
+ 'SELECT tp.photo_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
+ ).all(tripId) as { photo_id: number; shared: number }[];
if (!tripPhotos.length) return;
const now = ts();
+ const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
+ let nextOrder = (maxOrderRow?.m ?? -1) + 1;
- // find or create a "Photos" entry for this trip's photos
- let photoEntry = db.prepare(`
- SELECT id FROM journey_entries
- WHERE journey_id = ? AND source_trip_id = ? AND title = '[Trip Photos]' AND type = 'entry'
- `).get(journeyId, tripId) as { id: number } | undefined;
-
- if (!photoEntry) {
- const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
- const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
- const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
-
- const res = db.prepare(`
- INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
- VALUES (?, ?, ?, 'entry', '[Trip Photos]', ?, 999, ?, ?)
- `).run(journeyId, tripId, owner.user_id, entryDate, now, now);
- photoEntry = { id: Number(res.lastInsertRowid) };
- }
-
- // import each trip photo, skip duplicates (by photo_id)
for (const tp of tripPhotos) {
- const exists = db.prepare(
- 'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?'
- ).get(photoEntry.id, tp.photo_id);
- if (exists) continue;
-
- const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
-
db.prepare(`
- INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at)
+ INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
- `).run(photoEntry.id, tp.photo_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
+ `).run(journeyId, tp.photo_id, tp.shared, nextOrder++, now);
}
}
@@ -381,15 +376,19 @@ export function onPlaceCreated(tripId: number, placeId: number) {
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
const entryDate = place.day_date;
+ const maxOrder = db.prepare(
+ 'SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
+ ).get(link.journey_id, entryDate) as { m: number | null };
+ const nextOrder = (maxOrder?.m ?? -1) + 1;
db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
- VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
+ VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
link.journey_id, tripId, placeId, journey.user_id,
place.name, entryDate, place.assignment_time || place.place_time || null,
place.address || place.name, place.lat || null, place.lng || null,
- now, now
+ nextOrder, now, now
);
}
}
@@ -444,7 +443,7 @@ export function onPlaceDeleted(placeId: number) {
for (const entry of entries) {
if (entry.type === 'skeleton') {
// no content: just delete
- const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entry.id);
+ const hasPhotos = db.prepare('SELECT 1 FROM journey_entry_photos WHERE entry_id = ?').get(entry.id);
if (!hasPhotos && !entry.story) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
continue;
@@ -465,11 +464,11 @@ export function listEntries(journeyId: number, userId: number) {
if (!canAccessJourney(journeyId, userId)) return null;
const entries = db.prepare(
- 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
+ 'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
- `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
+ `SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jep.sort_order ASC`
).all(journeyId) as JourneyPhoto[];
const photosByEntry: Record = {};
@@ -628,9 +627,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
if (!entry) return false;
if (!canEdit(entry.journey_id, userId)) return false;
- // delete photos along with the entry โ no more orphan Gallery entries
- db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId);
-
if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') {
// Revert filled entry back to skeleton instead of deleting
db.prepare(`
@@ -645,12 +641,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
}
- // clean up any empty Gallery entries in this journey
- db.prepare(`
- DELETE FROM journey_entries WHERE journey_id = ? AND title = 'Gallery'
- AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
- `).run(entry.journey_id);
-
return true;
}
@@ -664,23 +654,40 @@ function promoteSkeletonIfNeeded(entry: JourneyEntry): void {
db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id);
}
+// Ensure a trek_photo_id is in the journey gallery; return its gallery row id.
+function ensureInGallery(journeyId: number, trekPhotoId: number, caption?: string, shared?: number): number {
+ const now = ts();
+ const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
+ db.prepare(`
+ INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, shared, sort_order, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(journeyId, trekPhotoId, caption || null, shared ?? 0, (maxOrderRow?.m ?? -1) + 1, now);
+ const row = db.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekPhotoId) as { id: number };
+ return row.id;
+}
+
+// Link a gallery photo to an entry (idempotent). Returns the junction JP_SELECT row.
+function linkGalleryPhotoToEntry(galleryId: number, entryId: number): JourneyPhoto | null {
+ const now = ts();
+ const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_entry_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
+ db.prepare(`
+ INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
+ VALUES (?, ?, ?, ?)
+ `).run(entryId, galleryId, (maxOrderRow?.m ?? -1) + 1, now);
+ return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jep.entry_id = ? AND jep.journey_photo_id = ?`)
+ .get(entryId, galleryId) as JourneyPhoto | null;
+}
+
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath);
- const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
- const now = ts();
-
- const res = db.prepare(`
- INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
- VALUES (?, ?, ?, ?, ?)
- `).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
-
+ const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))();
+ const result = linkGalleryPhotoToEntry(galleryId, entryId);
promoteSkeletonIfNeeded(entry);
-
- return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
+ return result;
}
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
@@ -690,119 +697,127 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
- // skip if already added
- const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
- if (exists) return null;
-
- const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
- const now = ts();
-
- const res = db.prepare(`
- INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
- VALUES (?, ?, ?, ?, ?)
- `).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
+ // skip if this photo is already linked to this entry
+ const alreadyLinked = db.prepare(`
+ SELECT 1 FROM journey_entry_photos jep
+ JOIN journey_photos gp ON gp.id = jep.journey_photo_id
+ WHERE jep.entry_id = ? AND gp.photo_id = ?
+ `).get(entryId, trekPhotoId);
+ if (alreadyLinked) return null;
+ const galleryId = db.transaction(() => ensureInGallery(entry.journey_id, trekPhotoId, caption))();
+ const result = linkGalleryPhotoToEntry(galleryId, entryId);
promoteSkeletonIfNeeded(entry);
-
- return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
+ return result;
}
-export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null {
+// Link a gallery photo (by its journey_photos.id) to an entry โ idempotent.
+export function linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
- const source = db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto | undefined;
- if (!source) return null;
-
- if (source.entry_id === entryId) return source;
-
- const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(source.entry_id) as JourneyEntry | undefined;
- const sourceIsGallery = oldEntry?.title === 'Gallery';
-
- // skip if target already has this photo (by trek_photo_id)
- const dupe = db.prepare('SELECT id FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, source.photo_id) as { id: number } | undefined;
- if (dupe) return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(dupe.id) as JourneyPhoto;
-
- const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
- let resultId: number;
-
- if (sourceIsGallery) {
- // Copy so the photo stays in the gallery even after being used in an entry.
- const res = db.prepare(`
- INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
- VALUES (?, ?, ?, ?, ?)
- `).run(entryId, source.photo_id, source.caption || null, (maxOrder?.m ?? -1) + 1, ts());
- resultId = Number(res.lastInsertRowid);
- } else {
- // Non-gallery source: keep existing move behavior.
- db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
- resultId = photoId;
- }
+ // Verify the gallery photo belongs to this journey
+ const galleryRow = db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(journeyPhotoId) as { id: number; journey_id: number } | undefined;
+ if (!galleryRow || galleryRow.journey_id !== entry.journey_id) return null;
+ const result = linkGalleryPhotoToEntry(galleryRow.id, entryId);
promoteSkeletonIfNeeded(entry);
+ return result;
+}
- // If we moved out of a Gallery entry (shouldn't happen with the guard above,
- // but kept for any legacy data), clean up the Gallery wrapper if emptied.
- if (!sourceIsGallery && oldEntry && oldEntry.title === 'Gallery') {
- const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(source.entry_id) as { c: number };
- if (remaining.c === 0) {
- db.prepare('DELETE FROM journey_entries WHERE id = ?').run(source.entry_id);
- }
+// Upload photos to the journey gallery only (no entry association).
+export function uploadGalleryPhotos(journeyId: number, userId: number, filePaths: { path: string; thumbnail?: string }[]): JourneyPhoto[] {
+ if (!canEdit(journeyId, userId)) return [];
+ const results: any[] = [];
+ const now = ts();
+ const maxOrderRow = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE journey_id = ?').get(journeyId) as { m: number | null };
+ let nextOrder = (maxOrderRow?.m ?? -1) + 1;
+
+ for (const f of filePaths) {
+ const trekPhotoId = getOrCreateLocalTrekPhoto(f.path, f.thumbnail);
+ db.prepare(`
+ INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at)
+ VALUES (?, ?, 0, ?, ?)
+ `).run(journeyId, trekPhotoId, nextOrder++, now);
+ const row = db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.journey_id = ? AND gp.photo_id = ?`).get(journeyId, trekPhotoId);
+ if (row) results.push(row);
}
+ return results;
+}
- return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(resultId) as JourneyPhoto;
+// Add a provider photo to the gallery only (no entry link).
+export function addProviderPhotoToGallery(journeyId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): any | null {
+ if (!canEdit(journeyId, userId)) return null;
+ const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
+ const galleryId = db.transaction(() => ensureInGallery(journeyId, trekPhotoId, caption))();
+ return db.prepare(`SELECT ${GALLERY_SELECT} FROM ${GALLERY_JOIN} WHERE gp.id = ?`).get(galleryId) ?? null;
+}
+
+// Unlink a photo from a specific entry; gallery row is preserved.
+export function unlinkPhotoFromEntry(entryId: number, journeyPhotoId: number, userId: number): boolean {
+ const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
+ if (!entry) return false;
+ if (!canEdit(entry.journey_id, userId)) return false;
+
+ const result = db.prepare('DELETE FROM journey_entry_photos WHERE entry_id = ? AND journey_photo_id = ?').run(entryId, journeyPhotoId);
+ return result.changes > 0;
+}
+
+// Hard-delete a gallery photo (removes from all entries and the gallery).
+export function deleteGalleryPhoto(journeyPhotoId: number, userId: number): { photo_id: number; file_path?: string | null } | null {
+ const row = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(journeyPhotoId) as { id: number; journey_id: number; photo_id: number } | undefined;
+ if (!row) return null;
+ if (!canEdit(row.journey_id, userId)) return null;
+
+ const trekRow = db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as { file_path?: string; provider?: string } | undefined;
+
+ // cascade on journey_entry_photos.journey_photo_id handles junction cleanup
+ db.prepare('DELETE FROM journey_photos WHERE id = ?').run(journeyPhotoId);
+ deleteTrekPhotoIfOrphan(row.photo_id);
+
+ return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null };
}
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
- // Get the trek_photo_id from the journey_photo, then update the central registry
+ // photoId = journey_photos.id (gallery row); look up the trek_photo_id
const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined;
if (!jp) return;
setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId);
+ // also denorm on gallery row for fast reads
+ db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId);
}
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
- const photo = db.prepare(`
- SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
- JOIN journey_entries je ON jp.entry_id = je.id
- WHERE jp.id = ?
- `).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
- if (!photo) return null;
- if (!canEdit(photo.journey_id, userId)) return null;
+ // photoId = journey_photos.id (gallery row)
+ const row = db.prepare('SELECT id, journey_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number } | undefined;
+ if (!row) return null;
+ if (!canEdit(row.journey_id, userId)) return null;
- const fields: string[] = [];
- const values: unknown[] = [];
- if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
- if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
- if (!fields.length) return photo;
-
- values.push(photoId);
- db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
- return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
+ // caption lives on the gallery row; sort_order lives on the junction table
+ // (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order
+ // would not be reflected in the returned row).
+ if (data.caption !== undefined) {
+ db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId);
+ }
+ if (data.sort_order !== undefined) {
+ db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run(data.sort_order, photoId);
+ }
+ return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
}
-export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
- const photo = db.prepare(`
- SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
- JOIN journey_entries je ON jp.entry_id = je.id
- WHERE jp.id = ?
- `).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
- if (!photo) return null;
- if (!canEdit(photo.journey_id, userId)) return null;
+// deletePhoto: hard-delete (backwards compat name used by old route).
+export function deletePhoto(photoId: number, userId: number): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null {
+ const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined;
+ if (!row) return null;
+ if (!canEdit(row.journey_id, userId)) return null;
+
+ const trekRow = db.prepare('SELECT file_path, provider FROM trek_photos WHERE id = ?').get(row.photo_id) as { file_path?: string; provider?: string } | undefined;
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
- deleteTrekPhotoIfOrphan(photo.photo_id);
+ deleteTrekPhotoIfOrphan(row.photo_id);
- // clean up empty Gallery entries left behind
- const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
- if (!remaining) {
- const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(photo.entry_id) as JourneyEntry | undefined;
- if (entry && entry.title === 'Gallery' && !entry.story) {
- db.prepare('DELETE FROM journey_entries WHERE id = ?').run(photo.entry_id);
- }
- }
-
- return photo;
+ return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
}
// โโ Contributors โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
diff --git a/server/src/services/journeyShareService.ts b/server/src/services/journeyShareService.ts
index 85e83fb9..5ac03be4 100644
--- a/server/src/services/journeyShareService.ts
+++ b/server/src/services/journeyShareService.ts
@@ -66,11 +66,10 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
const photo = db.prepare(`
- SELECT jp.photo_id, tkp.owner_id, je.journey_id
- FROM journey_photos jp
- JOIN trek_photos tkp ON tkp.id = jp.photo_id
- JOIN journey_entries je ON jp.entry_id = je.id
- WHERE jp.photo_id = ? AND je.journey_id = ?
+ SELECT gp.photo_id, tkp.owner_id, gp.journey_id
+ FROM journey_photos gp
+ JOIN trek_photos tkp ON tkp.id = gp.photo_id
+ WHERE gp.photo_id = ? AND gp.journey_id = ?
`).get(photoId, row.journey_id) as any;
if (!photo) return null;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -81,10 +80,9 @@ export function validateShareTokenForAsset(token: string, assetId: string): { ow
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
const photo = db.prepare(`
- SELECT tkp.owner_id FROM journey_photos jp
- JOIN trek_photos tkp ON tkp.id = jp.photo_id
- JOIN journey_entries je ON jp.entry_id = je.id
- WHERE tkp.asset_id = ? AND je.journey_id = ?
+ SELECT tkp.owner_id FROM journey_photos gp
+ JOIN trek_photos tkp ON tkp.id = gp.photo_id
+ WHERE tkp.asset_id = ? AND gp.journey_id = ?
`).get(assetId, row.journey_id) as any;
if (!photo) {
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -108,13 +106,13 @@ export function getPublicJourney(token: string) {
`).all(row.journey_id) as any[];
const photos = db.prepare(`
- SELECT jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
+ SELECT gp.id, jep.entry_id, gp.photo_id, gp.caption, jep.sort_order, gp.shared, gp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
- FROM journey_photos jp
- JOIN trek_photos tkp ON tkp.id = jp.photo_id
- JOIN journey_entries je ON jp.entry_id = je.id
- WHERE je.journey_id = ?
- ORDER BY jp.sort_order
+ FROM journey_entry_photos jep
+ JOIN journey_photos gp ON gp.id = jep.journey_photo_id
+ JOIN trek_photos tkp ON tkp.id = gp.photo_id
+ WHERE gp.journey_id = ?
+ ORDER BY jep.sort_order
`).all(row.journey_id) as any[];
const photosByEntry: Record = {};
@@ -122,12 +120,16 @@ export function getPublicJourney(token: string) {
(photosByEntry[p.entry_id] ||= []).push(p);
}
+ const gallery = db.prepare(`
+ SELECT gp.id, gp.journey_id, gp.photo_id, gp.caption, gp.shared, gp.sort_order, gp.created_at,
+ tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
+ FROM journey_photos gp
+ JOIN trek_photos tkp ON tkp.id = gp.photo_id
+ WHERE gp.journey_id = ?
+ ORDER BY gp.sort_order
+ `).all(row.journey_id) as any[];
+
const enrichedEntries = entries
- .filter(e => {
- // hide empty Gallery entries (no photos, no story)
- if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
- return true;
- })
.map(e => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
@@ -138,7 +140,7 @@ export function getPublicJourney(token: string) {
// Stats
const stats = {
entries: entries.length,
- photos: photos.length,
+ photos: gallery.length,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
@@ -150,6 +152,7 @@ export function getPublicJourney(token: string) {
status: journey.status,
},
entries: enrichedEntries,
+ gallery,
stats,
permissions: {
share_timeline: !!row.share_timeline,
diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts
index 96fb42cd..7720feb1 100644
--- a/server/src/services/memories/helpersService.ts
+++ b/server/src/services/memories/helpersService.ts
@@ -129,15 +129,14 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
// Journey photos use tripId=0 โ check journey_photos + journey_contributors
if (tripId === '0') {
const journeyPhoto = db.prepare(`
- SELECT jp.entry_id, je.journey_id
- FROM journey_photos jp
- JOIN trek_photos tkp ON tkp.id = jp.photo_id
- JOIN journey_entries je ON je.id = jp.entry_id
+ SELECT gp.journey_id
+ FROM journey_photos gp
+ JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE tkp.asset_id = ?
AND tkp.provider = ?
AND tkp.owner_id = ?
LIMIT 1
- `).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
+ `).get(assetId, provider, ownerUserId) as { journey_id: number } | undefined;
if (!journeyPhoto) return false;
const access = db.prepare(`
@@ -194,13 +193,12 @@ export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number
// Check journey_photos โ is this photo in a journey the user can access?
const journeyAccess = db.prepare(`
- SELECT 1 FROM journey_photos jp
- JOIN journey_entries je ON je.id = jp.entry_id
- WHERE jp.photo_id = ?
+ SELECT 1 FROM journey_photos gp
+ WHERE gp.photo_id = ?
AND EXISTS (
- SELECT 1 FROM journeys j WHERE j.id = je.journey_id AND j.user_id = ?
+ SELECT 1 FROM journeys j WHERE j.id = gp.journey_id AND j.user_id = ?
UNION ALL
- SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = je.journey_id AND jc.user_id = ?
+ SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = gp.journey_id AND jc.user_id = ?
)
LIMIT 1
`).get(trekPhotoId, requestingUserId, requestingUserId);
diff --git a/server/src/services/memories/photoResolverService.ts b/server/src/services/memories/photoResolverService.ts
index 82d07243..428c1aa4 100644
--- a/server/src/services/memories/photoResolverService.ts
+++ b/server/src/services/memories/photoResolverService.ts
@@ -9,6 +9,7 @@ import type { ServiceResult, AssetInfo } from './helpersService';
import { fail, success } from './helpersService';
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import * as photoCache from './trekPhotoCache';
+import { ensureLocalThumbnail } from './thumbnailService';
// โโ Lookup / Register โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -101,7 +102,31 @@ export async function streamPhoto(
}
if (photo.file_path) {
- const localPath = path.join(__dirname, '../../../uploads', photo.file_path);
+ const uploadsRoot = path.join(__dirname, '../../../uploads');
+
+ if (kind === 'thumbnail') {
+ let thumbRel = photo.thumbnail_path ?? null;
+ if (!thumbRel) {
+ const result = await ensureLocalThumbnail(uploadsRoot, photo.file_path);
+ if (result) {
+ thumbRel = result.thumbnailRelPath;
+ db.prepare(
+ 'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?'
+ ).run(thumbRel, result.width, result.height, photo.id);
+ }
+ }
+ if (thumbRel) {
+ const thumbAbs = path.join(uploadsRoot, thumbRel);
+ if (fs.existsSync(thumbAbs)) {
+ res.set('Cache-Control', 'public, max-age=86400, immutable');
+ res.sendFile(thumbAbs);
+ return;
+ }
+ }
+ // Fall through to original if thumbnail unavailable.
+ }
+
+ const localPath = path.join(uploadsRoot, photo.file_path);
if (fs.existsSync(localPath)) {
res.set('Cache-Control', 'public, max-age=86400');
res.sendFile(localPath);
diff --git a/server/src/services/memories/thumbnailService.ts b/server/src/services/memories/thumbnailService.ts
new file mode 100644
index 00000000..d328b579
--- /dev/null
+++ b/server/src/services/memories/thumbnailService.ts
@@ -0,0 +1,50 @@
+import { Jimp } from 'jimp'
+import path from 'path'
+import fs from 'fs/promises'
+import crypto from 'crypto'
+import { isAddonEnabled } from '../adminService'
+import { ADDON_IDS } from '../../addons'
+
+const THUMB_MAX = 800
+const THUMB_QUALITY = 80
+
+export async function ensureLocalThumbnail(
+ uploadsRoot: string,
+ originalRelPath: string,
+): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
+ if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return null
+
+ const originalAbs = path.join(uploadsRoot, originalRelPath)
+ try { await fs.access(originalAbs) } catch { return null }
+
+ // Deterministic name so concurrent requests don't race on the same photo.
+ const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16)
+ const thumbRel = `journey/thumbs/${hash}.jpg`
+ const thumbAbs = path.join(uploadsRoot, thumbRel)
+
+ try {
+ const [srcStat, dstStat] = await Promise.all([
+ fs.stat(originalAbs),
+ fs.stat(thumbAbs).catch(() => null),
+ ])
+ if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) {
+ const img = await Jimp.read(thumbAbs)
+ return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
+ }
+
+ await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
+
+ // Jimp auto-applies EXIF orientation on read, matching sharp's .rotate() behavior.
+ const img = await Jimp.read(originalAbs)
+ const { width: w, height: h } = img.bitmap
+ if (w > THUMB_MAX || h > THUMB_MAX) {
+ img.scaleToFit({ w: THUMB_MAX, h: THUMB_MAX })
+ }
+ await img.write(thumbAbs as `${string}.jpg`, { quality: THUMB_QUALITY })
+
+ return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
+ } catch {
+ // Unsupported format, corrupt file, etc. โ fall back to original in caller.
+ return null
+ }
+}
diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts
index db0ef8ad..42c0c5cd 100644
--- a/server/src/services/oidcService.ts
+++ b/server/src/services/oidcService.ts
@@ -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);
@@ -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 };
}
diff --git a/server/src/services/packingService.ts b/server/src/services/packingService.ts
index a9eddb25..19fa54d9 100644
--- a/server/src/services/packingService.ts
+++ b/server/src/services/packingService.ts
@@ -1,4 +1,5 @@
import { db, canAccessTrip } from '../db/database';
+import { avatarUrl } from './authService';
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
@@ -131,7 +132,10 @@ export function listBags(tripId: string | number) {
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
membersByBag.get(m.bag_id)!.push(m);
}
- return bags.map(b => ({ ...b, members: membersByBag.get(b.id) || [] }));
+ return bags.map(b => ({
+ ...b,
+ members: (membersByBag.get(b.id) || []).map(m => ({ ...m, avatar: avatarUrl(m) })),
+ }));
}
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
@@ -140,11 +144,12 @@ export function setBagMembers(tripId: string | number, bagId: string | number, u
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
for (const uid of userIds) ins.run(bagId, uid);
- return db.prepare(`
+ const rows = db.prepare(`
SELECT bm.user_id, u.username, u.avatar
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
WHERE bm.bag_id = ?
- `).all(bagId);
+ `).all(bagId) as { user_id: number; username: string; avatar: string | null }[];
+ return rows.map(m => ({ ...m, avatar: avatarUrl(m) }));
}
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
@@ -260,7 +265,7 @@ export function getCategoryAssignees(tripId: string | number) {
const assignees: Record = {};
for (const row of rows as any[]) {
if (!assignees[row.category_name]) assignees[row.category_name] = [];
- assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
+ assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: avatarUrl(row) });
}
return assignees;
@@ -274,12 +279,13 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s
for (const uid of userIds) insert.run(tripId, categoryName, uid);
}
- return db.prepare(`
+ const updated = db.prepare(`
SELECT pca.user_id, u.username, u.avatar
FROM packing_category_assignees pca
JOIN users u ON pca.user_id = u.id
WHERE pca.trip_id = ? AND pca.category_name = ?
- `).all(tripId, categoryName);
+ `).all(tripId, categoryName) as { user_id: number; username: string; avatar: string | null }[];
+ return updated.map(m => ({ ...m, avatar: avatarUrl(m) }));
}
// โโ Reorder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts
index 4410c36d..354a14f7 100644
--- a/server/src/services/reservationService.ts
+++ b/server/src/services/reservationService.ts
@@ -43,16 +43,42 @@ function loadEndpoints(reservationId: number): ReservationEndpoint[] {
).all(reservationId) as ReservationEndpoint[];
}
-const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
- db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
- const insert = db.prepare(`
- INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `);
- endpoints.forEach((e, i) => {
- insert.run(reservationId, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
+// 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;
+}
+
+function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
+ // Bind the transaction lazily on each call. Binding at module load time
+ // captures the DB connection that was open then, which becomes invalid
+ // after demo-reset / restore-from-backup closes and reinitialises the
+ // connection โ every later endpoint save would throw
+ // "The database connection is not open".
+ const tx = db.transaction((rid: number, eps: EndpointInput[]) => {
+ db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(rid);
+ const insert = db.prepare(`
+ INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `);
+ eps.forEach((e, i) => {
+ insert.run(rid, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
+ });
});
-});
+ tx(reservationId, endpoints);
+}
export function listReservations(tripId: string | number) {
const reservations = db.prepare(`
@@ -160,13 +186,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 +215,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 +329,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 +378,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,
diff --git a/server/src/types.ts b/server/src/types.ts
index 5c658f1e..514dae9e 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -389,6 +389,24 @@ export interface JourneyPhoto {
height?: number | null;
}
+export interface GalleryPhoto {
+ id: number;
+ journey_id: number;
+ photo_id: number;
+ caption?: string | null;
+ shared: number;
+ sort_order: number;
+ created_at: number;
+ // Joined from trek_photos for API responses
+ provider?: string;
+ asset_id?: string | null;
+ owner_id?: number | null;
+ file_path?: string | null;
+ thumbnail_path?: string | null;
+ width?: number | null;
+ height?: number | null;
+}
+
export interface JourneyTrip {
journey_id: number;
trip_id: number;
diff --git a/server/tests/integration/journey.test.ts b/server/tests/integration/journey.test.ts
index 167dc326..6748992a 100644
--- a/server/tests/integration/journey.test.ts
+++ b/server/tests/integration/journey.test.ts
@@ -649,7 +649,7 @@ describe('Link photo to entry', () => {
.send({});
expect(res.status).toBe(400);
- expect(res.body.error).toBe('photo_id required');
+ expect(res.body.error).toBe('journey_photo_id required');
});
});
diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts
index 0fcd6a33..40179329 100644
--- a/server/tests/integration/systemNotices.test.ts
+++ b/server/tests/integration/systemNotices.test.ts
@@ -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')
diff --git a/server/tests/unit/services/journeyService.test.ts b/server/tests/unit/services/journeyService.test.ts
index 31f24214..82b14d55 100644
--- a/server/tests/unit/services/journeyService.test.ts
+++ b/server/tests/unit/services/journeyService.test.ts
@@ -68,6 +68,7 @@ import {
removeContributor,
getSuggestions,
syncTripPlaces,
+ reorderEntries,
onPlaceCreated,
onPlaceUpdated,
onPlaceDeleted,
@@ -1325,9 +1326,10 @@ describe('Edge cases', () => {
const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true);
- // Photo should be deleted with the entry
- const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
- expect(deletedPhoto).toBeUndefined();
+ // Junction row must be gone (ON DELETE CASCADE from journey_entries).
+ // Gallery row (journey_photos) is preserved โ photo may belong to other entries.
+ const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any;
+ expect(junctionRow).toBeUndefined();
});
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
@@ -1395,17 +1397,12 @@ describe('Edge cases', () => {
addTripToJourney(journey.id, trip.id, user.id);
- // Should have a [Trip Photos] entry with the imported photo
- const photoEntry = testDb.prepare(
- "SELECT * FROM journey_entries WHERE journey_id = ? AND title = '[Trip Photos]'"
- ).get(journey.id) as any;
- expect(photoEntry).toBeDefined();
-
+ // Trip photos now go straight into the journey gallery (no wrapper entry).
const photos = testDb.prepare(`
SELECT jp.*, tkp.asset_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
- WHERE jp.entry_id = ?
- `).all(photoEntry.id);
+ WHERE jp.journey_id = ?
+ `).all(journey.id);
expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
});
@@ -1469,3 +1466,108 @@ describe('addProviderPhoto โ passphrase', () => {
expect(row?.passphrase).not.toBe('secret-pp');
});
});
+
+// -- reorderEntries (#846) ----------------------------------------------------
+
+function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): { id: number } {
+ const now = Date.now();
+ const res = testDb.prepare(`
+ INSERT INTO journey_entries (journey_id, author_id, type, entry_date, entry_time, sort_order, visibility, created_at, updated_at)
+ VALUES (?, ?, 'entry', ?, ?, ?, 'private', ?, ?)
+ `).run(journeyId, authorId, opts.entry_date, opts.entry_time ?? null, opts.sort_order ?? 0, now, now);
+ return { id: Number(res.lastInsertRowid) };
+}
+
+describe('reorderEntries', () => {
+ it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', () => {
+ const { user } = createUser(testDb);
+ const journey = createJourney(testDb, user.id);
+ const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 });
+ const e2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 });
+
+ const ok = reorderEntries(journey.id, user.id, [e2.id, e1.id]);
+ expect(ok).toBe(true);
+
+ const entries = listEntries(journey.id, user.id)!;
+ const dayEntries = entries.filter(e => e.entry_date === '2026-08-01');
+ expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]);
+ });
+
+ it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', () => {
+ const { user } = createUser(testDb);
+ const j1 = createJourney(testDb, user.id);
+ const j2 = createJourney(testDb, user.id);
+ const entry = createJourneyEntry(testDb, j2.id, user.id, { entry_date: '2026-08-02' });
+
+ const ok = reorderEntries(j1.id, user.id, [entry.id]);
+ expect(ok).toBe(false);
+ });
+
+ it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', () => {
+ const { user } = createUser(testDb);
+ const journey = createJourney(testDb, user.id);
+ const day1a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 });
+ const day1b = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 });
+ const day2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 });
+
+ reorderEntries(journey.id, user.id, [day1b.id, day1a.id]);
+
+ const entries = listEntries(journey.id, user.id)!;
+ const day2Entry = entries.find(e => e.id === day2.id)!;
+ expect(day2Entry.sort_order).toBe(0);
+ });
+});
+
+describe('syncTripPlaces sort_order', () => {
+ it('JOURNEY-SVC-092: assigns unique sequential sort_order per date for same-day places', () => {
+ const { user } = createUser(testDb);
+ const journey = createJourney(testDb, user.id);
+ const trip = createTrip(testDb, user.id, {
+ title: 'Order Trip',
+ start_date: '2026-09-01',
+ end_date: '2026-09-02',
+ });
+ const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
+ const p1 = createPlace(testDb, trip.id, { name: 'Place A' });
+ const p2 = createPlace(testDb, trip.id, { name: 'Place B' });
+ const p3 = createPlace(testDb, trip.id, { name: 'Place C' });
+ createDayAssignment(testDb, day.id, p1.id);
+ createDayAssignment(testDb, day.id, p2.id);
+ createDayAssignment(testDb, day.id, p3.id);
+
+ syncTripPlaces(journey.id, trip.id, user.id);
+
+ const rows = testDb.prepare(
+ 'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC'
+ ).all(journey.id) as { sort_order: number }[];
+ const orders = rows.map(r => r.sort_order);
+ expect(new Set(orders).size).toBe(orders.length);
+ expect(orders).toEqual([0, 1, 2]);
+ });
+});
+
+describe('onPlaceCreated sort_order', () => {
+ it('JOURNEY-SVC-093: assigns MAX+1 sort_order when entries already exist on the target date', () => {
+ const { user } = createUser(testDb);
+ const journey = createJourney(testDb, user.id);
+ const trip = createTrip(testDb, user.id, {
+ title: 'Append Trip',
+ start_date: '2026-10-01',
+ end_date: '2026-10-02',
+ });
+ addTripToJourney(journey.id, trip.id, user.id);
+
+ const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string };
+ insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 });
+
+ const place = createPlace(testDb, trip.id, { name: 'Late Addition' });
+ createDayAssignment(testDb, day.id, place.id);
+ onPlaceCreated(trip.id, place.id);
+
+ const newEntry = testDb.prepare(
+ 'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
+ ).get(journey.id, place.id) as { sort_order: number } | undefined;
+ expect(newEntry).toBeDefined();
+ expect(newEntry!.sort_order).toBe(6);
+ });
+});
diff --git a/server/tests/unit/services/journeyShareService.test.ts b/server/tests/unit/services/journeyShareService.test.ts
index bbd196a7..3df9c2ff 100644
--- a/server/tests/unit/services/journeyShareService.test.ts
+++ b/server/tests/unit/services/journeyShareService.test.ts
@@ -58,7 +58,7 @@ afterAll(() => {
// -- Helpers ------------------------------------------------------------------
-/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */
+/** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */
function insertJourneyPhoto(
entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
@@ -70,10 +70,24 @@ function insertJourneyPhoto(
VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number;
+
+ // Look up journey_id from entry so gallery row is keyed to the journey (not entry).
+ const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number };
+ const journeyId = entryRow.journey_id;
+ const now = Date.now();
+
testDb.prepare(`
- INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
+ INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?)
- `).run(entryId, trekId, Date.now());
+ `).run(journeyId, trekId, now);
+
+ const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number };
+
+ testDb.prepare(`
+ INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
+ VALUES (?, ?, 0, ?)
+ `).run(entryId, galleryRow.id, now);
+
// Return trek_photos.id โ this is p.photo_id in the public API response
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
return trekId;
diff --git a/server/tests/unit/services/oidcService.test.ts b/server/tests/unit/services/oidcService.test.ts
index eca92065..5de82a4b 100644
--- a/server/tests/unit/services/oidcService.test.ts
+++ b/server/tests/unit/services/oidcService.test.ts
@@ -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;
+ 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);
+ });
+});
diff --git a/unraid-template.xml b/unraid-template.xml
index 69ca38f6..b9c48442 100644
--- a/unraid-template.xml
+++ b/unraid-template.xml
@@ -37,6 +37,7 @@
false
+ false
true
1
false
diff --git a/wiki/Admin-Addons.md b/wiki/Admin-Addons.md
index 01a418c3..81553305 100644
--- a/wiki/Admin-Addons.md
+++ b/wiki/Admin-Addons.md
@@ -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
diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md
index 9f1e658f..7ea29c5a 100644
--- a/wiki/Environment-Variables.md
+++ b/wiki/Environment-Variables.md
@@ -48,11 +48,12 @@ 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 |
|---|---|---|
| `FORCE_HTTPS` | When `true`: 301-redirects HTTPโHTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` |
+| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `TRUST_PROXY` | Number of trusted proxy hops. Tells Express to read the real client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` automatically in production. Required for `FORCE_HTTPS` to detect the forwarded protocol. | `1` (production) |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived as `true` when `NODE_ENV=production` or `FORCE_HTTPS=true`. Set to `false` only as an escape hatch for LAN testing without TLS โ not recommended in production. | auto |
@@ -62,7 +63,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 +111,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 +130,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
diff --git a/wiki/Home.md b/wiki/Home.md
index 102ca21e..81f8ff68 100644
--- a/wiki/Home.md
+++ b/wiki/Home.md
@@ -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
diff --git a/wiki/Install-Docker-Compose.md b/wiki/Install-Docker-Compose.md
index f9344617..16b72821 100644
--- a/wiki/Install-Docker-Compose.md
+++ b/wiki/Install-Docker-Compose.md
@@ -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
diff --git a/wiki/Install-Docker.md b/wiki/Install-Docker.md
index 17dd3983..62ddbf8d 100644
--- a/wiki/Install-Docker.md
+++ b/wiki/Install-Docker.md
@@ -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
diff --git a/wiki/Install-Helm.md b/wiki/Install-Helm.md
index d0fca6db..1a320a09 100644
--- a/wiki/Install-Helm.md
+++ b/wiki/Install-Helm.md
@@ -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
diff --git a/wiki/Install-Unraid.md b/wiki/Install-Unraid.md
index 83e1706f..0edf49d6 100644
--- a/wiki/Install-Unraid.md
+++ b/wiki/Install-Unraid.md
@@ -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
diff --git a/wiki/Map-Features.md b/wiki/Map-Features.md
index 878976eb..3d8e170e 100644
--- a/wiki/Map-Features.md
+++ b/wiki/Map-Features.md
@@ -36,18 +36,20 @@ When you have a day selected, a dark dashed line connects consecutive places in
At zoom level 12 or higher, small pill-shaped labels appear between consecutive places and show the estimated **walking time** and **driving time** for each segment. Below zoom 12 they are hidden to keep the map clean.
+> **Requires:** Settings โ Display โ **Route calculation** must be ON. When this setting is OFF, TREK never queries the routing service, so no pills are calculated or drawn at any zoom level.
+
## Reservation and transport overlay
-Flights, trains, cars, and cruises are drawn as overlays between their endpoint places:
+Flights, trains, cars, and cruises can be drawn as overlays between their endpoint places. Overlays are **off by default** โ activate each reservation individually by clicking the small **Route** icon next to the booking row in the day sidebar. The selection is remembered per trip in your browser. Click the icon again to hide it.
- **Flights and cruises** โ geodesic great-circle arcs
- **Trains and cars** โ straight lines
- **Antimeridian crossings** โ arcs that would cross the date line are split into sub-arcs to avoid wrapping across the map
- **Endpoint markers** โ pill-shaped labels with the transport icon and the endpoint code (e.g. IATA airport code) or location name
-- **Flight stats** โ a floating label on the arc shows departure code โ arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights.
+- **Flight stats** โ a floating label on the arc shows departure code โ arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights and require Settings โ Display โ **Route calculation** to be ON.
- **Confirmed reservations** โ solid line; **Pending** โ dashed line
-> **Admin:** Whether endpoint labels appear is controlled by the **Show connection labels** setting (`map_booking_labels`).
+> **Admin:** Whether endpoint text labels appear on the endpoint markers is controlled by the **Booking route labels** setting in Settings โ Display (`map_booking_labels`).
## Location button
diff --git a/wiki/Photo-Providers.md b/wiki/Photo-Providers.md
index 05f37f5c..dca7acd2 100644
--- a/wiki/Photo-Providers.md
+++ b/wiki/Photo-Providers.md
@@ -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).
---
diff --git a/wiki/Quick-Start.md b/wiki/Quick-Start.md
index 786af51d..17e7b970 100644
--- a/wiki/Quick-Start.md
+++ b/wiki/Quick-Start.md
@@ -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
diff --git a/wiki/Reverse-Proxy.md b/wiki/Reverse-Proxy.md
index a9993960..526472ee 100644
--- a/wiki/Reverse-Proxy.md
+++ b/wiki/Reverse-Proxy.md
@@ -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://: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
diff --git a/wiki/Tags-and-Categories.md b/wiki/Tags-and-Categories.md
index d334f645..cdb8274d 100644
--- a/wiki/Tags-and-Categories.md
+++ b/wiki/Tags-and-Categories.md
@@ -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`).
@@ -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 |
diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md
index eb32feb2..aae97181 100644
--- a/wiki/Troubleshooting.md
+++ b/wiki/Troubleshooting.md
@@ -223,6 +223,23 @@ If `ALLOWED_ORIGINS` is not set, TREK allows all origins (development default).
---
+## MCP OAuth flow does not initiate / "Connect" redirects but authentication never starts
+
+**Cause:** TREK builds the OAuth 2.1 redirect URI from `APP_URL`. If `APP_URL` is not set, the authorization URL is constructed from a localhost fallback that external clients (Claude.ai, Claude Desktop) cannot reach, so the OAuth handshake never completes.
+
+**Fix:** Set `APP_URL` to the public URL of your instance:
+
+```yaml
+environment:
+ - APP_URL=https://trek.example.com
+```
+
+Restart the container after adding the variable. Once set, clicking **Connect** in the MCP client should redirect to your TREK instance and complete the OAuth flow normally.
+
+> **Note:** `APP_URL` is required for any MCP OAuth integration. Without it, the authorization endpoint resolves to `http://localhost:`, which is unreachable from external MCP clients.
+
+---
+
## MCP integration: "Too many requests" or "Session limit reached"
**Cause:** Each user is limited to 300 MCP requests per minute and 20 concurrent sessions by default. Exceeding either limit returns a `429` response.
diff --git a/wiki/Updating.md b/wiki/Updating.md
index 46e45ba9..2e63c91d 100644
--- a/wiki/Updating.md
+++ b/wiki/Updating.md
@@ -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