Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8dc6ace3c | |||
| 18f14d7e0b | |||
| 0edf4a789c | |||
| 4ef56aeb8f | |||
| 3263fbcec3 | |||
| b9ce853059 | |||
| 3d8a505bd9 | |||
| e138752dd3 | |||
| b9c908169b | |||
| e6bde5c525 | |||
| eab7b86c0b | |||
| b86789ae4c | |||
| 2a8ec2fccf | |||
| 60a8533a44 | |||
| c86ac4273c | |||
| 73467f2263 | |||
| e068f083c1 |
+14
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+19
-2
@@ -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 / {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)).
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)).
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+40
@@ -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 "=================================================="
|
||||
Executable
+183
@@ -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."
|
||||
Executable
+40
@@ -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'"
|
||||
@@ -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 "=================================================="
|
||||
|
||||
Generated
+1885
-1
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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}`)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -12,5 +12,6 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user