Compare commits

...

17 Commits

Author SHA1 Message Date
elpatron f8dc6ace3c chore: release v0.1.0.79 2026-06-01 15:20:55 +02:00
elpatron 18f14d7e0b chore(deploy): run predeploy-check.sh from update-prod.sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:18:58 +02:00
elpatron 0edf4a789c feat(quality): Sprint 2 pre-deploy gates and server smoke tests
Extract Express app factory for testability, add Vitest/Supertest API
smoke tests, root npm run check script, and deployment docs. Fix
express-rate-limit IPv6 keyGenerator for feedback limiter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:17:46 +02:00
elpatron 4ef56aeb8f fix(ops): force-recreate backend after postgres password rotation
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:10:50 +02:00
elpatron 3263fbcec3 chore: restore merge skill accidentally removed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:09:24 +02:00
elpatron b9ce853059 feat(ops): script to rotate PostgreSQL password safely
Add rotate-postgres-password.sh with optional app role, document the
procedure, and stop defaulting production POSTGRES_PASSWORD to postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:09:15 +02:00
elpatron 3d8a505bd9 fix(nginx): security headers on index.html and PWA asset routes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:04:27 +02:00
elpatron e138752dd3 feat(security): Sprint 1 hardening for production behind NPM
Add trust proxy, WebAuthn challenge TTL, stricter public collaboration
rate limits, generic 500 responses, Docker POSTGRES_PASSWORD from env,
nginx security headers/CSP, and deployment documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:02:15 +02:00
elpatron b9c908169b chore: release v0.1.0.78 2026-06-01 13:44:36 +02:00
elpatron e6bde5c525 fix: shorten travel day badge to "Reisetag x"
Use travel_day_number i18n key in journal cards and live log subtitle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:41:27 +02:00
elpatron eab7b86c0b chore: release v0.1.0.77 2026-06-01 13:28:24 +02:00
elpatron b86789ae4c fix: show profile button while a logbook is open
Extract ProfileHeaderButton and open UserProfilePage from any screen
without leaving the active logbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 13:28:03 +02:00
elpatron 2a8ec2fccf chore: release v0.1.0.76 2026-06-01 11:43:33 +02:00
elpatron 60a8533a44 feat: add Plausible events for live log photos and OWM usage
Track Live Log Photo Uploaded and centralize OWM Weather Fetched with
source props for live log and entry editor call sites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:34:21 +02:00
elpatron c86ac4273c chore: release v0.1.0.75 2026-06-01 10:59:31 +02:00
elpatron 73467f2263 fix: live journal camera save on Android
Use native camera picker on mobile, add preview-and-save step, and
harden canvas capture with toDataURL fallback when toBlob fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:45:54 +02:00
elpatron e068f083c1 style: add blank line before LiveLogViewProps interface
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:01:34 +02:00
45 changed files with 3194 additions and 253 deletions
+14 -1
View File
@@ -6,10 +6,23 @@ DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin)
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
# Production (kapteins-daagbok.eu):
# RP_ID=kapteins-daagbok.eu
# ORIGIN=https://kapteins-daagbok.eu
RP_ID=localhost
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md
# TRUST_PROXY=172.16.10.10
# TRUST_PROXY=1
# Docker Compose database (required for production deploy)
# Generate: openssl rand -hex 24
# Rotate on running server: ./scripts/rotate-postgres-password.sh (see docs/deployment/postgres-password.md)
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173
+18 -6
View File
@@ -219,13 +219,19 @@ cd server && npx prisma db push && cd ..
| Health Check | http://localhost:5000/api/health |
| Public Demo | http://localhost:5173/demo |
### 5. Tests (Frontend)
### 5. Qualität & Tests
Vor jedem Deploy auf [kapteins-daagbok.eu](https://kapteins-daagbok.eu/) (kein externes CI):
```bash
cd client && npm test
npm run check
# oder: ./scripts/predeploy-check.sh
```
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen).
Einzeln: `npm test` (Client + Server) · `npm run build` · optional `npm run lint` (Client, noch nicht in `check`)
- **Client:** Vitest für Utils, i18n, Services
- **Server:** Smoke-Tests (`/api/health`, Auth-Guards) mit Supertest — siehe `server/src/api.smoke.test.ts`
## Docker (produktionsnah)
@@ -237,11 +243,12 @@ Gesamten Stack lokal bauen und starten:
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Für Feedback `NTFY_*` setzen.
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`), `SESSION_SECRET` und für Docker Compose `POSTGRES_PASSWORD`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Für Feedback `NTFY_*` setzen.
## Deployment
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führt vor dem SSH-Deploy automatisch [`predeploy-check.sh`](scripts/predeploy-check.sh) aus (`npm run check`):
```bash
./scripts/update-prod.sh
@@ -249,12 +256,17 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
## Dokumentation
| Dokument | Inhalt |
|----------|--------|
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
+1 -1
View File
@@ -1 +1 @@
0.1.0.75
0.1.0.80
+19 -2
View File
@@ -3,15 +3,32 @@ server {
server_name localhost;
client_max_body_size 50M;
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate";
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
+38
View File
@@ -3607,6 +3607,44 @@ html.theme-cupertino .events-scroll-container {
gap: 8px;
}
.live-camera-file-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.live-camera-preview-still {
display: block;
object-fit: contain;
background: #000;
}
.live-camera-native-prompt {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
margin-bottom: 12px;
}
.live-camera-open-native {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
}
.live-camera-actions {
flex-wrap: wrap;
}
.stats-event-series-block + .stats-event-series-block {
margin-top: 16px;
}
+20 -12
View File
@@ -44,6 +44,7 @@ import { useLiveQuery } from 'dexie-react-hooks'
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage } from './utils/i18nLanguages.js'
import {
@@ -557,22 +558,27 @@ function App() {
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
if (showUserProfile) {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
</div>
)
}
if (!activeLogbookId) {
return (
<div style={{ display: 'contents' }}>
{pwaInstallBanner}
{showUserProfile ? (
<UserProfilePage
onBack={() => setShowUserProfile(false)}
onLogout={handleLogout}
/>
) : (
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
)}
<LogbookDashboard
onSelectLogbook={selectLogbook}
onLogout={handleLogout}
onOpenProfile={() => setShowUserProfile(true)}
/>
</div>
)
}
@@ -622,6 +628,8 @@ function App() {
<Languages size={18} />
</button>
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
<DisclaimerHeaderButton />
<FeedbackHeaderButton
+173 -34
View File
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react'
import {
captureVideoFrame,
preferNativeCameraPicker
} from '../utils/captureVideoFrame.js'
interface LiveCameraCaptureProps {
open: boolean
@@ -11,6 +15,8 @@ interface LiveCameraCaptureProps {
onCapture: (blob: Blob) => void
}
type Phase = 'live' | 'preview' | 'native'
export default function LiveCameraCapture({
open,
busy = false,
@@ -21,9 +27,26 @@ export default function LiveCameraCapture({
}: LiveCameraCaptureProps) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const previewUrlRef = useRef<string | null>(null)
const [cameraError, setCameraError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const [capturing, setCapturing] = useState(false)
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [streamGeneration, setStreamGeneration] = useState(0)
const clearPreview = useCallback(() => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
previewUrlRef.current = null
}
setPreviewUrl(null)
setPreviewBlob(null)
}, [])
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
@@ -36,10 +59,44 @@ export default function LiveCameraCapture({
setReady(false)
}, [])
const enterPreview = useCallback((blob: Blob) => {
stopStream()
clearPreview()
const url = URL.createObjectURL(blob)
previewUrlRef.current = url
setPreviewBlob(blob)
setPreviewUrl(url)
setPhase('preview')
}, [stopStream, clearPreview])
const resetToLive = useCallback(() => {
clearPreview()
setCameraError(null)
setCapturing(false)
if (preferNativeCameraPicker()) {
setPhase('native')
} else {
setPhase('live')
setStreamGeneration((n) => n + 1)
}
}, [clearPreview])
useEffect(() => {
if (!open) {
stopStream()
clearPreview()
setCameraError(null)
setCapturing(false)
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
return
}
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
clearPreview()
}, [open, stopStream, clearPreview])
useEffect(() => {
if (!open || phase !== 'live') {
stopStream()
return
}
@@ -68,11 +125,19 @@ export default function LiveCameraCapture({
}
streamRef.current = stream
const video = videoRef.current
if (video) {
video.srcObject = stream
await video.play()
setReady(true)
if (!video) return
const markReady = () => {
if (cancelled) return
if (video.videoWidth > 0 && video.videoHeight > 0) {
setReady(true)
}
}
video.onloadedmetadata = markReady
video.srcObject = stream
await video.play()
markReady()
} catch (err) {
console.error('Camera access failed:', err)
if (!cancelled) {
@@ -86,34 +151,58 @@ export default function LiveCameraCapture({
cancelled = true
stopStream()
}
}, [open, stopStream, t])
}, [open, phase, streamGeneration, stopStream, t])
const handleCapture = () => {
const handleCapture = async () => {
const video = videoRef.current
if (!video || !ready || busy) return
if (!video || !ready || busy || capturing) return
const width = video.videoWidth
const height = video.videoHeight
if (!width || !height) return
setCapturing(true)
setCameraError(null)
try {
const blob = await captureVideoFrame(video)
enterPreview(blob)
} catch (err) {
console.error('Live camera capture failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
} finally {
setCapturing(false)
}
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(video, 0, 0, width, height)
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || busy) return
canvas.toBlob(
(blob) => {
if (blob) onCapture(blob)
},
'image/jpeg',
0.92
)
setCameraError(null)
try {
enterPreview(file)
} catch (err) {
console.error('Live camera file pick failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
}
}
const handleSave = () => {
if (!previewBlob || busy) return
onCapture(previewBlob)
}
const handleRetake = () => {
if (busy) return
resetToLive()
}
const openNativePicker = () => {
if (busy) return
fileInputRef.current?.click()
}
if (!open) return null
const showPreview = phase === 'preview' && previewUrl
return (
<div
className="live-log-modal-backdrop live-camera-backdrop"
@@ -133,9 +222,41 @@ export default function LiveCameraCapture({
</button>
</div>
{cameraError ? (
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
className="live-camera-file-input"
onChange={(e) => void handleNativeFile(e)}
/>
{cameraError && (
<p className="live-log-modal-hint auth-error">{cameraError}</p>
) : (
)}
{showPreview ? (
<div className="live-camera-preview-wrap">
<img
src={previewUrl}
alt=""
className="live-camera-preview live-camera-preview-still"
/>
</div>
) : phase === 'native' ? (
<div className="live-camera-native-prompt">
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
<button
type="button"
className="btn primary live-camera-open-native"
onClick={openNativePicker}
disabled={busy}
>
<Camera size={18} />
{t('logs.live_photo_open_camera_btn')}
</button>
</div>
) : cameraError && !ready ? null : (
<div className="live-camera-preview-wrap">
<video
ref={videoRef}
@@ -168,15 +289,33 @@ export default function LiveCameraCapture({
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
{t('logs.confirm_no')}
</button>
<button
type="button"
className="btn primary live-camera-shutter"
onClick={handleCapture}
disabled={busy || !ready || !!cameraError}
>
<Camera size={18} />
{busy ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
</button>
{showPreview ? (
<>
<button type="button" className="btn secondary" onClick={handleRetake} disabled={busy}>
{t('logs.live_photo_retake_btn')}
</button>
<button
type="button"
className="btn primary live-camera-shutter"
onClick={handleSave}
disabled={busy || !previewBlob}
>
<Camera size={18} />
{busy ? t('logs.photo_processing') : t('logs.live_photo_save_btn')}
</button>
</>
) : phase === 'native' ? null : (
<button
type="button"
className="btn primary live-camera-shutter"
onClick={() => void handleCapture()}
disabled={busy || capturing || !ready || !!cameraError}
>
<Camera size={18} />
{capturing ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
</button>
)}
</div>
</div>
</div>
+6 -4
View File
@@ -65,6 +65,7 @@ import CourseDialInput from './CourseDialInput.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
interface LiveLogViewProps {
logbookId: string
onOpenEditor: (entryId: string) => void
@@ -473,7 +474,10 @@ export default function LiveLogView({
try {
let data: Record<string, unknown>
try {
data = await fetchOpenWeatherCurrent({ lat, lon: lng })
data = await fetchOpenWeatherCurrent(
{ lat, lon: lng },
{ analyticsSource: 'live_log' }
)
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
@@ -514,7 +518,6 @@ export default function LiveLogView({
await appendQuickEvents(logbookId, id, partials)
await refreshEntry(id)
showUndo()
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'weather_owm' })
} catch (err: unknown) {
console.error('Live log OWM weather save failed:', err)
setError(err instanceof Error ? err.message : t('logs.live_action_error'))
@@ -574,7 +577,6 @@ export default function LiveLogView({
setModal('none')
setPhotoCaption('')
showUndo('photo')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'photo' })
} catch (err: unknown) {
console.error('Live log photo save failed:', err)
void showAlert(
@@ -763,7 +765,7 @@ export default function LiveLogView({
<h2>{t('logs.live_title')}</h2>
{date && (
<p className="live-log-subtitle">
{t('logs.day_of_travel')} {dayOfTravel} · {new Date(date).toLocaleDateString()}
{t('logs.travel_day_number', { number: dayOfTravel })} · {new Date(date).toLocaleDateString()}
</p>
)}
</div>
+1 -1
View File
@@ -474,7 +474,7 @@ export default function LogEntriesList({
</h3>
<div className="card-meta">
<span className="sync-badge synced">
{t('logs.day_of_travel')} {item.dayOfTravel}
{t('logs.travel_day_number', { number: item.dayOfTravel })}
</span>
<EntrySkipperSignBadge status={item.skipperSignStatus} />
<span className="date-badge">
+6 -2
View File
@@ -900,7 +900,10 @@ export default function LogEntryEditor({
}
try {
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
const data = await fetchOpenWeatherCurrent(
{ q: locationQuery },
{ analyticsSource: 'entry_editor_gps_lookup' }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
if (coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
@@ -955,7 +958,8 @@ export default function LogEntryEditor({
const data = await fetchOpenWeatherCurrent(
hasGps
? { lat: evGpsLat, lon: evGpsLng }
: { q: fallbackLocation }
: { q: fallbackLocation },
{ analyticsSource: 'entry_editor' }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
+3 -14
View File
@@ -8,9 +8,10 @@ import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
@@ -74,7 +75,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null)
const [online, setOnline] = useState(navigator.onLine)
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
@@ -370,18 +370,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
)}
</div>
{/* Skipper profile */}
<button
type="button"
className="btn-icon skipper-badge"
onClick={onOpenProfile}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</button>
<ProfileHeaderButton onClick={onOpenProfile} />
{/* Lang toggle */}
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next'
import { User } from 'lucide-react'
interface ProfileHeaderButtonProps {
onClick: () => void
}
export default function ProfileHeaderButton({ onClick }: ProfileHeaderButtonProps) {
const { t } = useTranslation()
const username = localStorage.getItem('active_username') || 'Skipper'
return (
<button
type="button"
className="btn-icon skipper-badge"
onClick={onClick}
title={t('dashboard.open_profile', { name: username })}
aria-label={t('dashboard.open_profile', { name: username })}
data-tour="nav-profile"
>
<User size={18} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</button>
)
}
+7 -1
View File
@@ -150,7 +150,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
"date": "dato",
"day_of_travel": "Rejsedag / rejsedag",
"day_of_travel": "Rejsedag",
"travel_day_number": "Rejsedag {{number}}",
"departure": "Starthavn (rejse fra)",
"destination": "Destinationsport (til)",
"route": "Rejse fra/til",
@@ -235,6 +236,11 @@
"live_fix_lng_placeholder": "Længde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Tag billede",
"live_photo_save_btn": "Gem",
"live_photo_retake_btn": "Tag igen",
"live_photo_capture_failed": "Optagelse mislykkedes. Prøv igen.",
"live_photo_open_camera_btn": "Åbn kamera",
"live_photo_native_hint": "Tag et foto med enhedens kamera og gem det her bagefter.",
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
+7 -1
View File
@@ -150,7 +150,8 @@
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
"date": "Datum",
"day_of_travel": "Tag der Reise / Reisetag",
"day_of_travel": "Reisetag",
"travel_day_number": "Reisetag {{number}}",
"departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach",
@@ -235,6 +236,11 @@
"live_fix_lng_placeholder": "Länge (Lng)",
"live_photo_btn": "Foto (Kamera)",
"live_photo_capture_btn": "Aufnehmen",
"live_photo_save_btn": "Speichern",
"live_photo_retake_btn": "Neu aufnehmen",
"live_photo_capture_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
"live_photo_open_camera_btn": "Kamera öffnen",
"live_photo_native_hint": "Foto mit der Gerätekamera aufnehmen und anschließend hier speichern.",
"live_photo_camera_starting": "Kamera wird gestartet…",
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
+7 -1
View File
@@ -150,7 +150,8 @@
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
"date": "Date",
"day_of_travel": "Day of Travel",
"day_of_travel": "Travel day",
"travel_day_number": "Travel day {{number}}",
"departure": "Departure Port (von)",
"destination": "Destination Port (nach)",
"route": "Route / Journey",
@@ -235,6 +236,11 @@
"live_fix_lng_placeholder": "Longitude (Lng)",
"live_photo_btn": "Photo (camera)",
"live_photo_capture_btn": "Capture",
"live_photo_save_btn": "Save",
"live_photo_retake_btn": "Retake",
"live_photo_capture_failed": "Capture failed. Please try again.",
"live_photo_open_camera_btn": "Open camera",
"live_photo_native_hint": "Take a photo with your device camera, then save it here.",
"live_photo_camera_starting": "Starting camera…",
"live_photo_camera_denied": "Camera access denied or unavailable.",
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
+7 -1
View File
@@ -150,7 +150,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
"date": "dato",
"day_of_travel": "Reisens dag / reisedag",
"day_of_travel": "Reisedag",
"travel_day_number": "Reisedag {{number}}",
"departure": "Starthavn (reise fra)",
"destination": "Destinasjonsport (til)",
"route": "Reise fra/til",
@@ -235,6 +236,11 @@
"live_fix_lng_placeholder": "Lengde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta bilde",
"live_photo_save_btn": "Lagre",
"live_photo_retake_btn": "Ta på nytt",
"live_photo_capture_failed": "Opptak mislyktes. Prøv igjen.",
"live_photo_open_camera_btn": "Åpne kamera",
"live_photo_native_hint": "Ta et bilde med enhetskameraet og lagre det her etterpå.",
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
+7 -1
View File
@@ -150,7 +150,8 @@
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
"date": "datum",
"day_of_travel": "Resedag / resedag",
"day_of_travel": "Resedag",
"travel_day_number": "Resedag {{number}}",
"departure": "Starthamn (resa från)",
"destination": "Destinationsport (till)",
"route": "Resa från/till",
@@ -235,6 +236,11 @@
"live_fix_lng_placeholder": "Longitud (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta foto",
"live_photo_save_btn": "Spara",
"live_photo_retake_btn": "Ta om",
"live_photo_capture_failed": "Bildtagning misslyckades. Försök igen.",
"live_photo_open_camera_btn": "Öppna kamera",
"live_photo_native_hint": "Ta ett foto med enhetens kamera och spara det här efteråt.",
"live_photo_camera_starting": "Startar kamera…",
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
+6 -1
View File
@@ -39,9 +39,14 @@ export const PlausibleEvents = {
NMEA_IMPORTED: 'NMEA Imported',
NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged'
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched'
} as const
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean>
+3
View File
@@ -55,6 +55,9 @@ export async function saveEntryPhoto(options: {
})
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return photoId
}
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PlausibleEvents } from './analytics.js'
const apiFetch = vi.fn()
const trackPlausibleEvent = vi.fn()
vi.mock('./api.js', () => ({ apiFetch }))
vi.mock('./analytics.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./analytics.js')>()
return {
...actual,
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
}
})
vi.mock('./userPreferences.js', () => ({
getOwmApiKeyForActiveUser: () => ''
}))
describe('fetchOpenWeatherCurrent', () => {
beforeEach(() => {
apiFetch.mockReset()
trackPlausibleEvent.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('tracks OWM Weather Fetched on success when analyticsSource is set', async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ coord: { lat: 54, lon: 10 }, main: { temp: 20 } })
})
const { fetchOpenWeatherCurrent } = await import('./weather.js')
await fetchOpenWeatherCurrent(
{ lat: '54.0', lon: '10.0' },
{ analyticsSource: 'live_log' }
)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.OWM_WEATHER_FETCHED, {
source: 'live_log'
})
})
it('does not track when the API request fails', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: 'fail' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
await expect(
fetchOpenWeatherCurrent({ lat: '54', lon: '10' }, { analyticsSource: 'entry_editor' })
).rejects.toBeInstanceOf(WeatherApiError)
expect(trackPlausibleEvent).not.toHaveBeenCalled()
})
})
+19 -5
View File
@@ -1,5 +1,10 @@
import { apiFetch } from './api.js'
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
import {
type OwmAnalyticsSource,
PlausibleEvents,
trackPlausibleEvent
} from './analytics.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
@@ -13,11 +18,14 @@ export class WeatherApiError extends Error {
const OWM_FETCH_TIMEOUT_MS = 20_000
export async function fetchOpenWeatherCurrent(params: {
lat?: string
lon?: string
q?: string
}): Promise<Record<string, unknown>> {
export async function fetchOpenWeatherCurrent(
params: {
lat?: string
lon?: string
q?: string
},
options?: { analyticsSource: OwmAnalyticsSource }
): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams()
if (params.lat && params.lon) {
@@ -59,5 +67,11 @@ export async function fetchOpenWeatherCurrent(params: {
throw new WeatherApiError('Weather API rejected the request')
}
if (options?.analyticsSource) {
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
source: options.analyticsSource
})
}
return data
}
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
import { captureVideoFrame, preferNativeCameraPicker } from './captureVideoFrame.js'
describe('preferNativeCameraPicker', () => {
it('returns true on Android user agents', () => {
vi.stubGlobal('navigator', { ...navigator, userAgent: 'Mozilla/5.0 (Linux; Android 14)' })
expect(preferNativeCameraPicker()).toBe(true)
vi.unstubAllGlobals()
})
it('returns false on desktop without touch', () => {
vi.stubGlobal('navigator', {
...navigator,
userAgent: 'Mozilla/5.0 (Windows NT 10.0)',
maxTouchPoints: 0
})
vi.stubGlobal('matchMedia', () => ({
matches: false,
addEventListener: () => {},
removeEventListener: () => {}
}))
Object.defineProperty(window, 'ontouchstart', { value: undefined, configurable: true })
expect(preferNativeCameraPicker()).toBe(false)
vi.unstubAllGlobals()
})
})
describe('captureVideoFrame', () => {
it('throws when video dimensions are zero', async () => {
const video = { videoWidth: 0, videoHeight: 0 } as HTMLVideoElement
await expect(captureVideoFrame(video)).rejects.toThrow('video_frame_not_ready')
})
})
+59
View File
@@ -0,0 +1,59 @@
/** Capture current video frame as JPEG blob (with Android-safe fallbacks). */
export async function captureVideoFrame(video: HTMLVideoElement, quality = 0.92): Promise<Blob> {
const width = video.videoWidth
const height = video.videoHeight
if (!width || !height) {
throw new Error('video_frame_not_ready')
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('canvas_context_unavailable')
}
ctx.drawImage(video, 0, 0, width, height)
const blob = await canvasToJpegBlob(canvas, quality)
if (blob) return blob
const dataUrl = canvas.toDataURL('image/jpeg', quality)
const response = await fetch(dataUrl)
const fallback = await response.blob()
if (!fallback.size) {
throw new Error('capture_encode_failed')
}
return fallback
}
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
return new Promise((resolve) => {
let settled = false
const finish = (blob: Blob | null) => {
if (settled) return
settled = true
window.clearTimeout(timer)
resolve(blob)
}
const timer = window.setTimeout(() => finish(null), 3000)
try {
canvas.toBlob((blob) => finish(blob), 'image/jpeg', quality)
} catch {
finish(null)
}
})
}
/** Mobile: native camera via file input is more reliable than getUserMedia + canvas. */
export function preferNativeCameraPicker(): boolean {
if (typeof window === 'undefined') return false
const ua = navigator.userAgent
if (/Android|iPhone|iPad|iPod/i.test(ua)) return true
const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const coarse = window.matchMedia('(pointer: coarse)').matches
const narrow = window.matchMedia('(max-width: 768px)').matches
return touch && (coarse || narrow)
}
+7 -5
View File
@@ -4,13 +4,14 @@ services:
container_name: daagbox-prod-db
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: daagbox
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:-daagbox}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d daagbox"]
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
# Not published to the host — reachable only on the Compose network (do not add ports: here)
interval: 5s
timeout: 5s
retries: 5
@@ -23,9 +24,10 @@ services:
restart: always
environment:
PORT: 5000
DATABASE_URL: "postgresql://postgres:postgres@db:5432/daagbox?schema=public"
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox}?schema=public"
RP_ID: ${RP_ID:-localhost}
ORIGIN: ${ORIGIN:-http://localhost}
TRUST_PROXY: ${TRUST_PROXY:-1}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
+60
View File
@@ -0,0 +1,60 @@
# Deployment: Nginx Proxy Manager & Security (Sprint 1)
Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf den App-Stack (`172.16.10.110`).
## NPM Proxy Host
| Einstellung | Wert |
|-------------|------|
| Domain | `kapteins-daagbok.eu` |
| Scheme | `https` |
| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) |
| Forward Port | `80` (Frontend-Nginx) |
| Websockets | an, falls genutzt |
| Block Common Exploits | an |
| SSL | Let's Encrypt o. ä. |
### Custom Nginx (Advanced) — empfohlen
NPM setzt `X-Forwarded-*` in der Regel automatisch. Falls nicht, im Proxy-Host unter **Advanced**:
```nginx
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
```
## Backend-Umgebung (`.env` auf dem Server)
```env
ORIGIN=https://kapteins-daagbok.eu
RP_ID=kapteins-daagbok.eu
SESSION_SECRET=<min. 32 Zeichen, openssl rand -base64 48>
TRUST_PROXY=172.16.10.10
# oder TRUST_PROXY=1 für genau einen Proxy-Hop
```
`ORIGIN` muss **exakt** der Browser-URL entsprechen (ohne trailing slash).
## Security-Header
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf`](../../client/nginx.conf) (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP inkl. Plausible).
### Plausible Analytics
Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`.
Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz.
## Nach Deploy prüfen
1. https://kapteins-daagbok.eu/api/health — `status: ok`
2. Passkey Login / Registrierung
3. DevTools → Application → Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax`
4. Response-Header auf `index.html`: CSP, `X-Frame-Options`
5. Zwei Geräte hinter NAT: unabhängige Rate-Limits (nicht alle als eine IP)
## Docker Compose
Keine Default-Passwörter in Produktion: starkes `POSTGRES_PASSWORD` (siehe [postgres-password.md](postgres-password.md)) und `SESSION_SECRET` in `.env` setzen (siehe [`.env.example`](../../.env.example)).
+42
View File
@@ -0,0 +1,42 @@
# PostgreSQL absichern (Produktion)
## Ist-Zustand
- Die Datenbank läuft im Container `daagbox-prod-db` **ohne** Host-Port (nur Docker-Netz `db:5432`) — gut.
- Das Passwort wird beim **ersten** Start des Volumes gesetzt; ein späteres Ändern nur von `POSTGRES_PASSWORD` in `.env` **ändert nicht** das laufende Passwort.
- Nach Sprint 1 war auf dem Server noch das Legacy-Passwort `postgres` möglich → per Skript rotieren.
## Empfohlene Schritte
1. **Backup/Snapshot** (hast du laut Vorgabe).
2. Auf dem Server im Repo:
```bash
cd /opt/kapteins-daagbok
git pull
chmod +x scripts/rotate-postgres-password.sh
./scripts/rotate-postgres-password.sh
```
3. Inhalt von `.postgres-credentials.<timestamp>` in den Passwort-Manager übernehmen, Datei auf dem Server löschen:
```bash
shred -u .postgres-credentials.* # oder rm nach manuellem Notieren
```
### Optional: eigener App-Benutzer (statt `postgres` für Prisma)
```bash
./scripts/rotate-postgres-password.sh --app-user daagbok
```
- **`daagbok`**: Login für Backend/Prisma (kein Superuser)
- **`postgres`**: nur noch Admin (Passwort in `POSTGRES_ADMIN_PASSWORD` in `.env`)
## Lokale Entwicklung
`scripts/start-dev.sh` nutzt weiterhin `postgres/postgres` auf localhost — nur für Dev. Produktion nie dieses Passwort wiederverwenden.
## Verifikation
```bash
docker exec daagbox-prod-backend wget -qO- http://127.0.0.1:5000/api/health
curl -sf https://kapteins-daagbok.eu/api/health
```
+42
View File
@@ -0,0 +1,42 @@
# Pre-Deploy-Checks (ohne CI)
Vor jedem Update auf **https://kapteins-daagbok.eu/** lokal ausführen:
```bash
npm run check
```
Das Skript [`scripts/predeploy-check.sh`](../../scripts/predeploy-check.sh) führt aus:
1. i18n-Key-Validierung (`validate:i18n`)
2. Client: `test``build` (TypeScript via `tsc -b`)
3. Server: `test``build`
## Einzelbefehle (Repo-Root)
| Befehl | Inhalt |
|--------|--------|
| `npm run lint` | ESLint (Client) — optional, noch nicht Teil von `check` |
| `npm run test` | Vitest Client + Server |
| `npm run build` | Production-Build beider Pakete |
| `npm run predeploy` | Alias für `npm run check` |
## Server-Tests
Smoke-Tests in `server/src/api.smoke.test.ts` — keine echte Datenbank (Prisma gemockt). Prüfen u. a. Health, 401 ohne Session, öffentliche Collaboration-Validierung.
```bash
cd server && npm test
```
## Nach erfolgreichem Check
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
```bash
./scripts/update-prod.sh
```
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh`
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
+19 -2
View File
@@ -36,7 +36,9 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
@@ -80,6 +82,18 @@ Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel
| `comment` | Kommentar |
| `undo` | Letztes Ereignis rückgängig |
### OWM-Quellen
Property `source` bei **OWM Weather Fetched** — ein Event pro erfolgreichem API-Call (keine Koordinaten, kein Ortsname):
| `source` | Auslöser |
|----------|----------|
| `live_log` | OpenWeatherMap-Wetter im Live-Journal (`LiveLogView.tsx`) |
| `entry_editor` | Wetter-Button im Reisetag-Editor (`LogEntryEditor.tsx`, `handleFetchWeather`) |
| `entry_editor_gps_lookup` | GPS-Fallback per Ortsname im Reisetag-Editor (`LogEntryEditor.tsx`, `handleGetGps`) |
Fehlgeschlagene Abrufe (kein API-Key, Timeout, leere Antwort) lösen **kein** Event aus.
## Bewusst nicht getrackt
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
@@ -106,7 +120,8 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`)
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
## Entwicklung
@@ -117,6 +132,8 @@ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
```
+6 -1
View File
@@ -7,6 +7,11 @@
"translate:flyer": "node scripts/translate-flyer.mjs",
"validate:i18n": "node scripts/validate-i18n-keys.mjs",
"generate:flyer": "node scripts/generate-beta-flyer.mjs",
"generate:flyer:all": "node scripts/generate-beta-flyer.mjs --all"
"generate:flyer:all": "node scripts/generate-beta-flyer.mjs --all",
"lint": "npm run lint --prefix client",
"test": "npm run test --prefix client && npm run test --prefix server",
"build": "npm run build --prefix client && npm run build --prefix server",
"check": "bash scripts/predeploy-check.sh",
"predeploy": "npm run check"
}
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Local quality gates before deploying to kapteins-daagbok.eu (no external CI).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
echo "=================================================="
echo " Kapteins Daagbok — pre-deploy checks"
echo "=================================================="
run() {
echo ""
echo "==> $*"
"$@"
}
run npm run validate:i18n
pushd client >/dev/null
if [ ! -d node_modules ]; then
run npm ci
fi
# Lint: run separately with `npm run lint` (client ESLint; cleanup tracked separately)
run npm run test
run npm run build
popd >/dev/null
pushd server >/dev/null
if [ ! -d node_modules ]; then
run npm ci
fi
run npm run test
run npm run build
popd >/dev/null
echo ""
echo "=================================================="
echo " All pre-deploy checks passed."
echo "=================================================="
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# Rotate PostgreSQL password on a running Docker Compose stack (existing volume safe).
#
# The Postgres image only applies POSTGRES_PASSWORD on first init; for existing data
# you must ALTER USER inside the running database, then update .env and restart backend.
#
# Usage (on server in repo root, with backup/snapshot taken):
# ./scripts/rotate-postgres-password.sh
# ./scripts/rotate-postgres-password.sh --app-user daagbok # optional: dedicated app role
#
# Writes the new credentials once to .postgres-credentials.<timestamp> (mode 600).
set -euo pipefail
ENV_FILE="${ENV_FILE:-.env}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
DB_CONTAINER="${DB_CONTAINER:-daagbox-prod-db}"
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
CREATE_APP_USER=""
APP_USER_NAME="daagbok"
while [ $# -gt 0 ]; do
case "$1" in
--app-user)
CREATE_APP_USER=1
APP_USER_NAME="${2:-daagbok}"
shift 2
;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found" >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
OLD_PASSWORD="${POSTGRES_PASSWORD:-}"
if [ -z "$OLD_PASSWORD" ]; then
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
exit 1
fi
NEW_PASSWORD="$(openssl rand -hex 24)"
NEW_APP_PASSWORD=""
if [ -n "$CREATE_APP_USER" ]; then
NEW_APP_PASSWORD="$(openssl rand -hex 24)"
fi
BACKUP_ENV="${ENV_FILE}.bak.pg-rotate.$(date +%Y%m%d-%H%M%S)"
cp "$ENV_FILE" "$BACKUP_ENV"
echo "Backed up $ENV_FILE$BACKUP_ENV"
echo "Rotating password for PostgreSQL role: $POSTGRES_USER (database: $POSTGRES_DB)"
# Escape single quotes for SQL string literals
sql_escape() {
printf "%s" "$1" | sed "s/'/''/g"
}
NEW_PW_SQL="$(sql_escape "$NEW_PASSWORD")"
export PGPASSWORD="$OLD_PASSWORD"
if ! docker exec -e PGPASSWORD="$OLD_PASSWORD" "$DB_CONTAINER" \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 \
-c "ALTER USER \"${POSTGRES_USER}\" WITH PASSWORD '${NEW_PW_SQL}';" >/dev/null; then
echo "Error: ALTER USER failed. Is POSTGRES_PASSWORD in .env still correct?" >&2
exit 1
fi
unset PGPASSWORD
TARGET_USER="$POSTGRES_USER"
TARGET_PASSWORD="$NEW_PASSWORD"
if [ -n "$CREATE_APP_USER" ]; then
APP_PW_SQL="$(sql_escape "$NEW_APP_PASSWORD")"
export PGPASSWORD="$NEW_PASSWORD"
docker exec -e PGPASSWORD="$NEW_PASSWORD" "$DB_CONTAINER" psql -U postgres -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${APP_USER_NAME}') THEN
CREATE ROLE ${APP_USER_NAME} LOGIN PASSWORD '${APP_PW_SQL}';
ELSE
ALTER ROLE ${APP_USER_NAME} WITH LOGIN PASSWORD '${APP_PW_SQL}';
END IF;
END
\$\$;
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO ${APP_USER_NAME};
GRANT USAGE, CREATE ON SCHEMA public TO ${APP_USER_NAME};
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${APP_USER_NAME};
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${APP_USER_NAME};
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO ${APP_USER_NAME};
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO ${APP_USER_NAME};
SQL
unset PGPASSWORD
TARGET_USER="$APP_USER_NAME"
TARGET_PASSWORD="$NEW_APP_PASSWORD"
echo "Created/updated application role: $APP_USER_NAME (postgres superuser password also rotated)"
fi
# Update .env without exposing values in process list longer than necessary
python3 - "$ENV_FILE" "$TARGET_USER" "$TARGET_PASSWORD" "$NEW_PASSWORD" "$CREATE_APP_USER" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
target_user = sys.argv[2]
target_password = sys.argv[3]
postgres_password = sys.argv[4]
use_app_user = sys.argv[5] == "1"
text = path.read_text(encoding="utf-8")
def set_var(name: str, value: str, content: str) -> str:
pattern = rf"^{re.escape(name)}=.*$"
line = f"{name}={value}"
if re.search(pattern, content, flags=re.M):
return re.sub(pattern, line, content, count=1, flags=re.M)
return content.rstrip() + "\n" + line + "\n"
text = set_var("POSTGRES_USER", target_user, text)
text = set_var("POSTGRES_PASSWORD", target_password, text)
text = set_var("POSTGRES_DB", "daagbox", text) if "POSTGRES_DB=" not in text else text
if use_app_user:
text = set_var("POSTGRES_ADMIN_PASSWORD", postgres_password, text)
path.write_text(text, encoding="utf-8")
PY
CREDS_FILE=".postgres-credentials.$(date +%Y%m%d-%H%M%S)"
umask 077
{
echo "# Generated $(date -Iseconds) — store in password manager, then delete this file."
echo "POSTGRES_USER=$TARGET_USER"
echo "POSTGRES_PASSWORD=$TARGET_PASSWORD"
echo "POSTGRES_DB=$POSTGRES_DB"
if [ -n "$CREATE_APP_USER" ]; then
echo "POSTGRES_ADMIN_USER=postgres"
echo "POSTGRES_ADMIN_PASSWORD=$NEW_PASSWORD"
fi
} > "$CREDS_FILE"
chmod 600 "$CREDS_FILE"
echo "Credentials written to $CREDS_FILE (chmod 600)"
echo "Recreating backend (and db if compose env changed) to pick up DATABASE_URL..."
docker compose -f "$COMPOSE_FILE" up -d --force-recreate backend
echo "Waiting for backend health..."
for _ in $(seq 1 45); do
status="$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null || echo missing)"
if [ "$status" = healthy ]; then
break
fi
sleep 1
done
export PGPASSWORD="$TARGET_PASSWORD"
docker exec -e PGPASSWORD="$TARGET_PASSWORD" "$DB_CONTAINER" \
psql -U "$TARGET_USER" -d "$POSTGRES_DB" -tAc 'SELECT count(*) FROM "User";' >/dev/null
unset PGPASSWORD
if curl -sf http://127.0.0.1/api/health | grep -q '"status":"ok"'; then
echo "OK: /api/health and DB connection verified."
else
echo "Warning: health check failed — see: docker compose logs backend" >&2
exit 1
fi
echo "Done. Remove $CREDS_FILE after saving credentials securely."
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Patch production .env for Sprint 1 docker-compose (POSTGRES_* + TRUST_PROXY).
# Safe: does not overwrite existing keys. Run on the server in /opt/kapteins-daagbok.
set -euo pipefail
ENV_FILE="${1:-.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "Error: $ENV_FILE not found"
exit 1
fi
backup="${ENV_FILE}.bak.$(date +%Y%m%d-%H%M%S)"
cp "$ENV_FILE" "$backup"
echo "Backup: $backup"
ensure_var() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "$ENV_FILE"; then
echo " keep ${key} (already set)"
else
echo "${key}=${value}" >> "$ENV_FILE"
echo " add ${key}"
fi
}
echo "Patching $ENV_FILE for Sprint 1..."
# Match running container (docker exec daagbox-prod-db: USER=postgres DB=daagbox)
ensure_var POSTGRES_USER "postgres"
ensure_var POSTGRES_DB "daagbox"
if ! grep -q "^POSTGRES_PASSWORD=" "$ENV_FILE" || grep -q "^POSTGRES_PASSWORD=$" "$ENV_FILE"; then
echo " skip POSTGRES_PASSWORD (set manually or run scripts/rotate-postgres-password.sh)"
else
echo " keep POSTGRES_PASSWORD (already set)"
fi
# NPM on 172.16.10.10 → app on this host
ensure_var TRUST_PROXY "172.16.10.10"
echo "Done. Verify with: docker exec daagbox-prod-db psql -U postgres -d daagbox -c 'SELECT 1'"
+9
View File
@@ -125,6 +125,15 @@ prepare_release() {
prepare_release
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
else
echo "=================================================="
echo " Pre-deploy checks (local)"
echo "=================================================="
"$SCRIPT_DIR/predeploy-check.sh"
fi
echo "=================================================="
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
echo "=================================================="
+1885 -1
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -7,7 +7,9 @@
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
"dev": "tsx watch src/index.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@prisma/client": "^5.10.2",
@@ -26,8 +28,11 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.24",
"@types/supertest": "^6.0.3",
"@types/web-push": "^3.6.4",
"supertest": "^7.1.0",
"tsx": "^4.7.1",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"vitest": "^3.0.9"
}
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeAll } from 'vitest'
import request from 'supertest'
vi.mock('./db.js', () => ({
prisma: {
$queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1 }])
}
}))
const { createApp } = await import('./app.js')
describe('API smoke', () => {
const app = createApp()
beforeAll(() => {
process.env.SESSION_SECRET =
process.env.SESSION_SECRET ?? 'test-session-secret-minimum-32-characters-long'
process.env.ORIGIN = process.env.ORIGIN ?? 'http://localhost:5173'
process.env.RP_ID = process.env.RP_ID ?? 'localhost'
})
it('GET /api/health returns ok when database is reachable', async () => {
const res = await request(app).get('/api/health')
expect(res.status).toBe(200)
expect(res.body.status).toBe('ok')
expect(res.body.database).toBe('connected')
})
it('GET /api/logbooks requires session', async () => {
const res = await request(app).get('/api/logbooks')
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
it('POST /api/sync/push requires session', async () => {
const res = await request(app)
.post('/api/sync/push')
.send({ items: [] })
expect(res.status).toBe(401)
expect(res.body.error).toMatch(/Unauthorized/i)
})
it('GET /api/collaboration/invite-details requires token query', async () => {
const res = await request(app).get('/api/collaboration/invite-details')
expect(res.status).toBe(400)
expect(res.body.error).toMatch(/Token/i)
})
})
+103
View File
@@ -0,0 +1,103 @@
import express from 'express'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import authRouter from './routes/auth.js'
import logbooksRouter from './routes/logbooks.js'
import syncRouter from './routes/sync.js'
import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import feedbackRouter from './routes/feedback.js'
import { prisma } from './db.js'
import { buildCorsOptions } from './cors.js'
/** Behind Nginx Proxy Manager. See docs/deployment/npm-security.md */
function configureTrustProxy(app: express.Express): void {
const raw = process.env.TRUST_PROXY?.trim()
if (raw === '1' || raw === 'true') {
app.set('trust proxy', 1)
return
}
if (raw) {
app.set('trust proxy', raw)
return
}
if (process.env.NODE_ENV === 'production') {
app.set('trust proxy', 1)
}
}
export function createApp(): express.Express {
const app = express()
configureTrustProxy(app)
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
})
)
app.use(cors(buildCorsOptions()))
app.use(cookieParser())
app.use(express.json({ limit: '50mb' }))
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false
})
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false
})
const publicCollaborationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false
})
app.use('/api/auth', authLimiter)
app.use('/api/collaboration/invite-details', publicCollaborationLimiter)
app.use('/api/collaboration/share-pull', publicCollaborationLimiter)
app.use('/api', apiLimiter)
app.use('/api/auth', authRouter)
app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter)
app.use('/api/feedback', feedbackRouter)
app.get('/api/health', async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`
res.json({
status: 'ok',
database: 'connected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
} catch {
res.status(500).json({
status: 'error',
database: 'disconnected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
}
})
return app
}
+2 -74
View File
@@ -1,88 +1,16 @@
import express from 'express'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import dotenv from 'dotenv'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import authRouter from './routes/auth.js'
import logbooksRouter from './routes/logbooks.js'
import syncRouter from './routes/sync.js'
import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import feedbackRouter from './routes/feedback.js'
import { prisma } from './db.js'
import { buildCorsOptions } from './cors.js'
import { createApp } from './app.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: resolve(__dirname, '../../.env') })
dotenv.config({ path: resolve(__dirname, '../.env') })
const app = express()
const app = createApp()
const PORT = process.env.PORT || 5000
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
})
)
app.use(cors(buildCorsOptions()))
app.use(cookieParser())
// Encrypted sync payloads (photos, GPS tracks) can be large — align with nginx client_max_body_size
app.use(express.json({ limit: '50mb' }))
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false
})
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false
})
app.use('/api/auth', authLimiter)
app.use('/api', apiLimiter)
// Mount routes
app.use('/api/auth', authRouter)
app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter)
app.use('/api/feedback', feedbackRouter)
// Health check endpoint
app.get('/api/health', async (req, res) => {
try {
await prisma.$queryRaw`SELECT 1`
res.json({
status: 'ok',
database: 'connected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
} catch {
res.status(500).json({
status: 'error',
database: 'disconnected',
timestamp: new Date().toISOString(),
service: 'Kapteins Daagbok Backend'
})
}
})
app.listen(PORT, () => {
console.log(`[server] Server running on http://localhost:${PORT}`)
})
+6 -2
View File
@@ -1,4 +1,4 @@
import rateLimit from 'express-rate-limit'
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
import type { AuthedRequest } from './auth.js'
const MIN_SUBMIT_MS = 2_000
@@ -69,7 +69,11 @@ export const feedbackLimiter = rateLimit({
max: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => (req as AuthedRequest).userId ?? req.ip ?? 'unknown',
keyGenerator: (req) => {
const authed = req as AuthedRequest
if (authed.userId) return authed.userId
return ipKeyGenerator(req.ip ?? 'unknown')
},
handler: (_req, res) => {
res.status(429).json({
error: 'Too many feedback submissions. Please try again later.',
+37 -53
View File
@@ -14,6 +14,8 @@ import {
setSessionCookie,
setSessionTokenCookie
} from '../session.js'
import { ChallengeMap, ChallengeSet } from '../utils/challengeStore.js'
import { sendInternalError } from '../utils/httpErrors.js'
const router = Router()
@@ -21,10 +23,10 @@ const rpName = 'Kapteins Daagbok'
const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173'
const registrationChallenges = new Map<string, string>()
const registrationChallenges = new ChallengeMap()
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
const addCredentialChallenges = new Map<string, string>()
const activeChallenges = new Set<string>()
const addCredentialChallenges = new ChallengeSet<string>()
const activeChallenges = new ChallengeSet()
function previewCredentialId(credentialId: string): string {
if (credentialId.length <= 16) return credentialId
@@ -76,7 +78,7 @@ router.post('/register-options', async (req, res) => {
})
if (existingUser) {
return res.status(400).json({ error: 'User already exists' })
return res.status(400).json({ error: 'Could not start registration' })
}
const userID = Buffer.from(username, 'utf8').toString('base64url')
@@ -98,9 +100,8 @@ router.post('/register-options', async (req, res) => {
registrationChallenges.set(username, options.challenge)
return res.json(options)
} catch (error: any) {
console.error('Error generating registration options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/register-options')
}
})
@@ -163,9 +164,8 @@ router.post('/register-verify', async (req, res) => {
setSessionCookie(res, user.id, true)
return res.json({ verified: true, userId: user.id })
} catch (error: any) {
console.error('Error verifying registration response:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/register-verify')
}
})
@@ -197,9 +197,8 @@ router.post('/login-options', async (req, res) => {
activeChallenges.add(options.challenge)
return res.json(options)
} catch (error: any) {
console.error('Error generating authentication options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/login-options')
}
})
@@ -260,9 +259,8 @@ router.post('/login-verify', async (req, res) => {
encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv,
encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag
})
} catch (error: any) {
console.error('Error verifying authentication response:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/login-verify')
}
})
@@ -305,9 +303,8 @@ router.post('/reauth-options', requireUser, async (req: any, res) => {
activeChallenges.add(options.challenge)
return res.json(options)
} catch (error: any) {
console.error('Error generating reauth options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/reauth-options')
}
})
@@ -362,9 +359,8 @@ router.post('/reauth-verify', requireUser, async (req: any, res) => {
}
return res.json({ verified: true })
} catch (error: any) {
console.error('Error verifying reauth:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/reauth-verify')
}
})
@@ -384,9 +380,8 @@ router.delete('/delete-account', requireReauth, async (req: any, res) => {
clearSessionCookie(res)
return res.json({ success: true })
} catch (error: any) {
console.error('Error deleting account:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/delete-account')
}
})
@@ -415,9 +410,8 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error enrolling PRF key:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/enroll-prf')
}
})
@@ -446,9 +440,8 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error rotating recovery key:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/rotate-recovery')
}
})
@@ -468,9 +461,7 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
return res.json({ ...DEFAULT_APPEARANCE_PREFS })
}
console.error('Error reading appearance prefs:', error)
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
return sendInternalError(res, error, 'auth/appearance-prefs-get')
}
})
@@ -509,9 +500,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.'
})
}
console.error('Error updating appearance prefs:', error)
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
return sendInternalError(res, error, 'auth/appearance-prefs-put')
}
})
@@ -552,9 +541,8 @@ router.get('/profile', requireUser, async (req: any, res) => {
collaborationCount: user._count.collaborations
}
})
} catch (error: any) {
console.error('Error fetching user profile:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/profile')
}
})
@@ -591,12 +579,11 @@ router.post('/add-credential-options', requireReauth, async (req: any, res) => {
excludeCredentials
})
addCredentialChallenges.set(options.challenge, req.userId)
addCredentialChallenges.add(options.challenge, req.userId)
return res.json(options)
} catch (error: any) {
console.error('Error generating add-credential options:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/add-credential-options')
}
})
@@ -670,9 +657,8 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
transports: credential.transports
}
})
} catch (error: any) {
console.error('Error verifying add-credential response:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/add-credential-verify')
}
})
@@ -702,9 +688,8 @@ router.patch('/credentials/:id', requireReauth, async (req: any, res) => {
transports: updated.transports
}
})
} catch (error: any) {
console.error('Error updating credential label:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/credentials-patch')
}
})
@@ -733,9 +718,8 @@ router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error deleting credential:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/credentials-delete')
}
})
+17 -24
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js'
import { sendInternalError } from '../utils/httpErrors.js'
const router = Router()
@@ -39,9 +40,8 @@ router.get('/invite-details', async (req: any, res) => {
encryptedTitle: invitation.logbook.encryptedTitle,
role: invitation.role
})
} catch (error: any) {
console.error('Error fetching invite details:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/invite-details')
}
})
@@ -90,9 +90,8 @@ router.get('/share-pull', async (req: any, res) => {
photos,
gpsTracks
})
} catch (error: any) {
console.error('Error in share-pull:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/share-pull')
}
})
@@ -159,9 +158,8 @@ router.post('/accept', requireUser, async (req: any, res) => {
logbookId: invitation.logbookId,
role: invitation.role
})
} catch (error: any) {
console.error('Error accepting invitation:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/accept')
}
})
@@ -205,9 +203,8 @@ router.post('/invite', async (req: any, res) => {
token: invitation.token,
expiresAt: invitation.expiresAt
})
} catch (error: any) {
console.error('Error creating invitation:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/invite')
}
})
@@ -247,9 +244,8 @@ router.get('/collaborators', async (req: any, res) => {
role: c.role,
createdAt: c.createdAt
})))
} catch (error: any) {
console.error('Error fetching collaborators:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/collaborators')
}
})
@@ -277,9 +273,8 @@ router.delete('/collaborators/:id', async (req: any, res) => {
})
return res.json({ success: true })
} catch (error: any) {
console.error('Error revoking collaboration:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/revoke')
}
})
@@ -317,9 +312,8 @@ router.get('/share-link', async (req: any, res) => {
token: invitation ? invitation.token : null,
expiresAt: invitation ? invitation.expiresAt : null
})
} catch (error: any) {
console.error('Error fetching share link:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/share-link-get')
}
})
@@ -384,9 +378,8 @@ router.post('/share-link', async (req: any, res) => {
return res.json({ success: true })
}
} catch (error: any) {
console.error('Error toggling share link:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
} catch (error: unknown) {
return sendInternalError(res, error, 'collaboration/share-link-post')
}
})
+76
View File
@@ -0,0 +1,76 @@
/** WebAuthn challenge TTL — align with sign route. */
export const CHALLENGE_TTL_MS = 5 * 60 * 1000
interface TimedValue<T> {
value: T
expiresAt: number
}
/** Challenge keyed by arbitrary string (e.g. username) with a string payload. */
export class ChallengeMap {
private readonly entries = new Map<string, TimedValue<string>>()
prune(): void {
const now = Date.now()
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) this.entries.delete(key)
}
}
set(key: string, value: string): void {
this.prune()
this.entries.set(key, { value, expiresAt: Date.now() + CHALLENGE_TTL_MS })
}
get(key: string): string | undefined {
this.prune()
const entry = this.entries.get(key)
if (!entry) return undefined
if (entry.expiresAt <= Date.now()) {
this.entries.delete(key)
return undefined
}
return entry.value
}
delete(key: string): void {
this.entries.delete(key)
}
}
/** Challenge keyed by challenge id (login/reauth) with optional metadata. */
export class ChallengeSet<T = undefined> {
private readonly entries = new Map<string, TimedValue<T | undefined>>()
prune(): void {
const now = Date.now()
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) this.entries.delete(key)
}
}
add(key: string, value?: T): void {
this.prune()
this.entries.set(key, { value, expiresAt: Date.now() + CHALLENGE_TTL_MS })
}
has(key: string): boolean {
this.prune()
const entry = this.entries.get(key)
if (!entry) return false
if (entry.expiresAt <= Date.now()) {
this.entries.delete(key)
return false
}
return true
}
get(key: string): T | undefined {
if (!this.has(key)) return undefined
return this.entries.get(key)?.value as T | undefined
}
delete(key: string): void {
this.entries.delete(key)
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { Response } from 'express'
const PUBLIC_ERROR = 'Internal server error'
/** Log full error server-side; never expose stack or Prisma internals to clients. */
export function sendInternalError(res: Response, error: unknown, context: string): Response {
console.error(`[${context}]`, error)
return res.status(500).json({ error: PUBLIC_ERROR })
}
+2 -1
View File
@@ -12,5 +12,6 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
env: {
NODE_ENV: 'test',
SESSION_SECRET: 'test-session-secret-minimum-32-characters-long',
ORIGIN: 'http://localhost:5173',
RP_ID: 'localhost'
}
}
})