mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7fa676199 | |||
| 1547258c0c | |||
| a1ad512064 | |||
| 25324108cb | |||
| 9f5d2f6488 | |||
| 40253d2fdf | |||
| 910631c1ff | |||
| 5b41cab898 |
+4
-1
@@ -105,5 +105,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
# Preflight: if the app code is missing, a volume was almost certainly mounted
|
||||
# over /app (it hides the image's node_modules + dist). Fail with actionable
|
||||
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
|
||||
@@ -51,10 +51,10 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
@@ -90,7 +91,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### 🧳 Travel management
|
||||
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
@@ -108,6 +109,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
|
||||
</td>
|
||||
@@ -128,13 +130,13 @@ 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
|
||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), 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
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
|
||||
</td>
|
||||
@@ -156,8 +158,9 @@ 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
|
||||
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
@@ -191,9 +194,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -202,7 +205,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
</div>
|
||||
|
||||
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -263,7 +266,7 @@ Then:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -311,6 +314,9 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
||||
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
||||
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
@@ -397,12 +403,12 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `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` |
|
||||
| `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`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `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` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server 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` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
|
||||
@@ -20,14 +20,14 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder'
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
||||
import { RouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
||||
@@ -152,6 +152,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
||||
const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({})
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
const legsAbortRef = useRef<AbortController | null>(null)
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
@@ -379,12 +381,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
||||
useEffect(() => {
|
||||
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); setHotelLegs({}); return }
|
||||
const merged = mergedItemsMap[selectedDayId] || []
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||
let cur: { id: number; lat: number; lng: number }[] = []
|
||||
for (const it of merged) {
|
||||
@@ -392,7 +390,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||
} else if (it.type === 'transport') {
|
||||
const r = it.data
|
||||
const from = epLoc(r, 'from'), to = epLoc(r, 'to')
|
||||
const { from, to } = getTransportRouteEndpoints(r, selectedDayId)
|
||||
if (from || to) {
|
||||
// Located transport: route to its departure point, break the run (the
|
||||
// flight/train itself isn't driven), and let its arrival start the next.
|
||||
@@ -408,7 +406,33 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}
|
||||
}
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
if (runs.length === 0) { setRouteLegs({}); return }
|
||||
|
||||
// Hotel bookend legs: the drive from the day's accommodation to the first
|
||||
// located place (morning) and from the last place back to it (evening). Only
|
||||
// when the "optimize from accommodation" setting is on and the day has a hotel,
|
||||
// mirroring the range logic the optimizer itself uses (getAccommodationAnchors).
|
||||
const day = days.find(d => d.id === selectedDayId)
|
||||
const dayAccs = day && optimizeFromAccommodation !== false
|
||||
? accommodations.filter(a => a.place_lat != null && a.place_lng != null && isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||
: []
|
||||
const checkOut = day ? dayAccs.find(a => a.end_day_id === day.id) : undefined
|
||||
const checkIn = day ? dayAccs.find(a => a.start_day_id === day.id) : undefined
|
||||
const transfer = !!(checkOut && checkIn && checkOut !== checkIn)
|
||||
const startHotel = transfer ? checkOut : dayAccs[0]
|
||||
const endHotel = transfer ? checkIn : dayAccs[0]
|
||||
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
|
||||
const placePts: { lat: number; lng: number }[] = []
|
||||
for (const it of merged) {
|
||||
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||
placePts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
|
||||
}
|
||||
}
|
||||
const firstPlace = placePts[0]
|
||||
const lastPlace = placePts[placePts.length - 1]
|
||||
const wantTop = !!(startHotel && firstPlace)
|
||||
const wantBottom = !!(endHotel && lastPlace)
|
||||
|
||||
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
|
||||
|
||||
const controller = new AbortController()
|
||||
legsAbortRef.current = controller
|
||||
@@ -422,9 +446,27 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
}
|
||||
}
|
||||
if (!controller.signal.aborted) setRouteLegs(map)
|
||||
|
||||
// One extra cached OSRM call per bookend; shares RouteCalculator's cache.
|
||||
const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }): Promise<RouteSegment | undefined> => {
|
||||
try {
|
||||
const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: routeProfile })
|
||||
return r.legs[0]
|
||||
} catch { return undefined }
|
||||
}
|
||||
const hotel: { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } } = {}
|
||||
if (wantTop) {
|
||||
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstPlace.lat, lng: firstPlace.lng })
|
||||
if (seg) hotel.top = { seg, name: hotelName(startHotel!) }
|
||||
}
|
||||
if (wantBottom) {
|
||||
const seg = await legBetween({ lat: lastPlace.lat, lng: lastPlace.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number })
|
||||
if (seg) hotel.bottom = { seg, name: hotelName(endHotel!) }
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) }
|
||||
})()
|
||||
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap])
|
||||
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation])
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
@@ -938,6 +980,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setRouteInfo,
|
||||
routeLegs,
|
||||
setRouteLegs,
|
||||
hotelLegs,
|
||||
setHotelLegs,
|
||||
legsAbortRef,
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
@@ -1085,6 +1129,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setRouteInfo,
|
||||
routeLegs,
|
||||
setRouteLegs,
|
||||
hotelLegs,
|
||||
setHotelLegs,
|
||||
legsAbortRef,
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
@@ -1427,6 +1473,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||
}}
|
||||
>
|
||||
{isSelected && hotelLegs.top && (
|
||||
<HotelRouteConnector seg={hotelLegs.top.seg} name={hotelLegs.top.name} profile={routeProfile} placement="top" />
|
||||
)}
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||
@@ -2057,6 +2106,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
)
|
||||
})
|
||||
)}
|
||||
{isSelected && hotelLegs.bottom && (
|
||||
<HotelRouteConnector seg={hotelLegs.bottom.seg} name={hotelLegs.bottom.name} profile={routeProfile} placement="bottom" />
|
||||
)}
|
||||
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||
<div
|
||||
style={{ minHeight: 12, padding: '2px 8px' }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Car, Footprints } from 'lucide-react'
|
||||
import { Car, Footprints, Hotel } from 'lucide-react'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||
@@ -19,3 +19,60 @@ export function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: '
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The hotel's bookend legs for a day: a two-line connector naming the day's
|
||||
* accommodation with the drive to/from it. Rendered above the first place (the
|
||||
* morning departure from the hotel) and below the last place (the evening return),
|
||||
* when the "optimize from accommodation" setting is on and the day has a hotel.
|
||||
*/
|
||||
export function HotelRouteConnector({
|
||||
seg,
|
||||
profile,
|
||||
name,
|
||||
placement,
|
||||
}: {
|
||||
seg: RouteSegment
|
||||
profile: 'driving' | 'walking'
|
||||
name: string
|
||||
placement: 'top' | 'bottom'
|
||||
}) {
|
||||
const driving = profile === 'driving'
|
||||
const Icon = driving ? Car : Footprints
|
||||
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
||||
const hotelRow = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '0 14px', minWidth: 0 }}>
|
||||
<Hotel size={12} strokeWidth={1.8} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
const travelRow = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||
<div style={line} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<Icon size={11} strokeWidth={2} />
|
||||
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span>{seg.distanceText}</span>
|
||||
</div>
|
||||
<div style={line} />
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: placement === 'top' ? '2px 0 6px' : '6px 0 2px' }}>
|
||||
{placement === 'top' ? (
|
||||
<>
|
||||
{hotelRow}
|
||||
{travelRow}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{travelRow}
|
||||
{hotelRow}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,6 +253,101 @@ describe('PlaceFormModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
// ── Autocomplete suggestion click (#1192) ─────────────────────────────────────
|
||||
// Selecting a dropdown suggestion does a second `details` lookup which is fragile
|
||||
// (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When
|
||||
// it yields no usable place the modal must fall back to the reliable text search
|
||||
// instead of dead-ending on "Place search failed".
|
||||
|
||||
async function openSuggestion(user: ReturnType<typeof userEvent.setup>) {
|
||||
const searchInput = screen.getByPlaceholderText('Search places...');
|
||||
await user.type(searchInput, 'Eiffel');
|
||||
// Debounced autocomplete (300ms) then the dropdown renders the suggestion.
|
||||
return screen.findByText('Paris, France');
|
||||
}
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
// details rejects (e.g. proxy 504 from a hung Overpass mirror)
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({
|
||||
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||
source: 'openstreetmap',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
// Form is populated from the search fallback, and no error toast is shown.
|
||||
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument();
|
||||
expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything());
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({
|
||||
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||
source: 'openstreetmap',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined);
|
||||
});
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
||||
// hasMapsKey is false by default in beforeEach
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
|
||||
@@ -249,15 +249,34 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
const result = await mapsApi.details(suggestion.placeId, language)
|
||||
if (result.place) {
|
||||
handleSelectMapsResult(result.place)
|
||||
// The details lookup is a fragile second hop — it can fail when the
|
||||
// details kill-switch is off, when the OSM Overpass mirror is overloaded,
|
||||
// or on any upstream error. Treat a missing/coordinate-less place as a
|
||||
// miss and fall back to the reliable text-search path the search button
|
||||
// uses (its results already carry coordinates), so dropdown items stay
|
||||
// clickable instead of dead-ending on "Place search failed". (#1192)
|
||||
let place: Record<string, unknown> | null = null
|
||||
try {
|
||||
const result = await mapsApi.details(suggestion.placeId, language)
|
||||
if (result.place && result.place.lat != null && result.place.lng != null) {
|
||||
place = result.place
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
}
|
||||
if (!place) {
|
||||
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(query, language)
|
||||
place = search.places?.[0] ?? null
|
||||
}
|
||||
if (place) {
|
||||
handleSelectMapsResult(place)
|
||||
} else {
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
console.error('Place suggestion lookup failed:', err)
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
|
||||
@@ -647,5 +647,43 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.queryByText('Participants')).toBeNull();
|
||||
});
|
||||
|
||||
// ── Scroll / overflow (issue #1195) ──────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-046: content area is a bounded flex scroll region', () => {
|
||||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||
const p = buildPlace({ id: 200, description: longText, notes: longText } as any);
|
||||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const scroll = screen.getByTestId('inspector-scroll') as HTMLElement;
|
||||
expect(scroll.style.overflowY).toBe('auto');
|
||||
expect(scroll.style.minHeight).toBe('0px');
|
||||
// flex must allow the region to shrink/grow within the capped card
|
||||
expect(scroll.style.flex).not.toBe('');
|
||||
expect(scroll.style.flex).not.toBe('0 0 auto');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => {
|
||||
const longWord = 'https://example.com/' + 'a'.repeat(300);
|
||||
const p = buildPlace({ id: 201, description: longWord } as any);
|
||||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const descDiv = container.querySelector('.collab-note-md') as HTMLElement;
|
||||
expect(descDiv).toBeTruthy();
|
||||
expect(descDiv.style.overflowWrap).toBe('anywhere');
|
||||
expect(descDiv.style.wordBreak).toBe('break-word');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-048: description/notes do not shrink so the card scrolls instead of clipping', () => {
|
||||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||
const p = buildPlace({ id: 202, description: longText, notes: longText } as any);
|
||||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[];
|
||||
// Both description and notes containers must keep their natural height
|
||||
// (flex-shrink: 0) — otherwise they compress inside the flex column and
|
||||
// overflow:hidden clips the text with no scroll (issue #1195).
|
||||
expect(notes.length).toBe(2);
|
||||
for (const el of notes) {
|
||||
expect(el.style.flexShrink).toBe('0');
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function PlaceInspector({
|
||||
locale={locale} timeFormat={timeFormat} onClose={onClose} />
|
||||
|
||||
{/* Content — scrollable */}
|
||||
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div data-testid="inspector-scroll" style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
|
||||
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
||||
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||
@@ -253,14 +253,14 @@ export default function PlaceInspector({
|
||||
|
||||
{/* Description / Summary */}
|
||||
{(place.description || googleDetails?.summary) && (
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{place.notes && (
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
@@ -279,7 +279,7 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', flexShrink: 0 }}>
|
||||
{selectedDayId && (
|
||||
assignmentInDay ? (
|
||||
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||
@@ -497,7 +497,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
|
||||
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
|
||||
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
{/* Avatar with open/closed ring + tag */}
|
||||
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
||||
<div style={{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||
export default function ToggleSwitch({ on, onToggle, label }: { on: boolean; onToggle: () => void; label?: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle}
|
||||
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||
|
||||
@@ -288,4 +288,26 @@ describe('TripFormModal', () => {
|
||||
await user.click(submitBtn.closest('button')!);
|
||||
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-029: clearing the day count leaves the field empty (no snap to 1)', () => {
|
||||
render(<TripFormModal {...defaultProps} trip={null} />);
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
expect(dayInput).toBeInTheDocument();
|
||||
expect(dayInput.value).toBe('7');
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
expect(dayInput.value).toBe('');
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-030: empty day count blocks submit with an error', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn();
|
||||
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
||||
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'No-date Trip');
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!;
|
||||
await user.click(submitBtn.closest('button')!);
|
||||
await screen.findByText('Number of days is required');
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
reminder_days: 0 as number,
|
||||
day_count: 7,
|
||||
day_count: 7 as number | '',
|
||||
})
|
||||
const [customReminder, setCustomReminder] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -100,6 +100,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
||||
setError(t('dashboard.endDateError')); return
|
||||
}
|
||||
if (!formData.start_date && !formData.end_date) {
|
||||
const dc = Number(formData.day_count)
|
||||
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > 365) {
|
||||
setError(t('dashboard.dayCountRequired')); return
|
||||
}
|
||||
}
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await onSave({
|
||||
@@ -108,7 +114,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
start_date: formData.start_date || null,
|
||||
end_date: formData.end_date || null,
|
||||
reminder_days: formData.reminder_days,
|
||||
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
|
||||
...(!formData.start_date && !formData.end_date ? { day_count: Number(formData.day_count) } : {}),
|
||||
})
|
||||
const createdTrip = result ? result.trip : undefined
|
||||
// Add selected members for newly created trips
|
||||
@@ -320,7 +326,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
{t('dashboard.dayCount')}
|
||||
</label>
|
||||
<input type="number" min={1} max={365} value={formData.day_count}
|
||||
onChange={e => update('day_count', Math.max(1, Math.min(365, Number(e.target.value) || 1)))}
|
||||
onChange={e => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') { update('day_count', ''); return }
|
||||
const n = Math.floor(Number(raw))
|
||||
if (Number.isFinite(n)) update('day_count', Math.min(365, Math.max(1, n)))
|
||||
}}
|
||||
className={inputCls} />
|
||||
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult } from '../types'
|
||||
|
||||
@@ -53,12 +54,6 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
return pos != null
|
||||
})
|
||||
|
||||
// The departure/arrival coordinate of a transport, if its endpoints carry one.
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
|
||||
// Build a unified list of places + transports sorted by effective position.
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number }
|
||||
@@ -67,12 +62,15 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
||||
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
||||
})),
|
||||
...dayTransports.map(r => ({
|
||||
kind: 'transport' as const,
|
||||
from: epLoc(r, 'from'),
|
||||
to: epLoc(r, 'to'),
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
})),
|
||||
...dayTransports.map(r => {
|
||||
const { from, to } = getTransportRouteEndpoints(r, dayId)
|
||||
return {
|
||||
kind: 'transport' as const,
|
||||
from,
|
||||
to,
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
}
|
||||
}),
|
||||
].sort((a, b) => a.pos - b.pos)
|
||||
|
||||
// Group located places into driving runs.
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('LoginPage', () => {
|
||||
});
|
||||
|
||||
describe('FE-PAGE-LOGIN-007: Remember me sends remember_me to the API', () => {
|
||||
it('renders an unchecked checkbox and forwards remember_me: true when ticked', async () => {
|
||||
it('renders an off toggle and forwards remember_me: true when toggled on', async () => {
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/auth/login', async ({ request }) => {
|
||||
@@ -120,13 +120,13 @@ describe('LoginPage', () => {
|
||||
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /remember me/i });
|
||||
expect(checkbox).not.toBeChecked();
|
||||
const toggle = screen.getByRole('button', { name: /remember me/i });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
|
||||
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
|
||||
await user.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown, Fingerprint } from 'lucide-react'
|
||||
import { useLogin } from './login/useLogin'
|
||||
import ToggleSwitch from '../components/Settings/ToggleSwitch'
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const { t, language } = useTranslation()
|
||||
@@ -573,15 +574,15 @@ export default function LoginPage(): React.ReactElement {
|
||||
</div>
|
||||
{mode === 'login' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 8 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRememberMe(e.target.checked)}
|
||||
style={{ width: 15, height: 15, accentColor: '#111827', cursor: 'pointer', flexShrink: 0 }}
|
||||
/>
|
||||
{t('login.rememberMe')}
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ToggleSwitch on={rememberMe} onToggle={() => setRememberMe(!rememberMe)} label={t('login.rememberMe')} />
|
||||
<span
|
||||
onClick={() => setRememberMe(!rememberMe)}
|
||||
style={{ cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500, userSelect: 'none' }}
|
||||
>
|
||||
{t('login.rememberMe')}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||
import { parseTimeToMinutes, getSpanPhase, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||
|
||||
describe('parseTimeToMinutes', () => {
|
||||
it('parses HH:MM string', () => {
|
||||
@@ -34,6 +34,38 @@ describe('getSpanPhase', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransportRouteEndpoints', () => {
|
||||
const pickup = { role: 'from', lat: 48.1, lng: 11.5 }
|
||||
const dropoff = { role: 'to', lat: 52.5, lng: 13.4 }
|
||||
// A car rental spanning day 1 (pickup) through day 3 (drop-off).
|
||||
const rental = { day_id: 1, end_day_id: 3, endpoints: [pickup, dropoff] }
|
||||
|
||||
it('routes to the pickup only on the start day of a multi-day rental', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 1)).toEqual({ from: { lat: 48.1, lng: 11.5 }, to: null })
|
||||
})
|
||||
|
||||
it('routes from the drop-off only on the end day', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 3)).toEqual({ from: null, to: { lat: 52.5, lng: 13.4 } })
|
||||
})
|
||||
|
||||
it('adds no waypoints on the days in between (regression for #1210)', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 2)).toEqual({ from: null, to: null })
|
||||
})
|
||||
|
||||
it('uses both endpoints for a single-day transport', () => {
|
||||
const sameDay = { day_id: 1, end_day_id: 1, endpoints: [pickup, dropoff] }
|
||||
expect(getTransportRouteEndpoints(sameDay, 1)).toEqual({
|
||||
from: { lat: 48.1, lng: 11.5 },
|
||||
to: { lat: 52.5, lng: 13.4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns nulls when the endpoints carry no coordinates', () => {
|
||||
const noCoords = { day_id: 1, end_day_id: 1, endpoints: [{ role: 'from' }, { role: 'to' }] }
|
||||
expect(getTransportRouteEndpoints(noCoords, 1)).toEqual({ from: null, to: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDisplayTimeForDay', () => {
|
||||
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||
|
||||
|
||||
@@ -29,6 +29,33 @@ export function getSpanPhase(
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
/**
|
||||
* The route waypoints a transport contributes on a given day, respecting multi-day spans.
|
||||
* A car rental (or any reservation whose span covers several days) is only routed to on its
|
||||
* pickup day (the departure endpoint) and from on its drop-off day (the arrival endpoint) — on
|
||||
* the days in between you simply hold the vehicle, so it adds no waypoints and must not pull the
|
||||
* route to those points. Single-day transports contribute both endpoints.
|
||||
*/
|
||||
export function getTransportRouteEndpoints(
|
||||
r: any,
|
||||
dayId: number
|
||||
): { from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null } {
|
||||
const ep = (role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
switch (getSpanPhase(r, dayId)) {
|
||||
case 'start':
|
||||
return { from: ep('from'), to: null }
|
||||
case 'end':
|
||||
return { from: null, to: ep('to') }
|
||||
case 'middle':
|
||||
return { from: null, to: null }
|
||||
default:
|
||||
return { from: ep('from'), to: ep('to') }
|
||||
}
|
||||
}
|
||||
|
||||
export function getDisplayTimeForDay(
|
||||
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||
dayId: number
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 321 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 792 KiB After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 2.1 MiB |
@@ -155,6 +155,17 @@ export async function createBackup(): Promise<BackupInfo> {
|
||||
archive.file(dbPath, { name: 'travel.db' });
|
||||
}
|
||||
|
||||
// Bundle the at-rest encryption key so the backup is self-contained: the
|
||||
// DB stores secrets (API keys, MFA, SMTP/OIDC) encrypted with this key, so
|
||||
// a restore onto a different install would otherwise be unable to decrypt
|
||||
// them. NOTE: this makes the backup file as sensitive as the key itself —
|
||||
// store/transfer it securely. Skipped when ENCRYPTION_KEY is provided via
|
||||
// env, since in that case the file is not the source of truth.
|
||||
const encKeyPath = path.join(dataDir, '.encryption_key');
|
||||
if (!process.env.ENCRYPTION_KEY && fs.existsSync(encKeyPath)) {
|
||||
archive.file(encKeyPath, { name: '.encryption_key' });
|
||||
}
|
||||
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
// Exclude the place-photo and trek-memory caches: both are re-derivable
|
||||
// (re-fetched on demand, keyed on stable ids) and would otherwise dominate
|
||||
@@ -252,6 +263,16 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
fs.copyFileSync(extractedDb, dbDest);
|
||||
|
||||
// Restore the bundled at-rest encryption key (if the archive carries one)
|
||||
// so the restored DB's encrypted secrets can be decrypted. Only the file
|
||||
// is swapped here; the in-memory key was read at startup, so a restart is
|
||||
// required for it to take effect (and an explicit ENCRYPTION_KEY env var
|
||||
// still overrides the file).
|
||||
const extractedEncKey = path.join(extractDir, '.encryption_key');
|
||||
if (fs.existsSync(extractedEncKey)) {
|
||||
fs.copyFileSync(extractedEncKey, path.join(dataDir, '.encryption_key'));
|
||||
}
|
||||
|
||||
const extractedUploads = path.join(extractDir, 'uploads');
|
||||
if (fs.existsSync(extractedUploads)) {
|
||||
for (const sub of fs.readdirSync(uploadsDir)) {
|
||||
@@ -262,7 +283,12 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
|
||||
// Copy into the real directory behind uploadsDir. In Docker, uploadsDir
|
||||
// (/app/server/uploads) is a symlink to the mounted /app/uploads volume;
|
||||
// cpSync(dereference:false) would otherwise try to overwrite the symlink
|
||||
// node with a directory and throw ERR_FS_CP_DIR_TO_NON_DIR. realpathSync
|
||||
// is a no-op when uploadsDir is a plain directory (dev/non-Docker).
|
||||
fs.cpSync(extractedUploads, fs.realpathSync(uploadsDir), { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
// Reopening the DB must always run (even if the copy above threw) so the
|
||||
|
||||
@@ -16,11 +16,11 @@ function isAlwaysBlocked(ip: string): boolean {
|
||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||
|
||||
// Loopback
|
||||
if (addr.startsWith("127.") || addr === '::1') return true;
|
||||
if (addr.startsWith('127.') || addr === '::1') return true;
|
||||
// Unspecified
|
||||
if (addr.startsWith("0.")) return true;
|
||||
if (addr.startsWith('0.')) return true;
|
||||
// Link-local / cloud metadata
|
||||
if (addr.startsWith("169.254.") || /^fe80:/i.test(addr)) return true;
|
||||
if (addr.startsWith('169.254.') || /^fe80:/i.test(addr)) return true;
|
||||
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
|
||||
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
|
||||
|
||||
@@ -32,9 +32,9 @@ function isPrivateNetwork(ip: string): boolean {
|
||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||
|
||||
// RFC-1918 private ranges
|
||||
if (addr.startsWith("10.")) return true;
|
||||
if (addr.startsWith('10.')) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
|
||||
if (addr.startsWith("192.168.")) return true;
|
||||
if (addr.startsWith('192.168.')) return true;
|
||||
// CGNAT / Tailscale shared address space (100.64.0.0/10)
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
|
||||
// IPv6 ULA (fc00::/7)
|
||||
@@ -71,8 +71,9 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
try {
|
||||
const result = await dns.lookup(hostname);
|
||||
resolvedIp = result.address;
|
||||
} catch {
|
||||
return { allowed: false, isPrivate: false, error: 'Could not resolve hostname' };
|
||||
} catch (error_) {
|
||||
const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown';
|
||||
return { allowed: false, isPrivate: false, error: `Could not resolve hostname (${code})` };
|
||||
}
|
||||
|
||||
if (isAlwaysBlocked(resolvedIp)) {
|
||||
@@ -90,7 +91,8 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
resolvedIp,
|
||||
error: 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
|
||||
error:
|
||||
'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
|
||||
};
|
||||
}
|
||||
return { allowed: true, isPrivate: true, resolvedIp };
|
||||
@@ -187,7 +189,7 @@ export async function safeFetchFollow(
|
||||
// (2xx/4xx/5xx, or a 3xx with no Location) is the final response.
|
||||
const status = typeof response.status === 'number' ? response.status : 0;
|
||||
const isRedirectStatus = status >= 300 && status < 400;
|
||||
const location = isRedirectStatus ? response.headers?.get('location') ?? null : null;
|
||||
const location = isRedirectStatus ? (response.headers?.get('location') ?? null) : null;
|
||||
if (!location) {
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ const fsMock = vi.hoisted(() => ({
|
||||
rmSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
cpSync: vi.fn(),
|
||||
// Identity by default: when uploadsDir is a plain directory, realpathSync
|
||||
// returns it unchanged. Tests that exercise the symlink case override this.
|
||||
realpathSync: vi.fn((p: string) => p),
|
||||
}));
|
||||
|
||||
const archiverInstanceMock = vi.hoisted(() => ({
|
||||
@@ -479,6 +482,71 @@ describe('BACKUP-036 createBackup', () => {
|
||||
// The re-derivable caches must not be archived verbatim.
|
||||
expect(archiverInstanceMock.directory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BACKUP-036f — bundles .encryption_key when present and ENCRYPTION_KEY env is unset', async () => {
|
||||
const prevEnvKey = process.env.ENCRYPTION_KEY;
|
||||
delete process.env.ENCRYPTION_KEY;
|
||||
try {
|
||||
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.encryption_key'));
|
||||
fsMock.mkdirSync.mockReturnValue(undefined);
|
||||
|
||||
const writableEvents: Record<string, Function> = {};
|
||||
const fakeWriteStream = {
|
||||
on: vi.fn((event: string, cb: Function) => {
|
||||
writableEvents[event] = cb;
|
||||
}),
|
||||
};
|
||||
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
|
||||
|
||||
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
|
||||
archiverInstanceMock.pipe.mockReturnValue(undefined);
|
||||
archiverInstanceMock.finalize.mockImplementation(() => {
|
||||
if (writableEvents['close']) writableEvents['close']();
|
||||
});
|
||||
archiverMock.mockReturnValue(archiverInstanceMock);
|
||||
|
||||
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
|
||||
|
||||
await createBackup();
|
||||
|
||||
expect(archiverInstanceMock.file).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.encryption_key'),
|
||||
{ name: '.encryption_key' },
|
||||
);
|
||||
} finally {
|
||||
process.env.ENCRYPTION_KEY = prevEnvKey;
|
||||
}
|
||||
});
|
||||
|
||||
it('BACKUP-036g — does NOT bundle .encryption_key when ENCRYPTION_KEY env is set', async () => {
|
||||
// setup.ts sets process.env.ENCRYPTION_KEY, so the env is the source of truth.
|
||||
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.encryption_key'));
|
||||
fsMock.mkdirSync.mockReturnValue(undefined);
|
||||
|
||||
const writableEvents: Record<string, Function> = {};
|
||||
const fakeWriteStream = {
|
||||
on: vi.fn((event: string, cb: Function) => {
|
||||
writableEvents[event] = cb;
|
||||
}),
|
||||
};
|
||||
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
|
||||
|
||||
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
|
||||
archiverInstanceMock.pipe.mockReturnValue(undefined);
|
||||
archiverInstanceMock.finalize.mockImplementation(() => {
|
||||
if (writableEvents['close']) writableEvents['close']();
|
||||
});
|
||||
archiverMock.mockReturnValue(archiverInstanceMock);
|
||||
|
||||
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
|
||||
|
||||
await createBackup();
|
||||
|
||||
expect(archiverInstanceMock.file).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('.encryption_key'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -856,6 +924,53 @@ describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => {
|
||||
|
||||
expect(dbMock.reinitialize).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BACKUP-045d — restores bundled .encryption_key when the archive carries one', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
setupAllTablesPresent();
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('travel.db')) return true;
|
||||
if (String(p).endsWith('.encryption_key')) return true; // extracted key present
|
||||
if (String(p).includes('uploads')) return false;
|
||||
return true;
|
||||
});
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.copyFileSync.mockReturnValue(undefined);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
const result = await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
// Key copied from the extract dir into the live data dir.
|
||||
expect(fsMock.copyFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.encryption_key'),
|
||||
expect.stringContaining('.encryption_key'),
|
||||
);
|
||||
});
|
||||
|
||||
it('BACKUP-045e — skips key restore when the archive has no .encryption_key', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
setupAllTablesPresent();
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('travel.db')) return true;
|
||||
if (String(p).endsWith('.encryption_key')) return false; // no key in archive
|
||||
if (String(p).includes('uploads')) return false;
|
||||
return true;
|
||||
});
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.copyFileSync.mockReturnValue(undefined);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
const result = await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(fsMock.copyFileSync).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('.encryption_key'),
|
||||
expect.stringContaining('.encryption_key'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
|
||||
@@ -912,6 +1027,64 @@ describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
|
||||
{ recursive: true, force: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('BACKUP-046b — copies into the symlink target, not the symlink itself (#1193)', async () => {
|
||||
// In Docker, uploadsDir (/app/server/uploads) is a symlink to the mounted
|
||||
// /app/uploads volume. cpSync(dereference:false) would throw
|
||||
// ERR_FS_CP_DIR_TO_NON_DIR overwriting the symlink node with a directory.
|
||||
// The fix resolves the symlink with realpathSync first, so the copy targets
|
||||
// the real directory behind it.
|
||||
setupSuccessfulExtraction();
|
||||
|
||||
const fakeDbInstance = {
|
||||
prepare: vi.fn()
|
||||
.mockReturnValueOnce({
|
||||
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
all: vi.fn().mockReturnValue([
|
||||
{ name: 'users' },
|
||||
{ name: 'trips' },
|
||||
{ name: 'trip_members' },
|
||||
{ name: 'places' },
|
||||
{ name: 'days' },
|
||||
]),
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
DatabaseMock.mockReturnValue(fakeDbInstance);
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('travel.db')) return true;
|
||||
if (String(p).includes('uploads')) return true;
|
||||
return true;
|
||||
});
|
||||
fsMock.readdirSync.mockImplementation((p: string) => {
|
||||
if (String(p).includes('uploads') && !String(p).includes('restore-')) {
|
||||
return ['photos'] as any;
|
||||
}
|
||||
if (String(p).includes('photos')) return ['img1.jpg'] as any;
|
||||
return [] as any;
|
||||
});
|
||||
fsMock.statSync.mockReturnValue({ isDirectory: () => true } as any);
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.copyFileSync.mockReturnValue(undefined);
|
||||
fsMock.cpSync.mockReturnValue(undefined);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
// Resolve the uploads symlink to a distinct real target directory.
|
||||
const REAL_TARGET = '/app/uploads';
|
||||
fsMock.realpathSync.mockReturnValueOnce(REAL_TARGET);
|
||||
|
||||
const result = await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
// The copy destination must be the resolved real path, never the symlink.
|
||||
expect(fsMock.cpSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('uploads'),
|
||||
REAL_TARGET,
|
||||
{ recursive: true, force: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('checkSsrf', () => {
|
||||
const result = await checkSsrf('http://nxdomain.example.com');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.isPrivate).toBe(false);
|
||||
expect(result.error).toBe('Could not resolve hostname');
|
||||
expect(result.error).toContain('Could not resolve hostname');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'تبديل العملات',
|
||||
'dashboard.aria.addTimezone': 'إضافة منطقة زمنية',
|
||||
'dashboard.aria.removeTimezone': 'إزالة {city}',
|
||||
'dashboard.dayCountRequired': 'عدد الأيام مطلوب',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Trocar moedas',
|
||||
'dashboard.aria.addTimezone': 'Adicionar fuso horário',
|
||||
'dashboard.aria.removeTimezone': 'Remover {city}',
|
||||
'dashboard.dayCountRequired': 'O número de dias é obrigatório',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Prohodit měny',
|
||||
'dashboard.aria.addTimezone': 'Přidat časové pásmo',
|
||||
'dashboard.aria.removeTimezone': 'Odebrat {city}',
|
||||
'dashboard.dayCountRequired': 'Počet dní je povinný',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -164,5 +164,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Währungen tauschen',
|
||||
'dashboard.aria.addTimezone': 'Zeitzone hinzufügen',
|
||||
'dashboard.aria.removeTimezone': '{city} entfernen',
|
||||
'dashboard.dayCountRequired': 'Anzahl der Tage ist erforderlich',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Swap currencies',
|
||||
'dashboard.aria.addTimezone': 'Add timezone',
|
||||
'dashboard.aria.removeTimezone': 'Remove {city}',
|
||||
'dashboard.dayCountRequired': 'Number of days is required',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -164,5 +164,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Intercambiar monedas',
|
||||
'dashboard.aria.addTimezone': 'Añadir zona horaria',
|
||||
'dashboard.aria.removeTimezone': 'Eliminar {city}',
|
||||
'dashboard.dayCountRequired': 'El número de días es obligatorio',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -167,5 +167,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Inverser les devises',
|
||||
'dashboard.aria.addTimezone': 'Ajouter un fuseau horaire',
|
||||
'dashboard.aria.removeTimezone': 'Supprimer {city}',
|
||||
'dashboard.dayCountRequired': 'Le nombre de jours est requis',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -166,5 +166,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Swap currencies', // en-fallback
|
||||
'dashboard.aria.addTimezone': 'Add timezone', // en-fallback
|
||||
'dashboard.aria.removeTimezone': 'Remove {city}', // en-fallback
|
||||
'dashboard.dayCountRequired': 'Ο αριθμός ημερών είναι υποχρεωτικός',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -164,5 +164,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Pénznemek cseréje',
|
||||
'dashboard.aria.addTimezone': 'Időzóna hozzáadása',
|
||||
'dashboard.aria.removeTimezone': '{city} eltávolítása',
|
||||
'dashboard.dayCountRequired': 'A napok száma kötelező',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Tukar mata uang',
|
||||
'dashboard.aria.addTimezone': 'Tambah zona waktu',
|
||||
'dashboard.aria.removeTimezone': 'Hapus {city}',
|
||||
'dashboard.dayCountRequired': 'Jumlah hari wajib diisi',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -167,5 +167,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Inverti valute',
|
||||
'dashboard.aria.addTimezone': 'Aggiungi fuso orario',
|
||||
'dashboard.aria.removeTimezone': 'Rimuovi {city}',
|
||||
'dashboard.dayCountRequired': 'Il numero di giorni è obbligatorio',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -162,5 +162,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': '通貨を入れ替え',
|
||||
'dashboard.aria.addTimezone': 'タイムゾーンを追加',
|
||||
'dashboard.aria.removeTimezone': '{city}を削除',
|
||||
'dashboard.dayCountRequired': '日数は必須です',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -162,5 +162,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': '통화 바꾸기',
|
||||
'dashboard.aria.addTimezone': '시간대 추가',
|
||||
'dashboard.aria.removeTimezone': '{city} 제거',
|
||||
'dashboard.dayCountRequired': '일수는 필수입니다',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': "Valuta's omwisselen",
|
||||
'dashboard.aria.addTimezone': 'Tijdzone toevoegen',
|
||||
'dashboard.aria.removeTimezone': '{city} verwijderen',
|
||||
'dashboard.dayCountRequired': 'Aantal dagen is verplicht',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Zamień waluty',
|
||||
'dashboard.aria.addTimezone': 'Dodaj strefę czasową',
|
||||
'dashboard.aria.removeTimezone': 'Usuń {city}',
|
||||
'dashboard.dayCountRequired': 'Liczba dni jest wymagana',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -163,5 +163,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Поменять валюты',
|
||||
'dashboard.aria.addTimezone': 'Добавить часовой пояс',
|
||||
'dashboard.aria.removeTimezone': 'Удалить {city}',
|
||||
'dashboard.dayCountRequired': 'Количество дней обязательно',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -162,5 +162,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Para birimlerini değiştir',
|
||||
'dashboard.aria.addTimezone': 'Saat dilimi ekle',
|
||||
'dashboard.aria.removeTimezone': '{city} kaldır',
|
||||
'dashboard.dayCountRequired': 'Gün sayısı gereklidir',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -164,5 +164,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': 'Поміняти валюти',
|
||||
'dashboard.aria.addTimezone': 'Додати часовий пояс',
|
||||
'dashboard.aria.removeTimezone': 'Вилучити {city}',
|
||||
'dashboard.dayCountRequired': 'Вкажіть кількість днів',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -161,5 +161,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': '交換貨幣',
|
||||
'dashboard.aria.addTimezone': '新增時區',
|
||||
'dashboard.aria.removeTimezone': '移除 {city}',
|
||||
'dashboard.dayCountRequired': '天數為必填項',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
@@ -161,5 +161,6 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.aria.swapCurrencies': '交换货币',
|
||||
'dashboard.aria.addTimezone': '添加时区',
|
||||
'dashboard.aria.removeTimezone': '移除 {city}',
|
||||
'dashboard.dayCountRequired': '天数为必填项',
|
||||
};
|
||||
export default dashboard;
|
||||
|
||||
Reference in New Issue
Block a user