feat: web-server mode#59
Merged
Merged
Conversation
- New OpenIPC.Viewer.Web project: Kestrel host via ASP.NET shared framework - WebServer + WebServerOptions: localhost-only bind by default, opt-in --lan - --server-only headless entry in Desktop (Program.cs early-return, no GUI) - /healthz, /api/v1/version endpoints + minimal security-header floor
- IWebAuthProvider seam; public build ships single-admin PasswordAuthProvider (PBKDF2) - Admin password from OPENIPC_WEB_ADMIN_PASSWORD env, else generated + logged once - SessionStore: 256-bit opaque bearer tokens, stores only SHA-256 digest, sliding TTL - login (rate-limited 5/min/IP) / logout / me endpoints + bearer guard over /api/v1 - Origin check on mutations; private .ovac RBAC can plug in as another provider later
- WebBackend.AddWebBackend(dbPath): SQLite persistence only, no Avalonia/App - WebServer runs migrations on startup when a backend is composed - GET /api/v1/cameras behind auth; CameraDto strips RTSP creds, exposes hasCredentials bool - Desktop server-only points the web host at the same on-disk database - New OpenIPC.Viewer.Web.Tests: golden no-secret DTO test + auth/session tests
- POST/PUT/DELETE /api/v1/cameras via CameraDirectoryService (creds to secret store) - CameraWriteRequest.TryValidate: required fields, port ranges, RTSP URI, creds all-or-nothing - Audit line per mutation (action/camera/user/ip); 400/404/503 handled - WebBackend registers CameraDirectoryService; server-only reuses Desktop platform services
- GET/POST/PUT/DELETE /api/v1/groups via IGroupRepository; delete-in-use -> 409 - Shared ApiHelpers (backend/validation/not-found/audit); CameraApi refactored onto them
- Pivot UI to server-rendered Razor (static SSR): no wasm-tools/node, same Kestrel host - Web project -> Microsoft.NET.Sdk.Razor; AddRazorComponents + MapRazorComponents<App> - Cookie-carried session (HttpOnly openipc_session); AuthApi.TokenFrom = bearer or cookie - Login/logout form-post endpoints under /app/auth/*; cameras list page renders sanitized DTOs
- UseForwardedHeaders (X-Forwarded-For/Proto/Host) first in the pipeline - Trusts loopback by default + WebServerOptions.TrustedProxies for off-host proxies - Fixes Origin/CSRF check rejecting proxied mutations; direct exposure ignores spoofed headers
- WS GET /api/v1/cameras/{id}/live: ffmpeg remuxes RTSP H.264 -> fMP4 (copy) to the socket
- Credentials injected into the RTSP URL for ffmpeg only, never surfaced to any client
- Live.razor plays via MSE; JS reads the exact codec from the avcC init box (any H.264 profile)
- UseWebSockets; auth-guarded (cookie/bearer); ffmpeg killed on client disconnect
- Verified live: valid fMP4 (ftyp/moov/moof/avcC) over WS on a real camera; no token -> 401
- Grid.razor at /app/grid[/{size}] with 1/4/9 layouts, auth-guarded
- Auto-fills cells from grid-included cameras; each tile plays via shared MSE starter
- Extract window.openipcLive.start into App head; Live.razor reuses it
- Cameras header links to Grid
- localhost / 127.0.0.1 / ::1 mix no longer false-rejects with bad_origin - Compare hostnames port-agnostically (matches SameSite scope); cross-site still blocked
- Referrer-Policy no-referrer made Chrome send "Origin: null" on same-site form POST, tripping the CSRF check; switch to strict-origin-when-cross-origin - Treat null/opaque Origin as allowed (SameSite=Strict is the real CSRF defense) - Log Origin/Host on bad_origin rejections for diagnosis
- CameraEditor page (/app/cameras/new + /{id}/edit) with a full server-rendered form
- Form-post create/update/delete under /app/cameras/*, reusing validation + directory service
- Cameras list gains Add / Edit / Delete (confirm) actions; edit keeps creds when blank
…ce 20.4) - Live WS honours ?transcode=1: re-encodes to H.264 with libopenh264 (LGPL build has no libx264) - Client detects hvcC / unsupported codec and reconnects asking for transcode - H.264 stays on cheap copy; transcode CPU only for codecs the browser can't play - Verified: transcode WS delivers valid H.264 fMP4 (avc1.42C01F, constrained baseline)
…20 slice 20.4) - LiveStreamHub: shared per-(camera,mode) ffmpeg; parses MP4 boxes, caches the init segment, broadcasts whole fragments so late joiners never get a partial - Per-subscriber bounded channel (drop-oldest) so a slow viewer can't stall others - Refcounted: ffmpeg starts on first viewer, stops when the last leaves - LiveApi slimmed to subscribe + pump; LiveFfmpeg holds the process helpers - Verified: 2 viewers of one camera share a single ffmpeg; cleaned up on leave
- WebStrings (EN/RU) + scoped WebLocalizer (lang cookie, else Accept-Language)
- /app/lang/{code} switcher (cookie) + EN·RU links in headers
- All pages (login, cameras, grid, editor, live) use @l["key"]
- Desktop Localizer stays in the App layer; web keeps its own small dictionary
…lish) - Port desktop design tokens (Colors/Theme.axaml) to CSS variables: indigo accent, dark surfaces, text scale, radii, 8px grid, Inter stack - Replace top headers with a left NavSidebar (icon nav, user + lang + logout) - All pages use the .shell layout; login is a centered card
… 20 tail) - SystemPage (/app/system): version, camera/group counts, active sessions & streams - GET /app/config/export downloads browser-safe JSON (no camera passwords) - POST /app/config/import (file upload) applies via IConfigBackupService - POST /app/sessions/revoke-all clears every session; NavSidebar gains a System item - WebBackend registers ILayoutRepository + IConfigBackupService
- New src/OpenIPC.Viewer.Web.Client: Vite+React+TS SPA over the frozen Phase 20 /api/v1 — Login, Cameras+CRUD, Grid, Groups, System, RU/EN. - Grid tiles keyed by cameraId survive 1/4/9 layout switches without restarting the <video>/WS/ffmpeg session (verified: SAME DOM nodes). - useLiveTile hook ports the proven MSE+WS client (avcC/hvcC, HEVC transcode). - Cookie auth: JSON /api/v1/auth/login now also sets the HttpOnly cookie so REST + same-origin live-WS authenticate off it; logout clears it. - SPA embedded into Web.dll (ManifestEmbeddedFileProvider) + MapFallbackToFile; MSBuild target runs npm build on dotnet build/publish; CI gets Node+npm cache. - Razor kept at /app as transitional fallback; SPA served at /.
- Live-status pill with colored dot (live/connecting/error/offline) + centered spinner until the first frame arrives. - Tile hover highlight + fullscreen (hover button or double-click). - Pad short layouts to a regular NxN with dashed empty slots. - Grid uses full width (camera wall) instead of the 1100px content cap.
- Quick start (LAN), flags/env reference, adding cameras, ffmpeg. - Reverse-proxy (Caddy/nginx) HTTPS + systemd service examples.
…EADME - web-server.md: "How it's built" — React/Vite compiled + embedded into the server DLL, no Node/dist at runtime. - README: web-console feature bullet + self-host section linking the guide.
- Add docs/web-server.ru.md (full RU), cross-link EN <-> RU at top. - README self-host section links both languages.
- Api/LayoutApi.cs: GET/POST/PUT/DELETE /api/v1/layouts + PUT tiles over the Phase 19 ILayoutRepository (name, NxN grid size, ordered camera tiles). - Grid page: layout tabs, switch layouts, create/rename/delete, edit mode to assign a camera per cell and change grid size (1..25 cells). - Switching layouts keeps cameras present in both playing — no video restart (verified: shared tile survives X->Y->X as the same DOM node). - Editor preserves tiles beyond the visible page (no truncation of paginated desktop layouts). RU/EN strings, n16/n25 grid CSS.
- components/Modals.tsx: TextPromptModal + ConfirmModal on the shared skin. - Grid layout create/rename/delete now use them (rename pre-filled, delete gets a red confirm) instead of browser dialogs.
- Cameras delete, Groups rename/delete and System revoke-all now use the in-app TextPromptModal / ConfirmModal. - Confirm dialogs name the affected item; destructive ones get a red button. - No confirm()/prompt()/alert() left in the client.
- New /camera/:id page: one large live tile, details (host, group, RTSP, capabilities, chip/firmware), edit + delete. Replaces /grid?only=<id>. - Extract the live tile into components/LiveTile.tsx, shared by grid and page. - Responsive at the app-wide 700px breakpoint: sidebar becomes a bottom tab strip (safe-area aware), grids clamp to 2 columns, key/value tables stack, wide list tables scroll inside their container, modals go full width. - Account controls (user, EN/RU, sign out) added to System, since the mobile tab strip has no room for the sidebar footer.
The two are one commit because they meet in WebServer/csproj: the PTZ endpoints are mapped in the same file the Razor mapping leaves, and both edit the project's SDK/references. - Web backend references Devices and registers SoapOnvifClient; new PtzApi with move/stop and preset list/save/goto/delete. Movement is stateless: each move carries an ONVIF self-stop timeout that the client refreshes while held, so a closed tab cannot leave the camera panning. - CameraDto gains PtzReady (HasPtz + a probed profile token); the SPA shows the pad only when the calls can actually work. - delete Components/**, WebLocalizer/WebStrings, UiApi, CameraFormApi; the project goes back to the plain SDK (no .razor left to compile). - /app/config/export|import and /app/sessions/revoke-all become /api/v1 endpoints; import answers JSON counts instead of a redirect. - SPA fallback no longer swallows unmatched /api paths: they 404 with JSON instead of returning index.html as 200 text/html.
- self-host Inter from the inter-ui package (rsms' own); the full variable file, not the latin subset, which drops Cyrillic - grid pages a layout the way the desktop does: page = gridSize^2 cells, only the current page gets live tiles - the editor now edits the current page and splices it back between the pages before and after, instead of always editing the first one
- live sessions move into a pool above the router: a tile borrows a <video> and hands it back on unmount, so grid -> camera -> grid re-attaches a stream that never stopped (30s retention, then closed) - parked videos stay in the document; the spec pauses media elements removed from one, so they park in a 1px off-screen container - fix a pre-existing player bug: fMP4 carries the ffmpeg session's timestamps, so a viewer joining a running stream got a buffer starting minutes ahead of currentTime and never played at all. A watchdog now seeks to the live edge and resumes.
- Web backend composes the same discovery stack as the desktop (ONVIF WS-Discovery, mDNS, opt-in subnet sweep) behind DiscoveryApi - a scan is a background job polled by id, so it survives navigation and a second viewer joins the running one instead of starting a rival - add is probe -> review -> add: ONVIF is the only source of the real RTSP URI and it can be wrong behind NAT, so nothing is written until the user has seen it. The probe result is persisted as ONVIF metadata, the way the desktop add flow does, or PTZ would be lost. - ServerSettings supplies the IUserSettingsAccessor defaults the sources need; the only implementation lives in the App layer, which the web backend must not reference.
Replaces the single admin account with a roster, using the same model as the desktop access config so the two stay one feature: permission flags (ViewLive/ViewArchive/Ptz/Export/Manage) plus a camera subset per user. - WebUserStore: JSON roster next to the database, PBKDF2 hashes only, written atomically so a crash cannot lock everyone out - the bootstrap admin keeps working after a roster exists — it is the way back in when the last Manage account is lost - PermissionGuard: one line per endpoint. A camera outside the caller's subset answers 404, not 403, so ids can't be probed for existence - live video and PTZ check the subset before touching the backend - /api/v1/users for management, /me carries permissions so the SPA can hide what the caller may not do (the server still enforces all of it) - restricted users get layout tiles filtered to what they can see, and no layout editing at all: saving a partly visible layout would splice the hidden cameras out of it
- self-hosting guide: discovery as a way to add cameras, a users and permissions section, refreshed feature list and security notes - spell out that the web server is a separate door to the same cameras with its own accounts - README: same in one line, both languages of the guide unchanged in shape
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Web console: React SPA, PTZ, discovery, and user permissions
Grows the
--server-onlyweb console from the Phase 20 foundation into aself-contained browser client for OpenIPC cameras. The Razor UI it shipped
with is removed — the SPA reached parity and then passed it.
What's new
React SPA replaces the server-rendered UI. Client-routed, so navigating
never reloads the page — and, crucially, never restarts video. Live tiles are
keyed by camera id and survive a grid resize (1 → 9 → 1 keeps the shared
cameras playing), a layout switch, a page flip, and now a route change: the
<video>elements live in a pool above the router and are borrowed by tiles,with a 30s retention window before the stream is closed.
PTZ from the browser. Pan/tilt/zoom with presets, for cameras that report
PTZ and have a probed ONVIF profile. Movement is stateless on the server: each
move carries an ONVIF self-stop timeout that the client refreshes while a
button is held, so a closed tab cannot leave a camera panning.
Network discovery. ONVIF WS-Discovery, mDNS and an opt-in subnet sweep,
using the same aggregator as the desktop app. A scan is a background job polled
by id, so it survives navigation and a second viewer joins the running scan
instead of starting a rival. Adding is probe → review → add: ONVIF is the only
source of the real RTSP URI and it can be wrong behind NAT, so nothing is
written until the user has seen it.
Users and permissions. Accounts with permission flags
(
ViewLive/ViewArchive/Ptz/Export/Manage) and an optionalper-user camera subset. The roster is a JSON file next to the database holding
PBKDF2 hashes only; the built-in admin keeps working as the way back in if the
last
Manageaccount is lost. Authorization is enforced per endpoint — the UIhides what you may not do, but hiding is not the boundary. A camera outside
your subset answers 404 rather than 403, so ids can't be probed for existence.
Also: saved grid layouts with paging (page = gridSize², like the desktop
head), a single-camera page, mobile layout at the 700px breakpoint, in-app
modals instead of
prompt/confirm, and Inter bundled as a self-hosted woff2(full variable file, not the latin subset, which drops Cyrillic).
Fixes found along the way
ffmpeg session's timestamps, so a viewer joining a running stream got a buffer
starting minutes ahead of
currentTime, outside every buffered range —autoplay never fired and the tile sat frozen. A watchdog now seeks to the live
edge and resumes. Pre-existing, not caused by the pooling above (verified
against an element that is never reparented).
/apipaths returnedindex.htmlwith 200 text/html via theSPA fallback; they now 404 with JSON.
Breaking
/appis gone;/serves the SPA. Its form-post endpoints(
/app/config/export|import,/app/sessions/revoke-all) moved to/api/v1and answer JSON instead of redirects.
OpenIPC.Viewer.Webis a plain SDK project again (no.razorleft tocompile); the SPA is embedded in the assembly, so
dotnet publishstillproduces one self-contained binary. Node is a build-time-only dependency and
CI installs it.
Verification
Exercised live against a real 82-camera database via
--server-only:persistence (same DOM nodes,
currentTimeadvanced through the absence,server stream count unchanged), retention expiry (streams 8 → 0, no leak);
byte-for-byte (checked on a throwaway layout, then deleted);
403 on camera CRUD, users, discovery, export, PTZ, layouts and revoke-all; an
operator gets 404 (not 403) for PTZ and live on a camera outside their subset;
and add creating a camera with ONVIF metadata persisted;
Builds with 0 warnings (
TreatWarningsAsErrorsis on repo-wide); eslint andtscare clean.Not verified: PTZ against a physical PTZ camera, and discovery against
cameras on a real LAN — the dev machine has neither. Both paths are exercised
only through their error/fallback branches and a stubbed scan payload.
Related
Type
Checklist
TreatWarningsAsErrors=true).dotnet test); new Core logic has unit tests.AppreferencesCoreonly (Infrastructure / Video / Devices wired via DI in a head).Platforms tested
Screenshots / notes