PicPeak POSTs lifecycle notifications to admin-configured URLs. Each delivery is signed HMAC-SHA256 in the X-PicPeak-Signature header. Verified end-to-end: 1/1 Playwright spec, 8/8 backend integration tests, full UI click-through via Chrome DevTools. Schema (migration 082) - webhooks: id, name, url, secret (plaintext — required to compute HMAC for every outbound POST), secret_preview, events[], active, filter, template, created_by, timestamps, last_success_at/last_failure_at. - webhook_deliveries: webhook_id (FK CASCADE), event_type, payload, attempt_count, status (pending|success|failed), response_status, response_body (truncated to 1KB), latency_ms, next_retry_at, last_error, created_at, completed_at. Composite index (status, next_retry_at) serves the worker's hot-path query. Service + worker - webhookService.fire(eventType, data) — non-throwing entry point used by lifecycle hooks. Looks up active webhooks subscribed to the event and applies their per-webhook filter (dot-path equality predicate) before enqueueing one webhook_deliveries row per match. Filter and template logic ship in this commit; admin surfaces in the follow-up. - webhookDeliveryWorker — setInterval(5s) poller; fetches up to 5 pending rows; per delivery: re-validates URL via networkValidation (DNS-rebinding mitigation, opt-out via WEBHOOK_ALLOW_PRIVATE_URLS), signs body with HMAC-SHA256, POSTs with 10s timeout, records outcome. Backoff schedule: 1m → 5m → 30m → 2h → 12h, max 5 attempts. Response body truncated to 1KB before storage. If a webhook has a template, the rendered string replaces the JSON envelope as the request body (signature is computed over the bytes actually sent). Lifecycle wiring - adminEvents.js POST /events → event.created (+ event.published when not draft); POST /:id/publish → event.published. - routes/events.js (legacy public POST) → event.created + event.published. - routes/v1/events.js (#322 API) → event.created + event.published on create, photo.uploaded on photo POST. - archiveService.archiveEvent() → event.archived. Per-photo photo.deleted intentionally NOT fired during cascade — receivers infer from event.archived to avoid flooding (issue spec). - expirationChecker.handleExpiredEvent() → event.expired BEFORE the cascading archive (so receivers see expired→archived in order). - adminPhotos.js — photo.uploaded on each batch row, photo.deleted on single + bulk delete. - photoProcessor.js — photo.uploaded for guest uploads + auto-import (covers all entry paths). - fileWatcher.js — photo.uploaded on add, photo.deleted on unlink (local mode only). Admin endpoints (mirrors adminApiTokens.js pattern) - /api/admin/webhooks: GET list, POST create (returns plaintext secret exactly once), GET :id, PUT :id, DELETE :id, POST :id/test (synthetic fire), GET :id/deliveries (paginated, filter by status), GET :id/deliveries/:deliveryId, POST :id/deliveries/:deliveryId/replay. Frontend - Settings → Webhooks tab (mirrors API Tokens layout): name + URL + event checkboxes + "Advanced" expander for filter (JSON) and template. Plaintext secret shown once on creation with a Copy button. Active/ Disabled toggle button per row. - /admin/webhooks/:id/deliveries — operational debug surface. Table with timestamp/event/status/attempts/HTTP/latency. Status filter chips (all/pending/success/failed). Row click → slide-over with payload + signature + response body. Replay button on failed rows. Send-test-event dialog. Auto-refresh every 10s. Dev infrastructure - dev/webhook-receiver/ — tiny node:alpine HTTP server (~100 LOC) that records every POST to an in-memory ring buffer. Exposes GET /requests for the E2E spec to assert deliveries landed with the right HMAC. Sibling pattern to MinIO. Reachable from the backend at http://webhook-receiver:8888 inside the picpeak network. Tests - backend/__tests__/integration/webhookDelivery.test.js (8/8) — signature verification, headers, retry/backoff, max-attempts → failed, response truncation, disabled-mid-flight, SSRF block, start/stop idempotency. - tests/e2e/webhooks-roundtrip.spec.ts (1/1) — create webhook → trigger event.published → assert receiver got POST with valid HMAC → visit deliveries page → row visible with status=success → API test event → API replay → disable webhook → assert no new delivery. Docs - README §"Webhooks" — event catalog, payload shape, HMAC verification in Node + Python + bash, retry semantics, SSRF protection. - .env.example — WEBHOOK_ALLOW_PRIVATE_URLS, WEBHOOK_DELIVERY_INTERVAL_MS, WEBHOOK_DELIVERY_CONCURRENCY, WEBHOOK_HTTP_TIMEOUT_MS, WEBHOOK_MAX_ATTEMPTS. Out of scope for v1 (per issue): webhook templates' code-eval (the ${dot.path} substitution that ships is pure string replacement, no expression engine — see follow-up commit), per-webhook rate limiting beyond the global concurrency cap, synchronous "ask before delete" webhooks. Spanning files - App.tsx pulls in this commit with both the AnalyticsBootstrap (#325 dedup) and the WebhookDeliveriesPage route registration. Splitting via git add -p was forfeit for sanity; the single 92-line diff is honest about both contributions. - adminEvents.js diff bundles the webhook fires AND the allow_presigned_download field plumbing (#328 follow-up). Same reasoning. - The new webhookService/Worker/adminWebhooks files include the filter and template logic from the follow-up — they were authored in one pass; splitting them post-hoc would have produced fragile partial files. The follow-up commit covers the migration and the UI for these.
188 lines
7.1 KiB
TypeScript
188 lines
7.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { ToastContainer } from 'react-toastify';
|
|
import 'react-toastify/dist/ReactToastify.css';
|
|
import { analyticsService } from './services/analytics.service';
|
|
|
|
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
|
|
import { ThemeProvider } from './contexts/ThemeContext';
|
|
import { GalleryPage } from './pages/GalleryPage';
|
|
import { ClientAccessPage } from './pages/ClientAccessPage';
|
|
import { PreviewPage } from './pages/gallery/PreviewPage';
|
|
import { LegalPage } from './pages/public/LegalPage';
|
|
import {
|
|
AdminLoginPage,
|
|
AdminDashboard,
|
|
EventsListPage,
|
|
CreateEventPage,
|
|
EventDetailsPage,
|
|
EventFeedbackPage,
|
|
EmailConfigPage,
|
|
ArchivesPage,
|
|
AnalyticsPage,
|
|
BrandingPage,
|
|
SettingsPage,
|
|
BackupManagement,
|
|
CMSPage,
|
|
UserManagementPage,
|
|
EventTypesPage,
|
|
WebhookDeliveriesPage
|
|
} from './pages/admin';
|
|
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
|
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
|
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
|
|
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
|
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
|
import { usePublicSettings } from './hooks/usePublicSettings';
|
|
|
|
// Create a client
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
refetchOnWindowFocus: false,
|
|
retry: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Bootstraps Umami analytics from /public/settings. Lives inside QueryClientProvider
|
|
// so it shares the public-settings cache with every other consumer of usePublicSettings.
|
|
function AnalyticsBootstrap() {
|
|
const { data: settings, isError } = usePublicSettings();
|
|
|
|
useEffect(() => {
|
|
if (!settings && !isError) return;
|
|
|
|
const envUmamiUrl = import.meta.env.VITE_UMAMI_URL;
|
|
const envUmamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
|
|
|
if (settings?.umami_enabled && settings.umami_url && settings.umami_website_id) {
|
|
analyticsService.initialize({
|
|
websiteId: settings.umami_website_id,
|
|
hostUrl: settings.umami_url,
|
|
autoTrack: true,
|
|
doNotTrack: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (envUmamiUrl && envUmamiWebsiteId && (isError || settings?.enable_analytics !== false)) {
|
|
analyticsService.initialize({
|
|
websiteId: envUmamiWebsiteId,
|
|
hostUrl: envUmamiUrl,
|
|
autoTrack: true,
|
|
doNotTrack: true,
|
|
});
|
|
}
|
|
}, [settings, isError]);
|
|
|
|
return null;
|
|
}
|
|
|
|
function App() {
|
|
// Track dark mode for toast theming
|
|
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
|
|
|
|
useEffect(() => {
|
|
const observer = new MutationObserver(() => {
|
|
setToastTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light');
|
|
});
|
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
return (
|
|
<PageErrorBoundary>
|
|
<QueryClientProvider client={queryClient}>
|
|
<AnalyticsBootstrap />
|
|
<MaintenanceProvider>
|
|
<ThemeProvider>
|
|
<GlobalThemeProvider>
|
|
<DynamicFavicon />
|
|
<RobotsMetaTags />
|
|
<Router>
|
|
<MaintenanceWrapper>
|
|
<SkipLink />
|
|
<Routes>
|
|
{/* Public gallery routes */}
|
|
<Route path="/gallery/preview" element={<PreviewPage />} />
|
|
<Route path="/gallery/:slug/client-access" element={
|
|
<GalleryAuthProvider>
|
|
<ClientAccessPage />
|
|
</GalleryAuthProvider>
|
|
} />
|
|
<Route path="/gallery/:slug/:token?" element={
|
|
<GalleryAuthProvider>
|
|
<GalleryPage />
|
|
</GalleryAuthProvider>
|
|
} />
|
|
|
|
{/* Admin routes - wrap with AdminAuthProvider */}
|
|
<Route path="/admin" element={<AdminAuthWrapper />}>
|
|
<Route path="login" element={<AdminLoginPage />} />
|
|
<Route element={<AdminLayout />}>
|
|
<Route path="dashboard" element={<AdminDashboard />} />
|
|
<Route path="events" element={<EventsListPage />} />
|
|
<Route path="events/new" element={<CreateEventPage />} />
|
|
<Route path="events/:id" element={<EventDetailsPage />} />
|
|
<Route path="events/:id/feedback" element={<EventFeedbackPage />} />
|
|
<Route path="archives" element={<ArchivesPage />} />
|
|
<Route path="email" element={<EmailConfigPage />} />
|
|
<Route path="analytics" element={<AnalyticsPage />} />
|
|
<Route path="branding" element={<BrandingPage />} />
|
|
<Route path="settings" element={<SettingsPage />} />
|
|
<Route path="event-types" element={<EventTypesPage />} />
|
|
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
|
|
<Route path="backup" element={<BackupManagement />} />
|
|
<Route path="cms" element={<CMSPage />} />
|
|
<Route path="users" element={<UserManagementPage />} />
|
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
|
</Route>
|
|
</Route>
|
|
|
|
{/* Public invitation acceptance page */}
|
|
<Route path="/invite/:token" element={<AcceptInvitePage />} />
|
|
|
|
{/* Public legal pages */}
|
|
<Route path="/impressum" element={<LegalPage />} />
|
|
<Route path="/datenschutz" element={<LegalPage />} />
|
|
<Route path="/:slug" element={<LegalPage />} />
|
|
|
|
{/* Default redirect */}
|
|
<Route path="/" element={<Navigate to="/admin/login" replace />} />
|
|
|
|
{/* Customisable 404 (#324) — caught here for any path that
|
|
didn't match. Top-level `/:slug` is consumed above by
|
|
LegalPage; this picks up deeper unknown paths. */}
|
|
<Route path="*" element={<CMSContentBlock slug="not-found" />} />
|
|
</Routes>
|
|
</MaintenanceWrapper>
|
|
</Router>
|
|
|
|
{/* Offline indicator */}
|
|
<OfflineIndicator />
|
|
|
|
{/* Toast notifications */}
|
|
<ToastContainer
|
|
position="bottom-right"
|
|
autoClose={5000}
|
|
hideProgressBar={false}
|
|
newestOnTop
|
|
closeOnClick
|
|
rtl={false}
|
|
pauseOnFocusLoss
|
|
draggable
|
|
pauseOnHover
|
|
theme={toastTheme}
|
|
/>
|
|
</GlobalThemeProvider>
|
|
</ThemeProvider>
|
|
</MaintenanceProvider>
|
|
</QueryClientProvider>
|
|
</PageErrorBoundary>
|
|
);
|
|
}
|
|
|
|
export default App;
|