Compare commits
28 Commits
v0.1.0.89
...
v0.1.0.103
| Author | SHA1 | Date | |
|---|---|---|---|
| aee8f4f3db | |||
| 2b029a26f0 | |||
| 2156aa4bbd | |||
| 5eb4543255 | |||
| fb9bb6754c | |||
| 959afd5a63 | |||
| e3ea45f717 | |||
| 8f57b6ff22 | |||
| 60e1b714b7 | |||
| 1e203bfec1 | |||
| 11420685cf | |||
| c674aac344 | |||
| 9c91a0f1fc | |||
| 2bcbbba626 | |||
| b1500f8361 | |||
| bc7512003e | |||
| eaf126b584 | |||
| a9c712be45 | |||
| b0195601de | |||
| c2b58baa6e | |||
| a85d6e42fc | |||
| 53da4a14a0 | |||
| 2453134c51 | |||
| 671cb2dd9a | |||
| 1d511e0f8c | |||
| 18a68367bc | |||
| 90518372d8 | |||
| 9d22cb61c7 |
+2
-2
@@ -13,8 +13,8 @@ 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
|
||||
# Behind reverse proxy — see docs/deployment/npm-security.md
|
||||
# Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1
|
||||
# TRUST_PROXY=1
|
||||
|
||||
# Docker Compose database (required for production deploy)
|
||||
|
||||
@@ -43,6 +43,9 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
+8
-16
@@ -148,7 +148,8 @@ select.input-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.time-input-24h__select {
|
||||
.time-input-24h__select,
|
||||
.time-input-24h__native {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
@@ -157,6 +158,11 @@ select.input-text {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
input[type='time'].time-input-24h__native {
|
||||
color-scheme: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-input-24h__sep {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
@@ -2647,27 +2653,13 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
.events-scroll-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: rgba(11, 12, 16, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for events container */
|
||||
.events-scroll-container::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
.events-scroll-container::-webkit-scrollbar-track {
|
||||
background: rgba(11, 12, 16, 0.2);
|
||||
}
|
||||
.events-scroll-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.events-scroll-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.events-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useId, useMemo } from 'react'
|
||||
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
|
||||
import { preferNativeCameraPicker } from '../utils/captureVideoFrame.js'
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
||||
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
|
||||
@@ -18,7 +19,29 @@ export default function EventTimeInput24h({
|
||||
'aria-label': ariaLabel
|
||||
}: EventTimeInput24hProps) {
|
||||
const baseId = useId()
|
||||
const useNativePicker = preferNativeCameraPicker()
|
||||
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
||||
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
|
||||
|
||||
if (useNativePicker) {
|
||||
return (
|
||||
<div className="time-input-24h">
|
||||
<input
|
||||
id={baseId}
|
||||
type="time"
|
||||
step={60}
|
||||
className="input-text time-input-24h__native"
|
||||
value={timeValue}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
if (next) onChange(next.slice(0, 5))
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="time-input-24h">
|
||||
|
||||
@@ -15,6 +15,12 @@ import LiveLogView from './LiveLogView.tsx'
|
||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||
import {
|
||||
buildEntryListCache,
|
||||
entryListItemFromLocal,
|
||||
putEntryRecord
|
||||
} from '../utils/entryListCache.js'
|
||||
import { forEachInBatches } from '../utils/yieldToMain.js'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
@@ -116,24 +122,34 @@ export default function LogEntriesList({
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const local = await db.entries.where({ logbookId }).toArray()
|
||||
|
||||
|
||||
const list: DecryptedEntryItem[] = []
|
||||
|
||||
const needsDecrypt: typeof local = []
|
||||
|
||||
for (const entry of local) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (decrypted) {
|
||||
list.push({
|
||||
id: entry.payloadId,
|
||||
date: decrypted.date || '',
|
||||
dayOfTravel: decrypted.dayOfTravel || '',
|
||||
departure: decrypted.departure || '',
|
||||
destination: decrypted.destination || '',
|
||||
updatedAt: entry.updatedAt,
|
||||
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
|
||||
})
|
||||
const cached = entryListItemFromLocal(entry)
|
||||
if (cached) {
|
||||
list.push(cached)
|
||||
} else {
|
||||
needsDecrypt.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
await forEachInBatches(needsDecrypt, 8, async (entry) => {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (!decrypted) return
|
||||
|
||||
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
|
||||
list.push({
|
||||
id: entry.payloadId,
|
||||
...listCache,
|
||||
updatedAt: entry.updatedAt
|
||||
})
|
||||
void db.entries.update(entry.payloadId, { listCache }).catch((err) => {
|
||||
console.warn('Failed to persist entry list cache:', err)
|
||||
})
|
||||
})
|
||||
|
||||
// Sort chronological descending (by date, or dayOfTravel numerical)
|
||||
list.sort((a, b) => {
|
||||
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
@@ -309,14 +325,17 @@ export default function LogEntriesList({
|
||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||
|
||||
// Save locally
|
||||
await db.entries.put({
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
})
|
||||
await putEntryRecord(
|
||||
{
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
},
|
||||
initialPayload
|
||||
)
|
||||
|
||||
// Queue for background sync
|
||||
await db.syncQueue.put({
|
||||
|
||||
@@ -33,6 +33,7 @@ import CourseDialInput from './CourseDialInput.tsx'
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||
import { signLogEntry } from '../services/entrySigning.js'
|
||||
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||
@@ -454,14 +455,17 @@ export default function LogEntryEditor({
|
||||
const encrypted = await encryptJson(entryData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await putEntryRecord(
|
||||
{
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
},
|
||||
entryData
|
||||
)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
enableCollaboratorChangePush,
|
||||
fetchPushPrefs,
|
||||
getNotificationPermission,
|
||||
isPushSupported
|
||||
isPushSupported,
|
||||
preloadPushService
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
void preloadPushService()
|
||||
try {
|
||||
const prefs = await fetchPushPrefs()
|
||||
setEnabled(prefs.collaboratorChangesEnabled)
|
||||
@@ -56,7 +58,8 @@ export default function PushNotificationSettings() {
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('profile.push_error')
|
||||
console.error('Failed to toggle push notifications:', err)
|
||||
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||
showAlert(message)
|
||||
void loadPrefs()
|
||||
} finally {
|
||||
|
||||
@@ -10,7 +10,8 @@ import { apiFetch } from '../services/api.js'
|
||||
import {
|
||||
enableCollaboratorChangePush,
|
||||
isCollaboratorPushActive,
|
||||
isPushSupported
|
||||
isPushSupported,
|
||||
preloadPushService
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
|
||||
@@ -55,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
loadCollaborators()
|
||||
loadShareLink()
|
||||
}
|
||||
void preloadPushService()
|
||||
}, [logbookId])
|
||||
|
||||
const loadShareLink = async () => {
|
||||
@@ -191,7 +193,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to enable push after invite:', err)
|
||||
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
|
||||
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||
await showAlert(message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,12 +42,14 @@ function scheduleUpdateChecks(
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdate()
|
||||
// Delay check on wake-up to allow the mobile network stack to stabilize
|
||||
setTimeout(checkForUpdate, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const onOnline = () => {
|
||||
checkForUpdate()
|
||||
// Small delay to ensure connection is fully established
|
||||
setTimeout(checkForUpdate, 500)
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
|
||||
@@ -18,3 +18,62 @@ body {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Scrollbars — auf Touch-Geräten breiter und besser sichtbar */
|
||||
:root {
|
||||
--app-scrollbar-size: 10px;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse), (max-width: 768px) {
|
||||
:root {
|
||||
--app-scrollbar-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: var(--app-accent-light) var(--app-surface-inset);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar,
|
||||
*::-webkit-scrollbar {
|
||||
width: var(--app-scrollbar-size);
|
||||
height: var(--app-scrollbar-size);
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-track,
|
||||
body::-webkit-scrollbar-track,
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--app-surface-inset);
|
||||
border-radius: calc(var(--app-scrollbar-size) / 2);
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-thumb,
|
||||
body::-webkit-scrollbar-thumb,
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--app-accent-light) 55%, transparent);
|
||||
border-radius: calc(var(--app-scrollbar-size) / 2);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-thumb:hover,
|
||||
body::-webkit-scrollbar-thumb:hover,
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--app-accent-light) 80%, transparent);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse), (max-width: 768px) {
|
||||
html::-webkit-scrollbar-thumb,
|
||||
body::-webkit-scrollbar-thumb,
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--app-accent-light) 70%, transparent);
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-thumb:active,
|
||||
body::-webkit-scrollbar-thumb:active,
|
||||
*::-webkit-scrollbar-thumb:active {
|
||||
background: var(--app-accent-light);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,17 @@ async function bootstrap(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { scope: '/' })
|
||||
.then((reg) => {
|
||||
console.log('Service Worker registered successfully with scope:', reg.scope)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Service Worker registration failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element')
|
||||
|
||||
@@ -10,22 +10,43 @@ export class ApiError extends Error {
|
||||
|
||||
export async function apiFetch(
|
||||
input: string,
|
||||
init: RequestInit = {}
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 15000
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.body !== undefined && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
if (init.signal) {
|
||||
if (init.signal.aborted) {
|
||||
controller.abort()
|
||||
} else {
|
||||
init.signal.addEventListener('abort', () => controller.abort())
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: controller.signal
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await apiFetch(input, init)
|
||||
export async function apiJson<T>(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 15000
|
||||
): Promise<T> {
|
||||
const res = await apiFetch(input, init, timeoutMs)
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
|
||||
@@ -35,6 +35,14 @@ export interface LocalDeviation {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface EntryListCache {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
skipperSignStatus: 'none' | 'valid' | 'invalid'
|
||||
}
|
||||
|
||||
export interface LocalEntry {
|
||||
payloadId: string
|
||||
logbookId: string
|
||||
@@ -42,6 +50,8 @@ export interface LocalEntry {
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
|
||||
listCache?: EntryListCache
|
||||
}
|
||||
|
||||
export interface LocalPhoto {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import { syncPersonPool } from './personPoolSync.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
@@ -35,14 +36,17 @@ async function putEncryptedRecord(
|
||||
const encrypted = await encryptJson(data, key)
|
||||
|
||||
if (type === 'entry') {
|
||||
await db.entries.put({
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await putEntryRecord(
|
||||
{
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
},
|
||||
data as Record<string, unknown>
|
||||
)
|
||||
} else if (type === 'yacht') {
|
||||
await db.yachts.put({
|
||||
logbookId,
|
||||
|
||||
@@ -27,17 +27,62 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport
|
||||
return Notification.permission
|
||||
}
|
||||
|
||||
let cachedVapidKey: string | null = null
|
||||
let cachedRegistration: ServiceWorkerRegistration | null = null
|
||||
|
||||
async function getRegistrationCompat(timeoutMs = 8000): Promise<ServiceWorkerRegistration> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
throw new Error('Service Worker is not supported by your browser')
|
||||
}
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration()
|
||||
if (reg) return reg
|
||||
} catch (e) {
|
||||
console.warn('Failed to get service worker registration directly:', e)
|
||||
}
|
||||
|
||||
// Fallback to waiting for ready state with a timeout
|
||||
const readyPromise = navigator.serviceWorker.ready
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), timeoutMs)
|
||||
)
|
||||
|
||||
return Promise.race([readyPromise, timeoutPromise])
|
||||
}
|
||||
|
||||
export async function preloadPushService(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
try {
|
||||
if (!cachedVapidKey) {
|
||||
await fetchVapidPublicKey()
|
||||
}
|
||||
if (!cachedRegistration) {
|
||||
cachedRegistration = await getRegistrationCompat()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to preload push service:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVapidPublicKey(): Promise<string | null> {
|
||||
if (cachedVapidKey) return cachedVapidKey
|
||||
|
||||
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
||||
if (typeof envKey === 'string' && envKey.trim()) {
|
||||
return envKey.trim()
|
||||
cachedVapidKey = envKey.trim()
|
||||
return cachedVapidKey
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/vapid-public-key`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return typeof data.publicKey === 'string' ? data.publicKey : null
|
||||
if (typeof data.publicKey === 'string') {
|
||||
cachedVapidKey = data.publicKey.trim()
|
||||
return cachedVapidKey
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -72,11 +117,61 @@ export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promis
|
||||
})
|
||||
}
|
||||
|
||||
async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
if (typeof Notification === 'undefined') return 'denied'
|
||||
|
||||
// Try promise-based signature first
|
||||
try {
|
||||
const result = Notification.requestPermission()
|
||||
if (result !== undefined) {
|
||||
return await result
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back to callback
|
||||
}
|
||||
|
||||
// Callback-based fallback
|
||||
return new Promise<NotificationPermission>((resolve) => {
|
||||
Notification.requestPermission((permission) => {
|
||||
resolve(permission)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
||||
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
||||
|
||||
const endpoint = subscription.endpoint
|
||||
const json = subscription.toJSON()
|
||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||
let p256dh = json.keys?.p256dh
|
||||
let auth = json.keys?.auth
|
||||
|
||||
// Fallback for browsers (like Safari) that might not serialize keys in toJSON()
|
||||
if (!p256dh && typeof subscription.getKey === 'function') {
|
||||
try {
|
||||
const rawKey = subscription.getKey('p256dh')
|
||||
if (rawKey) {
|
||||
p256dh = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract p256dh key manually:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth && typeof subscription.getKey === 'function') {
|
||||
try {
|
||||
const rawAuth = subscription.getKey('auth')
|
||||
if (rawAuth) {
|
||||
auth = btoa(String.fromCharCode(...new Uint8Array(rawAuth)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract auth key manually:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!endpoint || !p256dh || !auth) {
|
||||
throw new Error('Invalid push subscription')
|
||||
}
|
||||
|
||||
@@ -85,8 +180,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
|
||||
await apiJson(`${API_BASE}/subscription`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
endpoint: json.endpoint,
|
||||
keys: json.keys,
|
||||
endpoint,
|
||||
keys: { p256dh, auth },
|
||||
locale,
|
||||
userAgent: navigator.userAgent
|
||||
})
|
||||
@@ -98,35 +193,48 @@ export async function subscribeToPush(): Promise<void> {
|
||||
throw new Error('Push notifications are not supported on this device')
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission denied')
|
||||
// Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
|
||||
let registration = cachedRegistration
|
||||
if (!registration) {
|
||||
registration = await getRegistrationCompat()
|
||||
cachedRegistration = registration
|
||||
}
|
||||
|
||||
const publicKey = await fetchVapidPublicKey()
|
||||
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
|
||||
if (!publicKey) {
|
||||
throw new Error('Push notifications are not configured on this server')
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (!subscription) {
|
||||
const keyBytes = urlBase64ToUint8Array(publicKey)
|
||||
const applicationServerKey = new Uint8Array(keyBytes)
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
const permission = await requestNotificationPermission()
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission denied')
|
||||
}
|
||||
|
||||
const keyBytes = urlBase64ToUint8Array(publicKey)
|
||||
const applicationServerKey = new Uint8Array(keyBytes)
|
||||
|
||||
// Always call subscribe with timeout to prevent silent hangs on push network errors
|
||||
const subscribePromise = registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
const subscribeTimeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
|
||||
)
|
||||
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
|
||||
|
||||
await saveSubscriptionToServer(subscription)
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPush(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let registration = cachedRegistration
|
||||
if (!registration) {
|
||||
registration = await getRegistrationCompat()
|
||||
cachedRegistration = registration
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) return
|
||||
|
||||
@@ -164,3 +272,7 @@ export async function disableCollaboratorChangePush(): Promise<void> {
|
||||
await savePushPrefs(false)
|
||||
await unsubscribeFromPush()
|
||||
}
|
||||
|
||||
if (isPushSupported()) {
|
||||
void preloadPushService()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getActiveMasterKey } from './auth.js'
|
||||
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import {
|
||||
buildLogEntryPayload,
|
||||
normalizeLogEvent,
|
||||
@@ -190,14 +191,17 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
|
||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
})
|
||||
await putEntryRecord(
|
||||
{
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
},
|
||||
initialPayload
|
||||
)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
@@ -305,14 +309,17 @@ async function persistEntry(
|
||||
const encrypted = await encryptJson(entryData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await putEntryRecord(
|
||||
{
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
},
|
||||
entryData
|
||||
)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
|
||||
+19
-13
@@ -8,6 +8,7 @@ import {
|
||||
type SyncConflict
|
||||
} from './syncConflicts.js'
|
||||
import { syncPersonPool } from './personPoolSync.js'
|
||||
import { forEachInBatches, yieldToMain } from '../utils/yieldToMain.js'
|
||||
|
||||
const API_BASE = '/api/sync'
|
||||
const syncingLogbooks = new Set<string>()
|
||||
@@ -130,12 +131,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
|
||||
}
|
||||
|
||||
function scheduleResync(logbookId: string) {
|
||||
if (pendingResync.has(logbookId)) return
|
||||
pendingResync.add(logbookId)
|
||||
queueMicrotask(() => {
|
||||
pendingResync.delete(logbookId)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
})
|
||||
}
|
||||
|
||||
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
|
||||
@@ -305,6 +301,10 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
|
||||
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
|
||||
await response.json()
|
||||
|
||||
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
|
||||
await yieldToMain()
|
||||
|
||||
const serverSnapshot: PulledServerPayload = {
|
||||
yacht,
|
||||
deviation,
|
||||
@@ -375,7 +375,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
// 3. Sync Crew List Payloads (legacy)
|
||||
const serverCrewMap = new Map<string, any>()
|
||||
if (crews && Array.isArray(crews)) {
|
||||
for (const c of crews) {
|
||||
await forEachInBatches(crews, 20, async (c) => {
|
||||
serverCrewMap.set(c.payloadId, c)
|
||||
const local = await db.crews.get(c.payloadId)
|
||||
if (!local || isNewer(c.updatedAt, local.updatedAt)) {
|
||||
@@ -388,7 +388,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: c.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for Crew: If present locally but not on server, and not pending creation locally
|
||||
@@ -408,7 +408,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
// 4. Sync Journal Entry Payloads
|
||||
const serverEntryMap = new Map<string, any>()
|
||||
if (entries && Array.isArray(entries)) {
|
||||
for (const e of entries) {
|
||||
await forEachInBatches(entries, 15, async (e) => {
|
||||
serverEntryMap.set(e.payloadId, e)
|
||||
const local = await db.entries.get(e.payloadId)
|
||||
if (!local || isNewer(e.updatedAt, local.updatedAt)) {
|
||||
@@ -421,7 +421,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: e.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for Entries
|
||||
@@ -440,7 +440,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
// 5. Sync Photos
|
||||
const serverPhotoMap = new Map<string, any>()
|
||||
if (photos && Array.isArray(photos)) {
|
||||
for (const p of photos) {
|
||||
await forEachInBatches(photos, 20, async (p) => {
|
||||
serverPhotoMap.set(p.payloadId, p)
|
||||
const local = await db.photos.get(p.payloadId)
|
||||
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
|
||||
@@ -455,7 +455,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: p.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for Photos
|
||||
@@ -474,7 +474,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
// 6. Sync GPS Tracks
|
||||
const serverGpsTrackMap = new Map<string, any>()
|
||||
if (gpsTracks && Array.isArray(gpsTracks)) {
|
||||
for (const gt of gpsTracks) {
|
||||
await forEachInBatches(gpsTracks, 10, async (gt) => {
|
||||
serverGpsTrackMap.set(gt.entryId, gt)
|
||||
const local = await db.gpsTracks.get(gt.entryId)
|
||||
if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
|
||||
@@ -487,7 +487,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: gt.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for GPS Tracks
|
||||
@@ -535,6 +535,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
} finally {
|
||||
syncingLogbooks.delete(logbookId)
|
||||
recomputeSyncingState()
|
||||
if (pendingResync.has(logbookId)) {
|
||||
pendingResync.delete(logbookId)
|
||||
setTimeout(() => {
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -6,6 +6,10 @@ import { NetworkFirst, NetworkOnly } from 'workbox-strategies'
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
cleanupOutdatedCaches()
|
||||
clientsClaim()
|
||||
|
||||
const appShellFallback = createHandlerBoundToURL('/index.html')
|
||||
const navigationStrategy = new NetworkFirst({
|
||||
cacheName: 'app-shell',
|
||||
@@ -20,10 +24,6 @@ registerRoute(({ request }) => request.mode === 'navigate', async (context) => {
|
||||
}
|
||||
})
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
cleanupOutdatedCaches()
|
||||
clientsClaim()
|
||||
|
||||
// Always fetch the live deploy version, even under an older precache.
|
||||
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildEntryListCache, entryListItemFromLocal } from './entryListCache.js'
|
||||
import type { LocalEntry } from '../services/db.js'
|
||||
|
||||
describe('entryListCache', () => {
|
||||
it('builds cache fields from decrypted entry', async () => {
|
||||
const cache = await buildEntryListCache({
|
||||
date: '2026-06-02',
|
||||
dayOfTravel: '3',
|
||||
departure: 'Kiel',
|
||||
destination: 'Laboe',
|
||||
signSkipper: 'Max'
|
||||
})
|
||||
expect(cache).toEqual({
|
||||
date: '2026-06-02',
|
||||
dayOfTravel: '3',
|
||||
departure: 'Kiel',
|
||||
destination: 'Laboe',
|
||||
skipperSignStatus: 'valid'
|
||||
})
|
||||
})
|
||||
|
||||
it('maps cached local entry to list item', () => {
|
||||
const entry: LocalEntry = {
|
||||
payloadId: 'e1',
|
||||
logbookId: 'lb1',
|
||||
encryptedData: 'x',
|
||||
iv: 'i',
|
||||
tag: 't',
|
||||
updatedAt: '2026-06-02T12:00:00.000Z',
|
||||
listCache: {
|
||||
date: '2026-06-02',
|
||||
dayOfTravel: '1',
|
||||
departure: 'A',
|
||||
destination: 'B',
|
||||
skipperSignStatus: 'none'
|
||||
}
|
||||
}
|
||||
expect(entryListItemFromLocal(entry)).toEqual({
|
||||
id: 'e1',
|
||||
date: '2026-06-02',
|
||||
dayOfTravel: '1',
|
||||
departure: 'A',
|
||||
destination: 'B',
|
||||
updatedAt: '2026-06-02T12:00:00.000Z',
|
||||
skipperSignStatus: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when cache is missing', () => {
|
||||
const entry: LocalEntry = {
|
||||
payloadId: 'e1',
|
||||
logbookId: 'lb1',
|
||||
encryptedData: 'x',
|
||||
iv: 'i',
|
||||
tag: 't',
|
||||
updatedAt: '2026-06-02T12:00:00.000Z'
|
||||
}
|
||||
expect(entryListItemFromLocal(entry)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { db, type EntryListCache, type LocalEntry } from '../services/db.js'
|
||||
import { getSkipperSignStatus, type SkipperSignStatus } from './signatures.js'
|
||||
|
||||
export type { EntryListCache }
|
||||
|
||||
export interface EntryListItem {
|
||||
id: string
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
updatedAt: string
|
||||
skipperSignStatus: SkipperSignStatus
|
||||
}
|
||||
|
||||
export async function buildEntryListCache(decrypted: Record<string, unknown>): Promise<EntryListCache> {
|
||||
return {
|
||||
date: String(decrypted.date || ''),
|
||||
dayOfTravel: String(decrypted.dayOfTravel || ''),
|
||||
departure: String(decrypted.departure || ''),
|
||||
destination: String(decrypted.destination || ''),
|
||||
skipperSignStatus: await getSkipperSignStatus(decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
export function entryListItemFromLocal(entry: LocalEntry): EntryListItem | null {
|
||||
if (!entry.listCache) return null
|
||||
return {
|
||||
id: entry.payloadId,
|
||||
date: entry.listCache.date,
|
||||
dayOfTravel: entry.listCache.dayOfTravel,
|
||||
departure: entry.listCache.departure,
|
||||
destination: entry.listCache.destination,
|
||||
updatedAt: entry.updatedAt,
|
||||
skipperSignStatus: entry.listCache.skipperSignStatus
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalEntryPut = Omit<LocalEntry, 'listCache'> & { listCache?: EntryListCache }
|
||||
|
||||
/** Persist entry ciphertext and optional plaintext list cache for fast journal list loads. */
|
||||
export async function putEntryRecord(
|
||||
record: LocalEntryPut,
|
||||
decryptedForCache?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const listCache =
|
||||
record.listCache ??
|
||||
(decryptedForCache ? await buildEntryListCache(decryptedForCache) : undefined)
|
||||
|
||||
await db.entries.put({
|
||||
...record,
|
||||
...(listCache ? { listCache } : {})
|
||||
})
|
||||
}
|
||||
|
||||
/** Backfill list cache after a legacy decrypt — fire-and-forget is fine. */
|
||||
export function persistEntryListCache(
|
||||
payloadId: string,
|
||||
decrypted: Record<string, unknown>
|
||||
): void {
|
||||
void buildEntryListCache(decrypted)
|
||||
.then((listCache) => db.entries.update(payloadId, { listCache }))
|
||||
.catch((err) => console.warn('Failed to persist entry list cache:', err))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Yield so long tasks can interleave with paint and input handling. */
|
||||
export function yieldToMain(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0)
|
||||
})
|
||||
}
|
||||
|
||||
/** Run an async handler over items in batches, yielding between batches. */
|
||||
export async function forEachInBatches<T>(
|
||||
items: T[],
|
||||
batchSize: number,
|
||||
handler: (item: T) => Promise<void>
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return
|
||||
const size = Math.max(1, batchSize)
|
||||
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
if (i > 0) await yieldToMain()
|
||||
const batch = items.slice(i, i + size)
|
||||
for (const item of batch) {
|
||||
await handler(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,9 @@ proxy_set_header X-Real-IP $remote_addr;
|
||||
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
|
||||
# Docker Compose: Frontend-Nginx ist der direkte Proxy zum Backend → 1 Hop
|
||||
TRUST_PROXY=1
|
||||
# Nur bei direktem Backend-Zugriff ohne Frontend-Nginx: NPM-IP, z. B. TRUST_PROXY=172.16.10.10
|
||||
```
|
||||
|
||||
`ORIGIN` muss **exakt** der Browser-URL entsprechen (ohne trailing slash).
|
||||
|
||||
@@ -34,7 +34,7 @@ if ! grep -q "^POSTGRES_PASSWORD=" "$ENV_FILE" || grep -q "^POSTGRES_PASSWORD=$"
|
||||
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"
|
||||
# Frontend-Nginx → Backend (one hop); NPM is in front of Nginx, not Backend directly
|
||||
ensure_var TRUST_PROXY "1"
|
||||
|
||||
echo "Done. Verify with: docker exec daagbox-prod-db psql -U postgres -d daagbox -c 'SELECT 1'"
|
||||
|
||||
+18
-1
@@ -45,6 +45,18 @@ export function createApp(): express.Express {
|
||||
app.use(cookieParser())
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
|
||||
/** Passkey login/register only — not person-pool, profile, etc. */
|
||||
const authFlowPaths = new Set([
|
||||
'/register-options',
|
||||
'/register-verify',
|
||||
'/login-options',
|
||||
'/login-verify',
|
||||
'/reauth-options',
|
||||
'/reauth-verify',
|
||||
'/logout',
|
||||
'/session'
|
||||
])
|
||||
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 60,
|
||||
@@ -66,7 +78,12 @@ export function createApp(): express.Express {
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
app.use('/api/auth', authLimiter)
|
||||
app.use('/api/auth', (req, res, next) => {
|
||||
if (authFlowPaths.has(req.path)) {
|
||||
return authLimiter(req, res, next)
|
||||
}
|
||||
return next()
|
||||
})
|
||||
app.use('/api/collaboration/invite-details', publicCollaborationLimiter)
|
||||
app.use('/api/collaboration/share-pull', publicCollaborationLimiter)
|
||||
app.use('/api', apiLimiter)
|
||||
|
||||
Reference in New Issue
Block a user