Compare commits
16
Commits
2ed89760d6
..
1.0.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f73928103a | ||
|
|
b427353672 | ||
|
|
f9aba11b90 | ||
|
|
926a663c23 | ||
|
|
6b9830de05 | ||
|
|
914a518f20 | ||
|
|
8064e459f2 | ||
|
|
587561f9fc | ||
|
|
887023a491 | ||
|
|
994c433fc8 | ||
|
|
6632df0bc7 | ||
|
|
e4ffa0b356 | ||
|
|
e8a43b8a79 | ||
|
|
b3a10ba3ef | ||
|
|
46e878bef6 | ||
|
|
39cca5d235 |
No files matched your search
@@ -0,0 +1,55 @@
|
||||
# EditorConfig is awesome: https://editorconfig.org
|
||||
root = true
|
||||
|
||||
# -------------------------------------------------
|
||||
# General settings – apply to all files
|
||||
# -------------------------------------------------
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
# -------------------------------------------------
|
||||
# Markdown (*.md)
|
||||
# -------------------------------------------------
|
||||
[*.md]
|
||||
max_line_length = off # Markdown lines can be long
|
||||
trim_trailing_whitespace = false # Preserve intentional spaces
|
||||
|
||||
# -------------------------------------------------
|
||||
# Go (*.go)
|
||||
# -------------------------------------------------
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
max_line_length = 100
|
||||
|
||||
# -------------------------------------------------
|
||||
# JavaScript & TypeScript (*.js, *.jsx, *.ts, *.tsx)
|
||||
# -------------------------------------------------
|
||||
[*.{js,jsx,ts,tsx}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
max_line_length = 120
|
||||
|
||||
# -------------------------------------------------
|
||||
# HTML (*.html, *.htm)
|
||||
# -------------------------------------------------
|
||||
[*.{html,htm}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = 120
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
# -------------------------------------------------
|
||||
# CSS & SCSS (*.css, *.scss)
|
||||
# -------------------------------------------------
|
||||
[*.{css,scss}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = 120
|
||||
trim_trailing_whitespace = true
|
||||
+11
-1
@@ -43,7 +43,17 @@ jobs:
|
||||
run: cd backend/chat && GOTOOLCHAIN=local go vet ./...
|
||||
|
||||
- name: Run Tests
|
||||
run: echo "Tests will be added in future CI steps"
|
||||
run: cd backend/chat && GOTOOLCHAIN=local go test ./... -v -cover -coverprofile=coverage.out -race
|
||||
|
||||
- name: Check Coverage
|
||||
run: |
|
||||
cd backend/chat
|
||||
COVERAGE=$(GOTOOLCHAIN=local go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
|
||||
echo "Coverage: $COVERAGE%"
|
||||
if [ "$(echo "$COVERAGE < 50" | bc)" -eq 1 ]; then
|
||||
echo "Coverage $COVERAGE% below 50% threshold"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker:
|
||||
needs: test
|
||||
|
||||
+10
@@ -7,6 +7,10 @@
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Test coverage
|
||||
coverage
|
||||
coverage.out
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -15,6 +19,10 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Benchmark artifacts
|
||||
artifacts/
|
||||
scripts/loadtest/artifacts/
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
@@ -41,6 +49,7 @@ out/
|
||||
# FFmpeg temporary files
|
||||
*.ts.tmp
|
||||
*.m3u8.tmp
|
||||
*.m4s.tmp
|
||||
hls/
|
||||
temp/
|
||||
|
||||
@@ -81,6 +90,7 @@ Thumbs.db
|
||||
# Agent / Tool files
|
||||
.github/agents/
|
||||
.github/instructions/
|
||||
.github/MEMORY.md
|
||||
|
||||
# Temporary architecture artifacts
|
||||
**/*.tmp
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# =============================================
|
||||
# Linear Void .prettierignore
|
||||
# =============================================
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
*.min.js
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
coverage.out
|
||||
|
||||
# Go source (handled by gofmt, not prettier)
|
||||
*.go
|
||||
go.mod
|
||||
go.sum
|
||||
|
||||
# Binary / compiled
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Media files
|
||||
*.m4s
|
||||
*.m3u8
|
||||
*.ts.tmp # FFmpeg temp, not TypeScript
|
||||
*.m3u8.tmp
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.svg
|
||||
*.ico
|
||||
*.webp
|
||||
*.mp4
|
||||
*.webm
|
||||
*.mp3
|
||||
*.ogg
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Databases
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Lockfile (auto-generated, don't reformat)
|
||||
package-lock.json
|
||||
|
||||
# Shell scripts (prettier doesn't handle sh)
|
||||
*.sh
|
||||
|
||||
# Environment & secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Editor & IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
|
||||
# Terraform / IaC
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
.terraform/
|
||||
|
||||
# Temp & backup files
|
||||
**/*.tmp
|
||||
**/*.bak
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
@@ -2,6 +2,42 @@
|
||||
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
## [1.0.11] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Phase 1 test fixtures**: Media and playlist test fixtures in `backend/playout/testdata/` with ffmpeg generation commands documented in test headers.
|
||||
- **Phase 4 R2 integration**: `SyncToR2` now sets `CacheControl` + `ContentType` based on file extension (immutable segments, short-lived manifests). Three integration tests added.
|
||||
- **Phase 2 viewer tests**: Presence updates (join/leave/sorted), private DM flow, message rendering (system/self/other) in `main.test.ts` (27 total tests).
|
||||
- **Phase 3 admin tests**: Playlist management (save via PUT, error handling, sortable rendering) in `admin.test.ts` (31 total tests passing).
|
||||
|
||||
### Changed
|
||||
|
||||
- Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts` for testing.
|
||||
|
||||
## [1.0.10] - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- **WebSocket summary helper**: Added `scripts/loadtest/summarize-artillery.ps1` to extract counters, rates, and available percentile stats from Artillery JSON output.
|
||||
- **Playbook worksheet mapping**: Added artifact-to-worksheet mapping section in `docs/operations/scaling-performance-playbook.md` for WebSocket and Admin API benchmark artifacts.
|
||||
- **Test fixture generation docs**: Added ffmpeg commands for generating valid and invalid media fixtures as comments in `backend/playout/src/playlist_test.go`. Phase 1 test fixtures now complete.
|
||||
- **R2 cache header support**: `SyncToR2` now sets `CacheControl` and `ContentType` based on file extension:
|
||||
- `.m4s`/`.ts`/`.mp4`/`.m4a` → immutable (max-age=31536000)
|
||||
- `.m3u8` → short-lived (max-age=2s)
|
||||
- unknown → no cache headers
|
||||
- **R2 integration tests**: Added `TestSyncToR2_ManifestCacheHeaders` and `TestSyncToR2_NoCacheHeadersForUnknownExt` verifying cache header behavior for segment, manifest, and unknown file types.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Loadtest runner**: `scripts/loadtest/run-loadtests.ps1` now generates `ws-chat-summary.txt` automatically for `ws` and `all` scenarios.
|
||||
- **Loadtest docs**: `scripts/loadtest/README.md` now documents `summarize-artillery.ps1` usage and the new `ws-chat-summary.txt` artifact.
|
||||
- **Planning status wording**: Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while benchmark execution remains pending.
|
||||
- **Phase 1 complete**: Test fixture creation and ffmpeg documentation marked done.
|
||||
- **Phase 4 partially complete**: R2 upload + cache header validation done; edge cache hit/miss validation remains manual.
|
||||
- **Phase 2 frontend tests**: Added presence updates (join/leave rendering), private DM flow, and message rendering tests in `main.test.ts` (27 total tests passing).
|
||||
- **Phase 3 admin frontend tests**: Added playlist management tests (save via PUT, error handling, sortable rendering) in `admin.test.ts` (31 total tests passing).
|
||||
|
||||
## [1.0.9] - 2026-08-06
|
||||
|
||||
### Added
|
||||
@@ -16,10 +52,15 @@
|
||||
- **CI pipeline**: Removed redundant `release` job (handled by `release.yml`).
|
||||
- **Tag format**: Standardized on `*.*.*` (e.g. `1.0.9`), removed `v` prefix from existing tags.
|
||||
- **DEPLOYMENT.md**: Documented parallel CI/CD pipeline flow with workflow table.
|
||||
- **Runtime checks**: Executed normalization edge-case tests (MOV, MKV, VFR, 5.1 input) and concat continuity verification in running playout environment.
|
||||
- **Incident runbook**: Added quarterly disaster recovery drill procedure with prerequisites, outage simulation, restore validation, and evidence template.
|
||||
- **Manual ops checklist**: Added `docs/operations/cloudflare-uptimerobot-checklist.md` to execute and record Cloudflare + UptimeRobot dashboard tasks with evidence capture.
|
||||
- **Scaling playbook**: Added `docs/operations/scaling-performance-playbook.md` with benchmark methods, trigger thresholds, and Phase 2/3 scaling decision flow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Prometheus config mount**: Switched prod from bind mounts to named volume `prometheus-config`, populated via `docker run` on remote host. Fixes `not a directory` error when bind source path doesn't exist on deployment host.
|
||||
- **Viewer mobile overflow**: Replaced `100vw` with `100%` on full-screen viewer containers to remove horizontal overflow on mobile viewports.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||
[](LICENSE)
|
||||
[](https://git.allucanget.biz)
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||
[](CHANGELOG.md)
|
||||
|
||||
## Architecture
|
||||
@@ -15,7 +15,7 @@
|
||||
The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses:
|
||||
|
||||
- Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat _(planned production; currently test lab at 192.168.88.40)_.
|
||||
- Cloudflare R2 + CDN with aggressive immutable caching of HLS `.ts` segments ($0 egress).
|
||||
- Cloudflare R2 + CDN with aggressive immutable caching of `.m4s` segments ($0 egress).
|
||||
- Lightweight static frontend and WebSocket chat backend.
|
||||
- Gitea self-hosted CI/CD (Actions) and Container Registry.
|
||||
|
||||
@@ -206,11 +206,12 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
|
||||
|
||||
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
||||
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
||||
- [API.md](API.md) - Implemented WebSocket and REST API reference.
|
||||
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
|
||||
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
||||
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
||||
- [SETUP.md](docs/SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
||||
- [API.md](docs/API.md) - Implemented WebSocket and REST API reference.
|
||||
- [DEPLOYMENT.md](docs/DEPLOYMENT.md) - Production deployment and operations guide.
|
||||
- [MONITORING.md](docs/MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
||||
- [SECURITY.md](docs/SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
||||
- [Scaling Playbook](docs/operations/scaling-performance-playbook.md) - Performance test method, thresholds, and scaling decision flow.
|
||||
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
||||
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
||||
|
||||
@@ -231,4 +232,4 @@ Since this is a linear project, we also rely on a linear development and release
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
The project is ready for use. See [`SETUP.md`](SETUP.md) for detailed installation instructions.
|
||||
The project is ready for use. See [`SETUP.md`](docs/SETUP.md) for detailed installation instructions.
|
||||
+11
-2
@@ -3,24 +3,33 @@ module chat
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
+26
-4
@@ -9,6 +9,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -23,12 +33,15 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -38,6 +51,9 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -46,7 +62,13 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionStore manages admin authentication sessions.
|
||||
type SessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]time.Time
|
||||
}
|
||||
|
||||
// NewSessionStore creates a new session store.
|
||||
func NewSessionStore() *SessionStore {
|
||||
return &SessionStore{
|
||||
sessions: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken creates a cryptographically random 64-char hex session token.
|
||||
func GenerateToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// Validate checks if a session token is valid and not expired.
|
||||
// Returns true if valid, false otherwise. Expired sessions are cleaned up.
|
||||
func (s *SessionStore) Validate(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
expiresAt, ok := s.sessions[token]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(expiresAt) {
|
||||
delete(s.sessions, token)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Add stores a session token with expiry.
|
||||
func (s *SessionStore) Add(token string, expiresAt time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[token] = expiresAt
|
||||
}
|
||||
|
||||
// Remove deletes a session token.
|
||||
func (s *SessionStore) Remove(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
|
||||
// CountActive returns the number of unexpired sessions.
|
||||
func (s *SessionStore) CountActive() int {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for token, expiresAt := range s.sessions {
|
||||
if now.After(expiresAt) {
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
}
|
||||
return len(s.sessions)
|
||||
}
|
||||
|
||||
// CookieSecure determines if the session cookie should have Secure flag.
|
||||
func CookieSecure(r *http.Request) bool {
|
||||
if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
|
||||
return strings.EqualFold(forwardedProto, "https")
|
||||
}
|
||||
return r.TLS != nil
|
||||
}
|
||||
|
||||
// SetCookie writes the admin session cookie.
|
||||
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: CookieSecure(r),
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// ClearCookie removes the admin session cookie.
|
||||
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: CookieSecure(r),
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTokenFromCookie extracts the session token from the request cookie.
|
||||
func GetTokenFromCookie(r *http.Request, cookieName string) (string, bool) {
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return "", false
|
||||
}
|
||||
return cookie.Value, true
|
||||
}
|
||||
|
||||
// RequireAdmin returns middleware that checks for a valid admin session.
|
||||
func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc) http.HandlerFunc {
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := GetTokenFromCookie(r, cookieName)
|
||||
if !ok || !store.Validate(token) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateToken(t *testing.T) {
|
||||
token, err := GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() error = %v", err)
|
||||
}
|
||||
if len(token) != 64 {
|
||||
t.Errorf("GenerateToken() length = %d, want 64", len(token))
|
||||
}
|
||||
|
||||
// Uniqueness check
|
||||
tokens := make(map[string]bool)
|
||||
for i := 0; i < 1000; i++ {
|
||||
tok, err := GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() iteration %d error = %v", i, err)
|
||||
}
|
||||
if tokens[tok] {
|
||||
t.Errorf("GenerateToken() produced duplicate token at iteration %d", i)
|
||||
}
|
||||
tokens[tok] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Validate(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
|
||||
// Empty token
|
||||
if store.Validate("") {
|
||||
t.Error("Validate(\"\") = true, want false")
|
||||
}
|
||||
|
||||
// Missing token
|
||||
if store.Validate("nonexistent") {
|
||||
t.Error("Validate(\"nonexistent\") = true, want false")
|
||||
}
|
||||
|
||||
// Valid token
|
||||
token, _ := GenerateToken()
|
||||
store.Add(token, time.Now().Add(1*time.Hour))
|
||||
if !store.Validate(token) {
|
||||
t.Error("Validate(valid) = false, want true")
|
||||
}
|
||||
|
||||
// Expired token
|
||||
expiredToken, _ := GenerateToken()
|
||||
store.Add(expiredToken, time.Now().Add(-1*time.Hour))
|
||||
if store.Validate(expiredToken) {
|
||||
t.Error("Validate(expired) = true, want false")
|
||||
}
|
||||
|
||||
// Expired token should be cleaned up
|
||||
if store.Validate(expiredToken) {
|
||||
t.Error("Validate(expired) second call = true, want false (should be cleaned)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_AddRemove(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
token, _ := GenerateToken()
|
||||
|
||||
store.Add(token, time.Now().Add(1*time.Hour))
|
||||
if !store.Validate(token) {
|
||||
t.Error("Validate after Add = false, want true")
|
||||
}
|
||||
|
||||
store.Remove(token)
|
||||
if store.Validate(token) {
|
||||
t.Error("Validate after Remove = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_CountActive(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
|
||||
if count := store.CountActive(); count != 0 {
|
||||
t.Errorf("CountActive() = %d, want 0", count)
|
||||
}
|
||||
|
||||
t1, _ := GenerateToken()
|
||||
t2, _ := GenerateToken()
|
||||
t3, _ := GenerateToken()
|
||||
|
||||
store.Add(t1, time.Now().Add(1*time.Hour))
|
||||
store.Add(t2, time.Now().Add(1*time.Hour))
|
||||
store.Add(t3, time.Now().Add(-1*time.Hour)) // expired
|
||||
|
||||
if count := store.CountActive(); count != 2 {
|
||||
t.Errorf("CountActive() = %d, want 2 (expired should be cleaned)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCookieSecure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
forwardedProto string
|
||||
tls bool
|
||||
expected bool
|
||||
}{
|
||||
{"https forwarded", "https", false, true},
|
||||
{"http forwarded", "http", false, false},
|
||||
{"empty forwarded, no TLS", "", false, false},
|
||||
{"empty forwarded, TLS", "", true, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
if tt.forwardedProto != "" {
|
||||
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||
}
|
||||
if tt.tls {
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
if got := CookieSecure(req); got != tt.expected {
|
||||
t.Errorf("CookieSecure() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCookie(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
SetCookie(w, req, "test_cookie", "abc123", expiresAt)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != "test_cookie" {
|
||||
t.Errorf("cookie name = %q, want test_cookie", c.Name)
|
||||
}
|
||||
if c.Value != "abc123" {
|
||||
t.Errorf("cookie value = %q, want abc123", c.Value)
|
||||
}
|
||||
if !c.HttpOnly {
|
||||
t.Error("cookie HttpOnly = false, want true")
|
||||
}
|
||||
if c.SameSite != http.SameSiteStrictMode {
|
||||
t.Errorf("cookie SameSite = %v, want Strict", c.SameSite)
|
||||
}
|
||||
if !c.Secure {
|
||||
t.Error("cookie Secure = false, want true (X-Forwarded-Proto: https)")
|
||||
}
|
||||
if c.Path != "/" {
|
||||
t.Errorf("cookie Path = %q, want /", c.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCookie(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ClearCookie(w, req, "test_cookie")
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != "test_cookie" {
|
||||
t.Errorf("cookie name = %q, want test_cookie", c.Name)
|
||||
}
|
||||
if c.Value != "" {
|
||||
t.Errorf("cookie value = %q, want empty", c.Value)
|
||||
}
|
||||
if c.MaxAge != -1 {
|
||||
t.Errorf("cookie MaxAge = %d, want -1", c.MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenFromCookie(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
|
||||
|
||||
token, ok := GetTokenFromCookie(req, "session")
|
||||
if !ok {
|
||||
t.Error("GetTokenFromCookie() ok = false, want true")
|
||||
}
|
||||
if token != "token123" {
|
||||
t.Errorf("GetTokenFromCookie() = %q, want token123", token)
|
||||
}
|
||||
|
||||
// Missing cookie
|
||||
_, ok = GetTokenFromCookie(req, "nonexistent")
|
||||
if ok {
|
||||
t.Error("GetTokenFromCookie() for missing cookie ok = true, want false")
|
||||
}
|
||||
|
||||
// Empty value
|
||||
req2 := httptest.NewRequest("GET", "/", nil)
|
||||
req2.AddCookie(&http.Cookie{Name: "session", Value: ""})
|
||||
_, ok = GetTokenFromCookie(req2, "session")
|
||||
if ok {
|
||||
t.Error("GetTokenFromCookie() for empty value ok = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdmin(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
token, _ := GenerateToken()
|
||||
store.Add(token, time.Now().Add(1*time.Hour))
|
||||
|
||||
handler := RequireAdmin(store, "session")(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// Valid session
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: token})
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// No cookie
|
||||
req2 := httptest.NewRequest("GET", "/", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler(w2, req2)
|
||||
if w2.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w2.Code)
|
||||
}
|
||||
|
||||
// Invalid token
|
||||
req3 := httptest.NewRequest("GET", "/", nil)
|
||||
req3.AddCookie(&http.Cookie{Name: "session", Value: "invalid"})
|
||||
w3 := httptest.NewRecorder()
|
||||
handler(w3, req3)
|
||||
if w3.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w3.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defaults and constants
|
||||
const (
|
||||
AdminSessionCookieName = "linearvoid_admin_session"
|
||||
MaxAdminUploadBytes int64 = 2 * 1024 * 1024 * 1024
|
||||
DefaultAdminChunkSize int64 = 8 * 1024 * 1024
|
||||
MaxAdminChunkSize int64 = 32 * 1024 * 1024
|
||||
DefaultRateLimitMax = 10
|
||||
DefaultRateLimitWindow = 10 * time.Second
|
||||
DefaultAdminSessionTTL = 12 * time.Hour
|
||||
DefaultChatPort = "3000"
|
||||
)
|
||||
|
||||
// PlaylistPath returns the playlist file path from env or default.
|
||||
func PlaylistPath() string {
|
||||
p := strings.TrimSpace(os.Getenv("PLAYLIST_PATH"))
|
||||
if p == "" {
|
||||
return "/app/playlist/playlist.txt"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// MediaDir returns the media directory from env or default.
|
||||
func MediaDir() string {
|
||||
d := strings.TrimSpace(os.Getenv("MEDIA_DIR"))
|
||||
if d == "" {
|
||||
return "/app/media"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// AdminUsername returns the admin username from env or default.
|
||||
func AdminUsername() string {
|
||||
u := strings.TrimSpace(os.Getenv("ADMIN_USERNAME"))
|
||||
if u == "" {
|
||||
return "admin"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// AdminPassword returns the admin password from env or default.
|
||||
func AdminPassword() string {
|
||||
p := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD"))
|
||||
if p == "" {
|
||||
return "admin"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// AdminSessionTTL returns the admin session TTL from env or default.
|
||||
func AdminSessionTTL() time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS"))
|
||||
if raw == "" {
|
||||
return DefaultAdminSessionTTL
|
||||
}
|
||||
hours, err := strconv.Atoi(raw)
|
||||
if err != nil || hours <= 0 {
|
||||
return DefaultAdminSessionTTL
|
||||
}
|
||||
return time.Duration(hours) * time.Hour
|
||||
}
|
||||
|
||||
// RateLimitMax returns the chat rate limit max messages per window.
|
||||
func RateLimitMax() int {
|
||||
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
|
||||
if raw == "" {
|
||||
return DefaultRateLimitMax
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return DefaultRateLimitMax
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// RateLimitWindow returns the chat rate limit window duration.
|
||||
func RateLimitWindow() time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_WINDOW_S"))
|
||||
if raw == "" {
|
||||
return DefaultRateLimitWindow
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return DefaultRateLimitWindow
|
||||
}
|
||||
return time.Duration(v) * time.Second
|
||||
}
|
||||
|
||||
// ChatPort returns the chat service port from env or default.
|
||||
func ChatPort() string {
|
||||
p := strings.TrimSpace(os.Getenv("CHAT_PORT"))
|
||||
if p == "" {
|
||||
return DefaultChatPort
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// RedisAddress returns the Redis address from env or default.
|
||||
func RedisAddress() string {
|
||||
a := strings.TrimSpace(os.Getenv("REDIS_URL"))
|
||||
if a == "" {
|
||||
return "redis:6379"
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// DatabaseURL returns the database URL from env (empty = SQLite default).
|
||||
func DatabaseURL() string {
|
||||
return strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlaylistPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "/app/playlist/playlist.txt"},
|
||||
{"custom", "/custom/playlist.txt", "/custom/playlist.txt"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("PLAYLIST_PATH", tt.env)
|
||||
defer os.Unsetenv("PLAYLIST_PATH")
|
||||
} else {
|
||||
os.Unsetenv("PLAYLIST_PATH")
|
||||
}
|
||||
if got := PlaylistPath(); got != tt.expected {
|
||||
t.Errorf("PlaylistPath() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "/app/media"},
|
||||
{"custom", "/data/media", "/data/media"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("MEDIA_DIR", tt.env)
|
||||
defer os.Unsetenv("MEDIA_DIR")
|
||||
} else {
|
||||
os.Unsetenv("MEDIA_DIR")
|
||||
}
|
||||
if got := MediaDir(); got != tt.expected {
|
||||
t.Errorf("MediaDir() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUsername(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "admin"},
|
||||
{"custom", "superadmin", "superadmin"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("ADMIN_USERNAME", tt.env)
|
||||
defer os.Unsetenv("ADMIN_USERNAME")
|
||||
} else {
|
||||
os.Unsetenv("ADMIN_USERNAME")
|
||||
}
|
||||
if got := AdminUsername(); got != tt.expected {
|
||||
t.Errorf("AdminUsername() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "admin"},
|
||||
{"custom", "secret123", "secret123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("ADMIN_PASSWORD", tt.env)
|
||||
defer os.Unsetenv("ADMIN_PASSWORD")
|
||||
} else {
|
||||
os.Unsetenv("ADMIN_PASSWORD")
|
||||
}
|
||||
if got := AdminPassword(); got != tt.expected {
|
||||
t.Errorf("AdminPassword() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSessionTTL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected time.Duration
|
||||
}{
|
||||
{"default", "", 12 * time.Hour},
|
||||
{"custom", "24", 24 * time.Hour},
|
||||
{"zero", "0", 12 * time.Hour},
|
||||
{"negative", "-5", 12 * time.Hour},
|
||||
{"invalid", "abc", 12 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("ADMIN_SESSION_TTL_HOURS", tt.env)
|
||||
defer os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
|
||||
} else {
|
||||
os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
|
||||
}
|
||||
if got := AdminSessionTTL(); got != tt.expected {
|
||||
t.Errorf("AdminSessionTTL() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMax(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected int
|
||||
}{
|
||||
{"default", "", 10},
|
||||
{"custom", "20", 20},
|
||||
{"zero", "0", 10},
|
||||
{"negative", "-5", 10},
|
||||
{"invalid", "abc", 10},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("CHAT_RATE_LIMIT_MAX", tt.env)
|
||||
defer os.Unsetenv("CHAT_RATE_LIMIT_MAX")
|
||||
} else {
|
||||
os.Unsetenv("CHAT_RATE_LIMIT_MAX")
|
||||
}
|
||||
if got := RateLimitMax(); got != tt.expected {
|
||||
t.Errorf("RateLimitMax() = %d, want %d", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitWindow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected time.Duration
|
||||
}{
|
||||
{"default", "", 10 * time.Second},
|
||||
{"custom", "30", 30 * time.Second},
|
||||
{"zero", "0", 10 * time.Second},
|
||||
{"negative", "-5", 10 * time.Second},
|
||||
{"invalid", "abc", 10 * time.Second},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("CHAT_RATE_LIMIT_WINDOW_S", tt.env)
|
||||
defer os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
|
||||
} else {
|
||||
os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
|
||||
}
|
||||
if got := RateLimitWindow(); got != tt.expected {
|
||||
t.Errorf("RateLimitWindow() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "3000"},
|
||||
{"custom", "8080", "8080"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("CHAT_PORT", tt.env)
|
||||
defer os.Unsetenv("CHAT_PORT")
|
||||
} else {
|
||||
os.Unsetenv("CHAT_PORT")
|
||||
}
|
||||
if got := ChatPort(); got != tt.expected {
|
||||
t.Errorf("ChatPort() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "redis:6379"},
|
||||
{"custom", "redis.example.com:6380", "redis.example.com:6380"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("REDIS_URL", tt.env)
|
||||
defer os.Unsetenv("REDIS_URL")
|
||||
} else {
|
||||
os.Unsetenv("REDIS_URL")
|
||||
}
|
||||
if got := RedisAddress(); got != tt.expected {
|
||||
t.Errorf("RedisAddress() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", ""},
|
||||
{"sqlite", "sqlite://test.db", "sqlite://test.db"},
|
||||
{"postgres", "postgres://user:pass@localhost/db", "postgres://user:pass@localhost/db"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("DATABASE_URL", tt.env)
|
||||
defer os.Unsetenv("DATABASE_URL")
|
||||
} else {
|
||||
os.Unsetenv("DATABASE_URL")
|
||||
}
|
||||
if got := DatabaseURL(); got != tt.expected {
|
||||
t.Errorf("DatabaseURL() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DBClient wraps gorm.DB for testability.
|
||||
type DBClient struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
// InitDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006).
|
||||
// Returns the client and the driver name.
|
||||
func InitDB(databaseURL string) (*DBClient, string, error) {
|
||||
var (
|
||||
gormDB *gorm.DB
|
||||
err error
|
||||
driver = "sqlite"
|
||||
)
|
||||
|
||||
switch {
|
||||
case databaseURL == "":
|
||||
gormDB, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
|
||||
case strings.HasPrefix(databaseURL, "sqlite://"):
|
||||
sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://")
|
||||
if sqlitePath == "" {
|
||||
sqlitePath = "chat.db"
|
||||
}
|
||||
gormDB, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
||||
case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"):
|
||||
driver = "postgres"
|
||||
gormDB, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
default:
|
||||
log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, driver, err
|
||||
}
|
||||
|
||||
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
||||
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
||||
|
||||
return &DBClient{DB: gormDB}, driver, nil
|
||||
}
|
||||
|
||||
// RedisClient wraps the Redis client for testability.
|
||||
// Interface defined for mocking in tests.
|
||||
type RedisClient interface {
|
||||
Ping(ctx context.Context) error
|
||||
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
Del(ctx context.Context, keys ...string) error
|
||||
Incr(ctx context.Context, key string) (int64, error)
|
||||
Decr(ctx context.Context, key string) (int64, error)
|
||||
Expire(ctx context.Context, key string, expiration time.Duration) error
|
||||
Exists(ctx context.Context, keys ...string) (int64, error)
|
||||
Publish(ctx context.Context, channel string, message interface{}) error
|
||||
PSubscribe(ctx context.Context, channels ...string) PubSub
|
||||
Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error)
|
||||
Info(ctx context.Context, sections ...string) (string, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// PubSub wraps Redis pub/sub subscription.
|
||||
type PubSub interface {
|
||||
ReceiveMessage(ctx context.Context) (*PubSubMessage, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// PubSubMessage represents a received pub/sub message.
|
||||
type PubSubMessage struct {
|
||||
Channel string
|
||||
Payload string
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInitDB_SQLite(t *testing.T) {
|
||||
client, driver, err := InitDB("sqlite://:memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB() error = %v", err)
|
||||
}
|
||||
if driver != "sqlite" {
|
||||
t.Errorf("InitDB() driver = %q, want sqlite", driver)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("InitDB() client = nil")
|
||||
}
|
||||
|
||||
// Verify tables exist by running AutoMigrate (idempotent)
|
||||
err = client.DB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
||||
if err != nil {
|
||||
t.Errorf("AutoMigrate error = %v", err)
|
||||
}
|
||||
|
||||
// Verify CRUD on Message
|
||||
msg := Message{
|
||||
Room: "test",
|
||||
Username: "user1",
|
||||
Text: "hello",
|
||||
Timestamp: time.Now(),
|
||||
Type: "public",
|
||||
}
|
||||
if err := client.DB.Create(&msg).Error; err != nil {
|
||||
t.Fatalf("Create message error = %v", err)
|
||||
}
|
||||
if msg.ID == 0 {
|
||||
t.Error("Create message did not set ID")
|
||||
}
|
||||
|
||||
var count int64
|
||||
client.DB.Model(&Message{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("Message count = %d, want 1", count)
|
||||
}
|
||||
|
||||
// Verify read
|
||||
var read Message
|
||||
if err := client.DB.First(&read, msg.ID).Error; err != nil {
|
||||
t.Fatalf("First message error = %v", err)
|
||||
}
|
||||
if read.Text != "hello" {
|
||||
t.Errorf("read.Text = %q, want hello", read.Text)
|
||||
}
|
||||
|
||||
// Verify delete
|
||||
client.DB.Delete(&Message{}, msg.ID)
|
||||
client.DB.Model(&Message{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("Message count after delete = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDB_ModerationLog(t *testing.T) {
|
||||
client, _, err := InitDB("sqlite://:memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB() error = %v", err)
|
||||
}
|
||||
|
||||
log := ModerationLog{
|
||||
Action: "mute",
|
||||
Target: "user1",
|
||||
Moderator: "admin",
|
||||
Reason: "spam",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err := client.DB.Create(&log).Error; err != nil {
|
||||
t.Fatalf("Create moderation log error = %v", err)
|
||||
}
|
||||
if log.ID == 0 {
|
||||
t.Error("Create moderation log did not set ID")
|
||||
}
|
||||
|
||||
var count int64
|
||||
client.DB.Model(&ModerationLog{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("ModerationLog count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDB_Media(t *testing.T) {
|
||||
client, _, err := InitDB("sqlite://:memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB() error = %v", err)
|
||||
}
|
||||
|
||||
media := Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: "/media/test.mp4",
|
||||
SizeBytes: 1024,
|
||||
DurationSeconds: 60,
|
||||
Status: "ready",
|
||||
Title: "Test Video",
|
||||
Tags: "tag1,tag2",
|
||||
}
|
||||
if err := client.DB.Create(&media).Error; err != nil {
|
||||
t.Fatalf("Create media error = %v", err)
|
||||
}
|
||||
if media.ID == 0 {
|
||||
t.Error("Create media did not set ID")
|
||||
}
|
||||
|
||||
// Verify update
|
||||
client.DB.Model(&media).Update("status", "needs_normalization")
|
||||
var updated Media
|
||||
client.DB.First(&updated, media.ID)
|
||||
if updated.Status != "needs_normalization" {
|
||||
t.Errorf("updated.Status = %q, want needs_normalization", updated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDB_ScheduleOverride(t *testing.T) {
|
||||
client, _, err := InitDB("sqlite://:memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB() error = %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
ov := ScheduleOverride{
|
||||
MediaID: 1,
|
||||
StartTime: now,
|
||||
EndTime: now.Add(1 * time.Hour),
|
||||
Reason: "special event",
|
||||
}
|
||||
if err := client.DB.Create(&ov).Error; err != nil {
|
||||
t.Fatalf("Create schedule override error = %v", err)
|
||||
}
|
||||
if ov.ID == 0 {
|
||||
t.Error("Create schedule override did not set ID")
|
||||
}
|
||||
|
||||
// Conflict detection
|
||||
var conflict int64
|
||||
client.DB.Model(&ScheduleOverride{}).
|
||||
Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime).
|
||||
Count(&conflict)
|
||||
if conflict != 1 {
|
||||
t.Errorf("conflict count = %d, want 1", conflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDB_EmptyURL(t *testing.T) {
|
||||
// Empty URL should default to SQLite chat.db
|
||||
// This creates a file, clean up after
|
||||
client, driver, err := InitDB("")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB(\"\") error = %v", err)
|
||||
}
|
||||
if driver != "sqlite" {
|
||||
t.Errorf("InitDB(\"\") driver = %q, want sqlite", driver)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("InitDB(\"\") client = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDB_SQLitePath(t *testing.T) {
|
||||
client, driver, err := InitDB("sqlite://:memory:?cache=shared")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB() error = %v", err)
|
||||
}
|
||||
if driver != "sqlite" {
|
||||
t.Errorf("InitDB() driver = %q, want sqlite", driver)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("InitDB() client = nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message represents a chat message (persisted in SQL DB per ADR-006)
|
||||
type Message struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Room string `json:"room"`
|
||||
Username string `json:"username"`
|
||||
Text string `json:"text"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Type string `json:"type"` // "public", "private", "system", "moderation"
|
||||
}
|
||||
|
||||
// ModerationLog for audit trail (immutable per ADR-005)
|
||||
type ModerationLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Action string `json:"action"` // "mute", "ban", "delete", "timeout"
|
||||
Target string `json:"target"`
|
||||
Moderator string `json:"moderator"`
|
||||
Reason string `json:"reason"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Media stores admin media library metadata (filesystem + DB model per ADR-006)
|
||||
type Media struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Filename string `gorm:"index" json:"filename"`
|
||||
OriginalName string `json:"originalName"`
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Status string `gorm:"index" json:"status"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
||||
type ScheduleOverride struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
MediaID uint `json:"mediaId"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Presence tracks online users per room (using Redis per ADR-007)
|
||||
type Presence struct {
|
||||
Username string `json:"username"`
|
||||
Room string `json:"room"`
|
||||
Online bool `json:"online"`
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisWrapper wraps go-redis client to implement RedisClient interface.
|
||||
type RedisWrapper struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewRedisWrapper creates a RedisWrapper from a go-redis client.
|
||||
func NewRedisWrapper(client *redis.Client) *RedisWrapper {
|
||||
return &RedisWrapper{client: client}
|
||||
}
|
||||
|
||||
// InitRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007).
|
||||
// Returns nil if Redis is unavailable (non-fatal).
|
||||
func InitRedis(redisAddress string) *RedisWrapper {
|
||||
var options *redis.Options
|
||||
var err error
|
||||
|
||||
if strings.Contains(redisAddress, "://") {
|
||||
options, err = redis.ParseURL(redisAddress)
|
||||
if err != nil {
|
||||
log.Printf("Redis configuration warning: %v (rate limiting and presence will be disabled)", err)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
options = &redis.Options{Addr: redisAddress}
|
||||
}
|
||||
|
||||
client := redis.NewClient(options)
|
||||
if err := client.Ping(context.Background()).Err(); err != nil {
|
||||
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
|
||||
client.Close()
|
||||
// Start auto-reconnect goroutine
|
||||
go redisReconnectLoop(redisAddress, options)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
|
||||
return &RedisWrapper{client: client}
|
||||
}
|
||||
|
||||
// redisReconnectLoop attempts to reconnect to Redis with exponential backoff.
|
||||
func redisReconnectLoop(addr string, baseOptions *redis.Options) {
|
||||
backoff := 2 * time.Second
|
||||
maxBackoff := 60 * time.Second
|
||||
for {
|
||||
time.Sleep(backoff)
|
||||
testClient := redis.NewClient(baseOptions)
|
||||
if err := testClient.Ping(context.Background()).Err(); err != nil {
|
||||
log.Printf("Redis reconnect failed: %v (retry in %v)", err, backoff)
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
testClient.Close()
|
||||
continue
|
||||
}
|
||||
// Reconnected — caller must replace their reference.
|
||||
// This is a best-effort background reconnect; the wrapper
|
||||
// returned by InitRedis remains nil until a new InitRedis call.
|
||||
log.Println("Redis reconnected successfully (background)")
|
||||
testClient.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ping checks Redis connectivity.
|
||||
func (r *RedisWrapper) Ping(ctx context.Context) error {
|
||||
if r == nil || r.client == nil {
|
||||
return redis.ErrClosed
|
||||
}
|
||||
return r.client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// Set stores a key with TTL.
|
||||
func (r *RedisWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
return r.client.Set(ctx, key, value, expiration).Err()
|
||||
}
|
||||
|
||||
// Get retrieves a key.
|
||||
func (r *RedisWrapper) Get(ctx context.Context, key string) (string, error) {
|
||||
return r.client.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Del removes keys.
|
||||
func (r *RedisWrapper) Del(ctx context.Context, keys ...string) error {
|
||||
return r.client.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
// Incr increments a key.
|
||||
func (r *RedisWrapper) Incr(ctx context.Context, key string) (int64, error) {
|
||||
return r.client.Incr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Decr decrements a key.
|
||||
func (r *RedisWrapper) Decr(ctx context.Context, key string) (int64, error) {
|
||||
return r.client.Decr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Expire sets TTL on a key.
|
||||
func (r *RedisWrapper) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return r.client.Expire(ctx, key, expiration).Err()
|
||||
}
|
||||
|
||||
// Exists checks if keys exist.
|
||||
func (r *RedisWrapper) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||
return r.client.Exists(ctx, keys...).Result()
|
||||
}
|
||||
|
||||
// Publish publishes a message to a channel.
|
||||
func (r *RedisWrapper) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||
return r.client.Publish(ctx, channel, message).Err()
|
||||
}
|
||||
|
||||
// PSubscribe subscribes to channels by pattern.
|
||||
func (r *RedisWrapper) PSubscribe(ctx context.Context, channels ...string) PubSub {
|
||||
ps := r.client.PSubscribe(ctx, channels...)
|
||||
return &redisPubSubWrapper{ps: ps}
|
||||
}
|
||||
|
||||
// Scan scans keys matching a pattern.
|
||||
func (r *RedisWrapper) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||
return r.client.Scan(ctx, cursor, match, count).Result()
|
||||
}
|
||||
|
||||
// Info returns Redis server info.
|
||||
func (r *RedisWrapper) Info(ctx context.Context, sections ...string) (string, error) {
|
||||
return r.client.Info(ctx, sections...).Result()
|
||||
}
|
||||
|
||||
// Close closes the Redis connection.
|
||||
func (r *RedisWrapper) Close() error {
|
||||
if r == nil || r.client == nil {
|
||||
return nil
|
||||
}
|
||||
return r.client.Close()
|
||||
}
|
||||
|
||||
// redisPubSubWrapper wraps go-redis PubSub.
|
||||
type redisPubSubWrapper struct {
|
||||
ps *redis.PubSub
|
||||
}
|
||||
|
||||
func (w *redisPubSubWrapper) ReceiveMessage(ctx context.Context) (*PubSubMessage, error) {
|
||||
msg, err := w.ps.ReceiveMessage(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PubSubMessage{Channel: msg.Channel, Payload: msg.Payload}, nil
|
||||
}
|
||||
|
||||
func (w *redisPubSubWrapper) Close() error {
|
||||
return w.ps.Close()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,947 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/auth"
|
||||
"chat/src/config"
|
||||
"chat/src/db"
|
||||
"chat/src/ws"
|
||||
)
|
||||
|
||||
// mockRedis implements db.RedisClient for handler tests.
|
||||
type mockRedis struct {
|
||||
mu sync.Mutex
|
||||
store map[string]string
|
||||
}
|
||||
|
||||
func newMockRedis() *mockRedis {
|
||||
return &mockRedis{store: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (m *mockRedis) Ping(ctx context.Context) error { return nil }
|
||||
func (m *mockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.store[key] = value.(string)
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Get(ctx context.Context, key string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.store[key], nil
|
||||
}
|
||||
func (m *mockRedis) Del(ctx context.Context, keys ...string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, k := range keys {
|
||||
delete(m.store, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.store[key] = "1"
|
||||
return 1, nil
|
||||
}
|
||||
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, k := range keys {
|
||||
if _, ok := m.store[k]; ok {
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range m.store {
|
||||
if strings.Contains(k, strings.TrimSuffix(match, "*")) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
return keys, 0, nil
|
||||
}
|
||||
func (m *mockRedis) Info(ctx context.Context, sections ...string) (string, error) {
|
||||
return "connected_clients:1\nused_memory_human:1M\n", nil
|
||||
}
|
||||
func (m *mockRedis) Close() error { return nil }
|
||||
|
||||
func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
|
||||
t.Helper()
|
||||
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test DB: %v", err)
|
||||
}
|
||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{})
|
||||
mr := newMockRedis()
|
||||
sessionStore := auth.NewSessionStore()
|
||||
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
|
||||
|
||||
deps := &Dependencies{
|
||||
DB: gormDB,
|
||||
Redis: mr,
|
||||
Hub: hub,
|
||||
SessionStore: sessionStore,
|
||||
DBDriver: "sqlite",
|
||||
}
|
||||
return deps, gormDB, sessionStore
|
||||
}
|
||||
|
||||
// adminCookie creates a valid admin session and returns the cookie header.
|
||||
func adminCookie(t *testing.T, store *auth.SessionStore) string {
|
||||
t.Helper()
|
||||
token, _ := auth.GenerateToken()
|
||||
store.Add(token, time.Now().Add(1*time.Hour))
|
||||
return config.AdminSessionCookieName + "=" + token
|
||||
}
|
||||
|
||||
func TestHandleAdminLogin_Success(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
handler := HandleAdminLogin(deps)
|
||||
|
||||
// Ensure env vars are set for credential check
|
||||
os.Setenv("ADMIN_USERNAME", "admin")
|
||||
os.Setenv("ADMIN_PASSWORD", "admin")
|
||||
defer os.Unsetenv("ADMIN_USERNAME")
|
||||
defer os.Unsetenv("ADMIN_PASSWORD")
|
||||
|
||||
body := `{"username":"admin","password":"admin"}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp AdminSessionResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if !resp.Authenticated {
|
||||
t.Error("Authenticated = false, want true")
|
||||
}
|
||||
|
||||
// Check cookie set
|
||||
cookies := w.Result().Cookies()
|
||||
found := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == config.AdminSessionCookieName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("session cookie not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminLogin_InvalidCreds(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
handler := HandleAdminLogin(deps)
|
||||
|
||||
body := `{"username":"admin","password":"wrong"}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminLogin_InvalidJSON(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
handler := HandleAdminLogin(deps)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader("not json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminLogin_WrongMethod(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
handler := HandleAdminLogin(deps)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/admin/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminLogout(t *testing.T) {
|
||||
deps, _, store := setupTestDeps(t)
|
||||
handler := HandleAdminLogout(deps)
|
||||
|
||||
cookie := adminCookie(t, store)
|
||||
req := httptest.NewRequest("POST", "/api/admin/logout", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// Cookie should be cleared
|
||||
cookies := w.Result().Cookies()
|
||||
found := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == config.AdminSessionCookieName && c.MaxAge == -1 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("session cookie not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSession_Authenticated(t *testing.T) {
|
||||
deps, _, store := setupTestDeps(t)
|
||||
handler := HandleAdminSession(deps)
|
||||
|
||||
cookie := adminCookie(t, store)
|
||||
req := httptest.NewRequest("GET", "/api/admin/session", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp AdminSessionResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if !resp.Authenticated {
|
||||
t.Error("Authenticated = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSession_Unauthenticated(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
handler := HandleAdminSession(deps)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/admin/session", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminOverview(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
// Add some test data
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
|
||||
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
|
||||
|
||||
handler := HandleAdminOverview(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/overview", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var overview AdminOverview
|
||||
json.NewDecoder(w.Body).Decode(&overview)
|
||||
if overview.StoredMessages != 1 {
|
||||
t.Errorf("StoredMessages = %d, want 1", overview.StoredMessages)
|
||||
}
|
||||
if overview.ModerationActions != 1 {
|
||||
t.Errorf("ModerationActions = %d, want 1", overview.ModerationActions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminPlaylist_Get(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
// Create temp playlist
|
||||
tmpDir := t.TempDir()
|
||||
playlistPath := filepath.Join(tmpDir, "playlist.txt")
|
||||
os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
|
||||
os.Setenv("PLAYLIST_PATH", playlistPath)
|
||||
defer os.Unsetenv("PLAYLIST_PATH")
|
||||
|
||||
handler := HandleAdminPlaylist(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/playlist", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var payload PlaylistPayload
|
||||
json.NewDecoder(w.Body).Decode(&payload)
|
||||
if !strings.Contains(payload.Content, "test.mp4") {
|
||||
t.Errorf("Content = %q, want test.mp4", payload.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminPlaylist_Put(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
playlistPath := filepath.Join(tmpDir, "playlist.txt")
|
||||
os.Setenv("PLAYLIST_PATH", playlistPath)
|
||||
defer os.Unsetenv("PLAYLIST_PATH")
|
||||
|
||||
handler := HandleAdminPlaylist(deps)
|
||||
body := `{"content":"file /videos/new.mp4"}`
|
||||
req := httptest.NewRequest("PUT", "/api/admin/playlist", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// Verify file written
|
||||
data, _ := os.ReadFile(playlistPath)
|
||||
if !strings.Contains(string(data), "new.mp4") {
|
||||
t.Errorf("file content = %q, want new.mp4", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminModerationLogs(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u1", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
|
||||
|
||||
handler := HandleAdminModerationLogs(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/moderation/logs", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp ModerationLogsResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp.Logs) != 1 {
|
||||
t.Errorf("len(Logs) = %d, want 1", len(resp.Logs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminModerationAction(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminModerationAction(deps)
|
||||
body := `{"action":"mute","target":"baduser","moderator":"admin","reason":"spam","room":"main"}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
gormDB.Model(&db.ModerationLog{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("moderation log count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminModerationAction_MissingFields(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminModerationAction(deps)
|
||||
body := `{"action":"mute"}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminAnalytics(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"})
|
||||
|
||||
handler := HandleAdminAnalytics(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/analytics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var analytics AdminAnalytics
|
||||
json.NewDecoder(w.Body).Decode(&analytics)
|
||||
if analytics.TotalMessages != 2 {
|
||||
t.Errorf("TotalMessages = %d, want 2", analytics.TotalMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSystemSummary(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminSystemSummary(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/system", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var summary AdminSystemSummary
|
||||
json.NewDecoder(w.Body).Decode(&summary)
|
||||
if summary.DatabaseDriver != "sqlite" {
|
||||
t.Errorf("DatabaseDriver = %q, want sqlite", summary.DatabaseDriver)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminConfig_Get(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminConfig(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminConfig_Put(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminConfig(deps)
|
||||
body := `{"rateLimitMax":20}`
|
||||
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminRestart(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminRestart(deps)
|
||||
req := httptest.NewRequest("POST", "/api/admin/restart?service=chat", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminUsers_Get(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminUsers(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/users", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminUsers_Post(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminUsers(deps)
|
||||
body := `{"action":"mute","username":"baduser","duration":10}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/users", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSchedule(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
playlistPath := filepath.Join(tmpDir, "playlist.txt")
|
||||
os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
|
||||
os.Setenv("PLAYLIST_PATH", playlistPath)
|
||||
defer os.Unsetenv("PLAYLIST_PATH")
|
||||
|
||||
gormDB.Create(&db.Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: "/videos/test.mp4",
|
||||
SizeBytes: 1024,
|
||||
DurationSeconds: 60,
|
||||
Status: "ready",
|
||||
Title: "Test Video",
|
||||
})
|
||||
|
||||
handler := HandleAdminSchedule(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/schedule", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp AdminScheduleResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.MatchedEntries != 1 {
|
||||
t.Errorf("MatchedEntries = %d, want 1", resp.MatchedEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminScheduleOverrides_CRUD(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
// Create
|
||||
handler := HandleAdminScheduleOverrides(deps)
|
||||
now := time.Now()
|
||||
body, _ := json.Marshal(db.ScheduleOverride{
|
||||
MediaID: 1,
|
||||
StartTime: now,
|
||||
EndTime: now.Add(1 * time.Hour),
|
||||
Reason: "test",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/admin/schedule/overrides", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("POST status = %d, want 201", w.Code)
|
||||
}
|
||||
|
||||
// List
|
||||
req2 := httptest.NewRequest("GET", "/api/admin/schedule/overrides", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Errorf("GET status = %d, want 200", w2.Code)
|
||||
}
|
||||
|
||||
// Delete
|
||||
var overrides []db.ScheduleOverride
|
||||
gormDB.Find(&overrides)
|
||||
if len(overrides) > 0 {
|
||||
req3 := httptest.NewRequest("DELETE", "/api/admin/schedule/overrides?id=1", nil)
|
||||
w3 := httptest.NewRecorder()
|
||||
handler(w3, req3)
|
||||
if w3.Code != http.StatusNoContent {
|
||||
t.Errorf("DELETE status = %d, want 204", w3.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLog(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
|
||||
|
||||
handler := HandleAdminChatLog(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/chat-log", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp ChatLogListResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Total != 1 {
|
||||
t.Errorf("Total = %d, want 1", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLog_FilterByRoom(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
|
||||
gormDB.Create(&db.Message{Room: "lobby", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"})
|
||||
|
||||
handler := HandleAdminChatLog(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/chat-log?room=main", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp ChatLogListResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Total != 1 {
|
||||
t.Errorf("Total = %d, want 1", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLogByID_Get(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}
|
||||
gormDB.Create(&msg)
|
||||
|
||||
handler := HandleAdminChatLogByID(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/chat-log/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLogByID_Delete(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}
|
||||
gormDB.Create(&msg)
|
||||
|
||||
handler := HandleAdminChatLogByID(deps)
|
||||
req := httptest.NewRequest("DELETE", "/api/admin/chat-log/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want 204", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
gormDB.Model(&db.Message{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("message count after delete = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLogBulk(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "m1", Timestamp: time.Now(), Type: "public"})
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "m2", Timestamp: time.Now(), Type: "public"})
|
||||
|
||||
handler := HandleAdminChatLogBulk(deps)
|
||||
body := `{"ids":[1,2]}`
|
||||
req := httptest.NewRequest("DELETE", "/api/admin/chat-log/bulk", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
gormDB.Model(&db.Message{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("message count after bulk delete = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLogExport(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
|
||||
|
||||
handler := HandleAdminChatLogExport(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/chat-log/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/csv") {
|
||||
t.Errorf("Content-Type = %q, want text/csv", contentType)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "hello") {
|
||||
t.Errorf("CSV body missing 'hello': %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePublicSchedule(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
playlistPath := filepath.Join(tmpDir, "playlist.txt")
|
||||
os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
|
||||
os.Setenv("PLAYLIST_PATH", playlistPath)
|
||||
defer os.Unsetenv("PLAYLIST_PATH")
|
||||
|
||||
handler := HandlePublicSchedule(deps)
|
||||
req := httptest.NewRequest("GET", "/api/schedule", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMedia_Get(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: "/media/test.mp4",
|
||||
SizeBytes: 1024,
|
||||
DurationSeconds: 60,
|
||||
Status: "ready",
|
||||
Title: "Test",
|
||||
})
|
||||
|
||||
handler := HandleAdminMedia(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/media", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp AdminMediaResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp.Files) != 1 {
|
||||
t.Errorf("len(Files) = %d, want 1", len(resp.Files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaByID_Get(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: "/media/test.mp4",
|
||||
SizeBytes: 1024,
|
||||
DurationSeconds: 60,
|
||||
Status: "ready",
|
||||
Title: "Test",
|
||||
})
|
||||
|
||||
handler := HandleAdminMediaByID(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/media/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaByID_NotFound(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminMediaByID(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/media/999", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaByID_Put(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: "/media/test.mp4",
|
||||
SizeBytes: 1024,
|
||||
DurationSeconds: 60,
|
||||
Status: "ready",
|
||||
Title: "Old Title",
|
||||
})
|
||||
|
||||
handler := HandleAdminMediaByID(deps)
|
||||
body := `{"title":"New Title","status":"needs_normalization","tags":"tag1"}`
|
||||
req := httptest.NewRequest("PUT", "/api/admin/media/1", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var updated db.Media
|
||||
gormDB.First(&updated, 1)
|
||||
if updated.Title != "New Title" {
|
||||
t.Errorf("Title = %q, want New Title", updated.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaByID_Delete(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
// Create a real temp file for the media record
|
||||
tmpDir := t.TempDir()
|
||||
os.Setenv("MEDIA_DIR", tmpDir)
|
||||
defer os.Unsetenv("MEDIA_DIR")
|
||||
|
||||
mediaPath := filepath.Join(tmpDir, "test.mp4")
|
||||
os.WriteFile(mediaPath, []byte("fake video"), 0644)
|
||||
|
||||
gormDB.Create(&db.Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: mediaPath,
|
||||
SizeBytes: 10,
|
||||
DurationSeconds: 1,
|
||||
Status: "ready",
|
||||
Title: "Test",
|
||||
})
|
||||
|
||||
handler := HandleAdminMediaByID(deps)
|
||||
req := httptest.NewRequest("DELETE", "/api/admin/media/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
gormDB.Model(&db.Media{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("media count after delete = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaUploads_Init(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminMediaUploads(deps)
|
||||
body := `{"fileName":"video.mp4","fileSize":1024,"mimeType":"video/mp4","chunkSize":524288,"totalChunks":1}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var status AdminMediaUploadStatusResponse
|
||||
json.NewDecoder(w.Body).Decode(&status)
|
||||
if status.FileName != "video.mp4" {
|
||||
t.Errorf("FileName = %q, want video.mp4", status.FileName)
|
||||
}
|
||||
if status.UploadID == "" {
|
||||
t.Error("UploadID is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaUploads_InvalidFile(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminMediaUploads(deps)
|
||||
body := `{"fileName":"bad.exe","fileSize":1024,"mimeType":"application/x-msdownload","chunkSize":524288,"totalChunks":1}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
+70
-2727
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,385 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FormatDuration converts seconds to HH:MM:SS string.
|
||||
func FormatDuration(seconds int) string {
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
hours := seconds / 3600
|
||||
minutes := (seconds % 3600) / 60
|
||||
remainingSeconds := seconds % 60
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds)
|
||||
}
|
||||
|
||||
// NormalizationResult holds ffprobe validation output for admin uploads.
|
||||
type NormalizationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// ValidateNormalization runs ffprobe against a media file and checks
|
||||
// conformance with the Linear Void normalization profile (ADR-001).
|
||||
func ValidateNormalization(mediaPath string) NormalizationResult {
|
||||
result := NormalizationResult{Valid: true, Errors: []string{}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx,
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", mediaPath,
|
||||
).CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return NormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}}
|
||||
}
|
||||
if err != nil {
|
||||
return NormalizationResult{Valid: false, Errors: []string{fmt.Sprintf("ffprobe failed: %v (%s)", err, strings.TrimSpace(string(out)))}}
|
||||
}
|
||||
|
||||
var probe struct {
|
||||
Streams []struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &probe); err != nil {
|
||||
return NormalizationResult{Valid: false, Errors: []string{"ffprobe JSON parse failed: " + err.Error()}}
|
||||
}
|
||||
|
||||
var hasVideo, hasAudio bool
|
||||
for _, s := range probe.Streams {
|
||||
switch s.CodecType {
|
||||
case "video":
|
||||
hasVideo = true
|
||||
if s.CodecName != "h264" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
|
||||
}
|
||||
if s.RFrameRate != "30/1" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("framerate must be 30 fps constant, got %s", s.RFrameRate))
|
||||
}
|
||||
if s.Width != 1280 && s.Width != 1920 {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
|
||||
}
|
||||
if s.Height != 720 && s.Height != 1080 {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
|
||||
}
|
||||
if s.PixFmt != "yuv420p" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
|
||||
}
|
||||
case "audio":
|
||||
hasAudio = true
|
||||
if s.CodecName != "aac" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
|
||||
}
|
||||
if s.SampleRate != "48000" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("sample rate must be 48000 Hz, got %s", s.SampleRate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasVideo {
|
||||
result.Errors = append(result.Errors, "no video stream found")
|
||||
}
|
||||
if !hasAudio {
|
||||
result.Errors = append(result.Errors, "no audio stream found")
|
||||
}
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
result.Valid = false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ProbeDurationSeconds returns the duration of a media file in seconds.
|
||||
func ProbeDurationSeconds(mediaPath string) (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(
|
||||
ctx,
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
mediaPath,
|
||||
).CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return 0, fmt.Errorf("ffprobe timed out")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
rawDuration := strings.TrimSpace(string(out))
|
||||
if rawDuration == "" {
|
||||
return 0, fmt.Errorf("ffprobe returned empty duration")
|
||||
}
|
||||
|
||||
secondsFloat, err := strconv.ParseFloat(rawDuration, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err)
|
||||
}
|
||||
if secondsFloat < 0 {
|
||||
secondsFloat = 0
|
||||
}
|
||||
|
||||
seconds := int(secondsFloat + 0.5)
|
||||
if secondsFloat > 0 && seconds == 0 {
|
||||
seconds = 1
|
||||
}
|
||||
|
||||
return seconds, nil
|
||||
}
|
||||
|
||||
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
|
||||
func DetectMime(filePath string) (string, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
head := make([]byte, 512)
|
||||
n, err := f.Read(head)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0], nil
|
||||
}
|
||||
|
||||
// IsAllowedExtension checks if the file extension is a supported video format.
|
||||
func IsAllowedExtension(fileName string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
switch ext {
|
||||
case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsAllowedMime checks if the MIME type is a supported video type.
|
||||
func IsAllowedMime(mime string) bool {
|
||||
mime = strings.TrimSpace(strings.ToLower(mime))
|
||||
if mime == "" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(mime, "video/") {
|
||||
return true
|
||||
}
|
||||
return mime == "application/octet-stream"
|
||||
}
|
||||
|
||||
// UniqueTargetPath returns a unique file path in mediaDir, appending -N if needed.
|
||||
func UniqueTargetPath(mediaDir, fileName string) string {
|
||||
baseName := filepath.Base(fileName)
|
||||
ext := filepath.Ext(baseName)
|
||||
nameOnly := strings.TrimSuffix(baseName, ext)
|
||||
candidate := filepath.Join(mediaDir, baseName)
|
||||
|
||||
for i := 1; ; i++ {
|
||||
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
||||
return candidate
|
||||
}
|
||||
candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext))
|
||||
}
|
||||
}
|
||||
|
||||
// FileEntry represents a media file for API responses.
|
||||
type FileEntry struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"originalName"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
UploadedAt string `json:"uploadedAt"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
// MediaRecord is the interface for media DB records.
|
||||
type MediaRecord interface {
|
||||
GetID() uint
|
||||
GetFilename() string
|
||||
GetOriginalName() string
|
||||
GetPath() string
|
||||
GetSizeBytes() int64
|
||||
GetDurationSeconds() int
|
||||
GetStatus() string
|
||||
GetTitle() string
|
||||
GetTags() string
|
||||
GetUploadedAt() time.Time
|
||||
GetUpdatedAt() time.Time
|
||||
}
|
||||
|
||||
// ListFiles returns all media files sorted by upload date descending.
|
||||
func ListFiles(db *gorm.DB) ([]FileEntry, error) {
|
||||
type record struct {
|
||||
ID uint
|
||||
Filename string
|
||||
OriginalName string
|
||||
Path string
|
||||
SizeBytes int64
|
||||
DurationSeconds int
|
||||
Status string
|
||||
Title string
|
||||
Tags string
|
||||
UploadedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
var records []record
|
||||
if err := db.Table("media").Order("uploaded_at desc").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files := make([]FileEntry, 0, len(records))
|
||||
for _, r := range records {
|
||||
files = append(files, FileEntry{
|
||||
ID: r.ID,
|
||||
Name: r.Filename,
|
||||
OriginalName: r.OriginalName,
|
||||
Path: r.Path,
|
||||
Size: r.SizeBytes,
|
||||
DurationSeconds: r.DurationSeconds,
|
||||
Runtime: FormatDuration(r.DurationSeconds),
|
||||
Status: r.Status,
|
||||
Title: r.Title,
|
||||
Tags: r.Tags,
|
||||
UploadedAt: r.UploadedAt.UTC().Format(time.RFC3339),
|
||||
ModifiedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
if files[i].UploadedAt == files[j].UploadedAt {
|
||||
return files[i].Name < files[j].Name
|
||||
}
|
||||
return files[i].UploadedAt > files[j].UploadedAt
|
||||
})
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// ParsePlaylistEntries parses playlist content into schedule entries.
|
||||
type ScheduleEntry struct {
|
||||
Index int `json:"index"`
|
||||
Path string `json:"path"`
|
||||
Label string `json:"label"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// ParsePlaylist parses playlist text content into entries.
|
||||
func ParsePlaylist(content string) []ScheduleEntry {
|
||||
entries := []ScheduleEntry{}
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, rawLine := range lines {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "file ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
|
||||
line = strings.Trim(line, "'\"")
|
||||
}
|
||||
|
||||
entries = append(entries, ScheduleEntry{
|
||||
Index: len(entries) + 1,
|
||||
Path: line,
|
||||
Label: filepath.Base(line),
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// UploadSessionDir returns the directory for an upload session's chunks.
|
||||
func UploadSessionDir(mediaDir, uploadID string) string {
|
||||
return filepath.Join(mediaDir, ".uploads", uploadID)
|
||||
}
|
||||
|
||||
// UploadSessionMetaPath returns the path to the session metadata file.
|
||||
func UploadSessionMetaPath(mediaDir, uploadID string) string {
|
||||
return filepath.Join(UploadSessionDir(mediaDir, uploadID), "session.json")
|
||||
}
|
||||
|
||||
// UploadChunkPath returns the path for a specific chunk file.
|
||||
func UploadChunkPath(mediaDir, uploadID string, chunkIndex int) string {
|
||||
return filepath.Join(UploadSessionDir(mediaDir, uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex))
|
||||
}
|
||||
|
||||
// CountUploadedChunks counts how many chunks exist for a session.
|
||||
func CountUploadedChunks(mediaDir, uploadID string, totalChunks int) int {
|
||||
uploaded := 0
|
||||
for i := 0; i < totalChunks; i++ {
|
||||
if _, err := os.Stat(UploadChunkPath(mediaDir, uploadID, i)); err == nil {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
}
|
||||
|
||||
// ValidateUploadID checks that an upload ID is a valid hex string of appropriate length.
|
||||
func ValidateUploadID(uploadID string) bool {
|
||||
if len(uploadID) < 16 || len(uploadID) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range uploadID {
|
||||
if (r < 'a' || r > 'f') && (r < '0' || r > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetMediaIDFromPath extracts a media ID from a URL path like /api/admin/media/123.
|
||||
func GetMediaIDFromPath(path, prefix string) (uint, error) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return 0, fmt.Errorf("invalid media path")
|
||||
}
|
||||
idPart := strings.TrimPrefix(path, prefix)
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart == "" || strings.Contains(idPart, "/") {
|
||||
return 0, fmt.Errorf("invalid media id")
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(idPart, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, fmt.Errorf("invalid media id")
|
||||
}
|
||||
|
||||
return uint(id), nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seconds int
|
||||
expected string
|
||||
}{
|
||||
{"zero", 0, "00:00:00"},
|
||||
{"one second", 1, "00:00:01"},
|
||||
{"one minute", 60, "00:01:00"},
|
||||
{"one hour", 3600, "01:00:00"},
|
||||
{"mixed", 3661, "01:01:01"},
|
||||
{"large", 86399, "23:59:59"},
|
||||
{"negative", -5, "00:00:00"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := FormatDuration(tt.seconds); got != tt.expected {
|
||||
t.Errorf("FormatDuration(%d) = %q, want %q", tt.seconds, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedExtension(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected bool
|
||||
}{
|
||||
{"video.mp4", true},
|
||||
{"video.mov", true},
|
||||
{"video.mkv", true},
|
||||
{"video.webm", true},
|
||||
{"video.m4v", true},
|
||||
{"video.ts", true},
|
||||
{"video.MP4", true},
|
||||
{"video.avi", false},
|
||||
{"video.flv", false},
|
||||
{"video.wmv", false},
|
||||
{"video", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsAllowedExtension(tt.name); got != tt.expected {
|
||||
t.Errorf("IsAllowedExtension(%q) = %v, want %v", tt.name, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedMime(t *testing.T) {
|
||||
tests := []struct {
|
||||
mime string
|
||||
expected bool
|
||||
}{
|
||||
{"video/mp4", true},
|
||||
{"video/quicktime", true},
|
||||
{"video/x-matroska", true},
|
||||
{"video/webm", true},
|
||||
{"application/octet-stream", true},
|
||||
{"", true},
|
||||
{"image/jpeg", false},
|
||||
{"audio/mpeg", false},
|
||||
{"text/plain", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mime, func(t *testing.T) {
|
||||
if got := IsAllowedMime(tt.mime); got != tt.expected {
|
||||
t.Errorf("IsAllowedMime(%q) = %v, want %v", tt.mime, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUploadID(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
expected bool
|
||||
}{
|
||||
{"abcdef0123456789", true}, // 16 hex chars
|
||||
{"abcdef0123456789abcdef0123456789", true}, // 32 hex chars
|
||||
{"abc", false}, // too short
|
||||
{"abcdef012345678", false}, // 15 chars (too short)
|
||||
{"abcdef0123456789g", false}, // non-hex char
|
||||
{"ABCDEF0123456789", false}, // uppercase (not allowed)
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.id, func(t *testing.T) {
|
||||
if got := ValidateUploadID(tt.id); got != tt.expected {
|
||||
t.Errorf("ValidateUploadID(%q) = %v, want %v", tt.id, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlaylist(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expected int
|
||||
first string
|
||||
}{
|
||||
{
|
||||
name: "simple",
|
||||
content: "/videos/video1.mp4\n/videos/video2.mp4\n",
|
||||
expected: 2,
|
||||
first: "/videos/video1.mp4",
|
||||
},
|
||||
{
|
||||
name: "with file prefix",
|
||||
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n",
|
||||
expected: 2,
|
||||
first: "/videos/video1.mp4",
|
||||
},
|
||||
{
|
||||
name: "with comments and blanks",
|
||||
content: "# comment\n\n/videos/video1.mp4\n\n# another comment\n/videos/video2.mp4\n",
|
||||
expected: 2,
|
||||
first: "/videos/video1.mp4",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
content: "",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "only comments",
|
||||
content: "# comment\n# another\n",
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
entries := ParsePlaylist(tt.content)
|
||||
if len(entries) != tt.expected {
|
||||
t.Errorf("ParsePlaylist() len = %d, want %d", len(entries), tt.expected)
|
||||
}
|
||||
if tt.expected > 0 && entries[0].Path != tt.first {
|
||||
t.Errorf("ParsePlaylist()[0].Path = %q, want %q", entries[0].Path, tt.first)
|
||||
}
|
||||
// Check indices
|
||||
for i, e := range entries {
|
||||
if e.Index != i+1 {
|
||||
t.Errorf("ParsePlaylist()[%d].Index = %d, want %d", i, e.Index, i+1)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMediaIDFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
prefix string
|
||||
expected uint
|
||||
expectErr bool
|
||||
}{
|
||||
{"/api/admin/media/123", "/api/admin/media/", 123, false},
|
||||
{"/api/admin/media/1", "/api/admin/media/", 1, false},
|
||||
{"/api/admin/media/", "/api/admin/media/", 0, true},
|
||||
{"/api/admin/media/abc", "/api/admin/media/", 0, true},
|
||||
{"/api/admin/media/0", "/api/admin/media/", 0, true},
|
||||
{"/other/path", "/api/admin/media/", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
id, err := GetMediaIDFromPath(tt.path, tt.prefix)
|
||||
if tt.expectErr && err == nil {
|
||||
t.Errorf("GetMediaIDFromPath(%q) expected error, got nil", tt.path)
|
||||
}
|
||||
if !tt.expectErr && err != nil {
|
||||
t.Errorf("GetMediaIDFromPath(%q) error = %v", tt.path, err)
|
||||
}
|
||||
if id != tt.expected {
|
||||
t.Errorf("GetMediaIDFromPath(%q) = %d, want %d", tt.path, id, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueTargetPath(t *testing.T) {
|
||||
// Test that the function returns a path with the mediaDir prefix
|
||||
// and the correct filename. We can't easily test collision behavior
|
||||
// without filesystem setup, but we can verify the basic path.
|
||||
expected := filepath.Join("/tmp/media", "video.mp4")
|
||||
path := UniqueTargetPath("/tmp/media", "video.mp4")
|
||||
if path != expected {
|
||||
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/auth"
|
||||
"chat/src/db"
|
||||
"chat/src/ws"
|
||||
)
|
||||
|
||||
var requestDurationBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5}
|
||||
|
||||
var chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||
|
||||
type requestDurationHistogram struct {
|
||||
mu sync.Mutex
|
||||
buckets []float64
|
||||
counts []uint64
|
||||
sum float64
|
||||
total uint64
|
||||
}
|
||||
|
||||
func newRequestDurationHistogram(buckets []float64) *requestDurationHistogram {
|
||||
return &requestDurationHistogram{
|
||||
buckets: append([]float64(nil), buckets...),
|
||||
counts: make([]uint64, len(buckets)+1),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *requestDurationHistogram) observe(durationSeconds float64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.total++
|
||||
h.sum += durationSeconds
|
||||
for index, bucket := range h.buckets {
|
||||
if durationSeconds <= bucket {
|
||||
h.counts[index]++
|
||||
return
|
||||
}
|
||||
}
|
||||
h.counts[len(h.counts)-1]++
|
||||
}
|
||||
|
||||
func (h *requestDurationHistogram) snapshot() (counts []uint64, sum float64, total uint64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
counts = append([]uint64(nil), h.counts...)
|
||||
return counts, h.sum, h.total
|
||||
}
|
||||
|
||||
// ObserveRequestDuration records a chat HTTP request duration in seconds.
|
||||
func ObserveRequestDuration(duration time.Duration) {
|
||||
chatRequestDurationHistogram.observe(duration.Seconds())
|
||||
}
|
||||
|
||||
// WrapRequestTiming records request duration for the provided handler.
|
||||
func WrapRequestTiming(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
defer func() { ObserveRequestDuration(time.Since(startedAt)) }()
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMetrics returns Prometheus-format metrics for the chat service.
|
||||
func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var storedMessages int64
|
||||
if gormDB != nil {
|
||||
_ = gormDB.Model(&db.Message{}).Count(&storedMessages).Error
|
||||
}
|
||||
|
||||
var moderationActions int64
|
||||
if gormDB != nil {
|
||||
_ = gormDB.Model(&db.ModerationLog{}).Count(&moderationActions).Error
|
||||
}
|
||||
|
||||
redisEnabled := 0
|
||||
redisUp := 0
|
||||
redisClients := 0
|
||||
redisMemoryUsedBytes := int64(0)
|
||||
redisMemoryMaxBytes := int64(0)
|
||||
redisMemoryUsageRatio := 0.0
|
||||
if redis != nil {
|
||||
redisEnabled = 1
|
||||
if err := redis.Ping(context.Background()); err == nil {
|
||||
redisUp = 1
|
||||
if info, err := redis.Info(context.Background(), "clients"); err == nil {
|
||||
for _, line := range strings.Split(info, "\n") {
|
||||
if strings.HasPrefix(line, "connected_clients:") {
|
||||
if value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "connected_clients:"))); err == nil {
|
||||
redisClients = value
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if info, err := redis.Info(context.Background(), "memory"); err == nil {
|
||||
if value := parseRedisMemoryBytes(info, "used_memory_human"); value > 0 {
|
||||
redisMemoryUsedBytes = value
|
||||
}
|
||||
if value := parseRedisMemoryBytes(info, "maxmemory_human"); value > 0 {
|
||||
redisMemoryMaxBytes = value
|
||||
} else if value := parseRedisMemoryBytes(info, "maxmemory"); value > 0 {
|
||||
redisMemoryMaxBytes = value
|
||||
}
|
||||
if redisMemoryMaxBytes > 0 {
|
||||
redisMemoryUsageRatio = math.Min(float64(redisMemoryUsedBytes)/float64(redisMemoryMaxBytes), 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectedClients := 0
|
||||
dmsSent := int64(0)
|
||||
if hub != nil {
|
||||
connectedClients = hub.ConnectedClients()
|
||||
dmsSent = hub.DMsSent
|
||||
}
|
||||
|
||||
activeSessions := 0
|
||||
if sessionStore != nil {
|
||||
activeSessions = sessionStore.CountActive()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
|
||||
writeLine := func(name string, value any) {
|
||||
fmt.Fprintf(w, "%s %v\n", name, value)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge")
|
||||
writeLine("linearvoid_chat_connected_clients", connectedClients)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
|
||||
writeLine("linearvoid_chat_messages_total", storedMessages)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_dms_total Total private messages sent.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_dms_total counter")
|
||||
writeLine("linearvoid_chat_dms_total", dmsSent)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
|
||||
writeLine("linearvoid_chat_moderation_actions_total", moderationActions)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_enabled Redis configured for chat service.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_enabled gauge")
|
||||
writeLine("linearvoid_chat_redis_enabled", redisEnabled)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_up Redis reachable from chat service.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_up gauge")
|
||||
writeLine("linearvoid_chat_redis_up", redisUp)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_connected_clients Redis connected client count seen by chat service.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
|
||||
writeLine("linearvoid_chat_redis_connected_clients", redisClients)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_used_bytes Redis used memory reported by INFO memory.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_used_bytes gauge")
|
||||
writeLine("linearvoid_chat_redis_memory_used_bytes", redisMemoryUsedBytes)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_max_bytes Redis maxmemory limit reported by INFO memory.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_max_bytes gauge")
|
||||
writeLine("linearvoid_chat_redis_memory_max_bytes", redisMemoryMaxBytes)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_usage_ratio Redis memory usage ratio (used / max).")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_usage_ratio gauge")
|
||||
writeLine("linearvoid_chat_redis_memory_usage_ratio", redisMemoryUsageRatio)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
|
||||
writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
|
||||
fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
|
||||
|
||||
counts, sum, total := chatRequestDurationHistogram.snapshot()
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_request_duration_seconds HTTP request duration for chat API handlers.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_request_duration_seconds histogram")
|
||||
cumulative := uint64(0)
|
||||
for index, bucket := range requestDurationBuckets {
|
||||
cumulative += counts[index]
|
||||
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"%g\"} %d\n", bucket, cumulative)
|
||||
}
|
||||
cumulative += counts[len(counts)-1]
|
||||
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"+Inf\"} %d\n", cumulative)
|
||||
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_sum %g\n", sum)
|
||||
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_count %d\n", total)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleHealth returns a simple health check endpoint.
|
||||
func HandleHealth() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleNotFound returns a 404 JSON response for all unhandled paths.
|
||||
func HandleNotFound() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprint(w, `{"error":"not found"}`)
|
||||
}
|
||||
}
|
||||
|
||||
func parseRedisMemoryBytes(info, key string) int64 {
|
||||
for _, line := range strings.Split(info, "\n") {
|
||||
if !strings.HasPrefix(line, key+":") {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(strings.TrimPrefix(line, key+":"))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if raw, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
return raw
|
||||
}
|
||||
return parseHumanBytes(value)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseHumanBytes(value string) int64 {
|
||||
value = strings.TrimSpace(strings.ToUpper(value))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
multiplier := float64(1)
|
||||
suffixes := map[string]float64{
|
||||
"B": 1,
|
||||
"K": 1024,
|
||||
"KB": 1024,
|
||||
"M": 1024 * 1024,
|
||||
"MB": 1024 * 1024,
|
||||
"G": 1024 * 1024 * 1024,
|
||||
"GB": 1024 * 1024 * 1024,
|
||||
"T": 1024 * 1024 * 1024 * 1024,
|
||||
"TB": 1024 * 1024 * 1024 * 1024,
|
||||
}
|
||||
|
||||
for suffix, factor := range suffixes {
|
||||
if strings.HasSuffix(value, suffix) {
|
||||
multiplier = factor
|
||||
value = strings.TrimSpace(strings.TrimSuffix(value, suffix))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(parsed * multiplier)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/auth"
|
||||
"chat/src/db"
|
||||
"chat/src/ws"
|
||||
)
|
||||
|
||||
func TestHandleHealth(t *testing.T) {
|
||||
handler := HandleHealth()
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"healthy"`) {
|
||||
t.Errorf("body missing healthy: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"chat"`) {
|
||||
t.Errorf("body missing service name: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNotFound(t *testing.T) {
|
||||
handler := HandleNotFound()
|
||||
req := httptest.NewRequest("GET", "/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"not found"`) {
|
||||
t.Errorf("body missing not found: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMetrics(t *testing.T) {
|
||||
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||
ObserveRequestDuration(100 * time.Millisecond)
|
||||
ObserveRequestDuration(750 * time.Millisecond)
|
||||
|
||||
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test DB: %v", err)
|
||||
}
|
||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||
|
||||
// Add some test data
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
|
||||
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
|
||||
|
||||
sessionStore := auth.NewSessionStore()
|
||||
hub := ws.NewHub(gormDB, nil, "test-instance", 10, 10*time.Second, "admin")
|
||||
hub.DMsSent = 5
|
||||
|
||||
handler := HandleMetrics(gormDB, nil, hub, sessionStore, "sqlite", "test-instance")
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Check required metric names
|
||||
requiredMetrics := []string{
|
||||
"linearvoid_chat_connected_clients",
|
||||
"linearvoid_chat_messages_total",
|
||||
"linearvoid_chat_dms_total",
|
||||
"linearvoid_chat_moderation_actions_total",
|
||||
"linearvoid_chat_redis_enabled",
|
||||
"linearvoid_chat_redis_up",
|
||||
"linearvoid_chat_redis_connected_clients",
|
||||
"linearvoid_chat_redis_memory_used_bytes",
|
||||
"linearvoid_chat_redis_memory_max_bytes",
|
||||
"linearvoid_chat_redis_memory_usage_ratio",
|
||||
"linearvoid_chat_admin_sessions_active",
|
||||
"linearvoid_chat_build_info",
|
||||
"linearvoid_chat_request_duration_seconds",
|
||||
}
|
||||
|
||||
for _, metric := range requiredMetrics {
|
||||
if !strings.Contains(body, metric) {
|
||||
t.Errorf("body missing metric %s", metric)
|
||||
}
|
||||
}
|
||||
|
||||
// Check build_info has instance_id label
|
||||
if !strings.Contains(body, `instance_id="test-instance"`) {
|
||||
t.Error("build_info missing instance_id label")
|
||||
}
|
||||
|
||||
// Check values
|
||||
if !strings.Contains(body, "linearvoid_chat_messages_total 1") {
|
||||
t.Error("messages_total should be 1")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_moderation_actions_total 1") {
|
||||
t.Error("moderation_actions_total should be 1")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_dms_total 5") {
|
||||
t.Error("dms_total should be 5")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") {
|
||||
t.Error("redis_enabled should be 0 (nil redis)")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_bucket{le=\"1\"} 2") {
|
||||
t.Error("request duration histogram should include both observed samples by the 1s bucket")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 2") {
|
||||
t.Error("request duration count should be 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMetrics_WrongMethod(t *testing.T) {
|
||||
handler := HandleMetrics(nil, nil, nil, nil, "", "")
|
||||
req := httptest.NewRequest("POST", "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMetrics_WithRedis(t *testing.T) {
|
||||
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||
ObserveRequestDuration(250 * time.Millisecond)
|
||||
|
||||
gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||
|
||||
// mockRedis for metrics test
|
||||
mr := &metricsMockRedis{}
|
||||
sessionStore := auth.NewSessionStore()
|
||||
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
|
||||
|
||||
handler := HandleMetrics(gormDB, mr, hub, sessionStore, "sqlite", "inst-123")
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_enabled 1") {
|
||||
t.Error("redis_enabled should be 1")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_up 1") {
|
||||
t.Error("redis_up should be 1")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") {
|
||||
t.Error("redis_connected_clients should be 5")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_memory_used_bytes 1048576") {
|
||||
t.Error("redis_memory_used_bytes should be 1048576")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_memory_usage_ratio 1") {
|
||||
t.Error("redis_memory_usage_ratio should be 1 when maxmemory is unavailable or equal to used memory in tests")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 1") {
|
||||
t.Error("request duration count should be 1")
|
||||
}
|
||||
}
|
||||
|
||||
// metricsMockRedis is a minimal mock for metrics tests.
|
||||
type metricsMockRedis struct{}
|
||||
|
||||
func (m *metricsMockRedis) Ping(ctx context.Context) error { return nil }
|
||||
func (m *metricsMockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (m *metricsMockRedis) Get(ctx context.Context, key string) (string, error) { return "", nil }
|
||||
func (m *metricsMockRedis) Del(ctx context.Context, keys ...string) error { return nil }
|
||||
func (m *metricsMockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||
func (m *metricsMockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||
func (m *metricsMockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (m *metricsMockRedis) Exists(ctx context.Context, keys ...string) (int64, error) { return 0, nil }
|
||||
func (m *metricsMockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||
return nil
|
||||
}
|
||||
func (m *metricsMockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub { return nil }
|
||||
func (m *metricsMockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) {
|
||||
return "connected_clients:5\nused_memory_human:1M\nmaxmemory_human:1M\n", nil
|
||||
}
|
||||
func (m *metricsMockRedis) Close() error { return nil }
|
||||
@@ -0,0 +1,75 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RedisClient is the subset of Redis operations needed for rate limiting.
|
||||
type RedisClient interface {
|
||||
Incr(ctx context.Context, key string) (int64, error)
|
||||
Expire(ctx context.Context, key string, expiration time.Duration) error
|
||||
Decr(ctx context.Context, key string) (int64, error)
|
||||
}
|
||||
|
||||
// CheckMessageRate checks if a user has exceeded the message rate limit.
|
||||
// Returns true if allowed, false if rate limited.
|
||||
func CheckMessageRate(ctx context.Context, redis RedisClient, username string, max int, window time.Duration) (bool, error) {
|
||||
if redis == nil {
|
||||
return true, nil // No Redis = no rate limiting
|
||||
}
|
||||
key := fmt.Sprintf("rate:%s", username)
|
||||
count, err := redis.Incr(ctx, key)
|
||||
if err != nil {
|
||||
return true, err // Fail open on Redis error
|
||||
}
|
||||
if count == 1 {
|
||||
_ = redis.Expire(ctx, key, window)
|
||||
}
|
||||
return count <= int64(max), nil
|
||||
}
|
||||
|
||||
// CheckLoginRate checks if an IP has exceeded the login attempt limit.
|
||||
// Returns true if allowed, false if rate limited.
|
||||
func CheckLoginRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
|
||||
if redis == nil {
|
||||
return true, nil
|
||||
}
|
||||
key := fmt.Sprintf("login_attempts:%s", clientIP)
|
||||
count, err := redis.Incr(ctx, key)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if count == 1 {
|
||||
_ = redis.Expire(ctx, key, 5*time.Minute)
|
||||
}
|
||||
return count <= 5, nil
|
||||
}
|
||||
|
||||
// CheckWSConnRate checks if an IP has exceeded the WebSocket connection limit.
|
||||
// Returns true if allowed, false if rate limited.
|
||||
// Caller must call Decr on disconnect.
|
||||
func CheckWSConnRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
|
||||
if redis == nil {
|
||||
return true, nil
|
||||
}
|
||||
key := fmt.Sprintf("ws_conn:%s", clientIP)
|
||||
count, err := redis.Incr(ctx, key)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if count == 1 {
|
||||
_ = redis.Expire(ctx, key, 60*time.Second)
|
||||
}
|
||||
return count <= 5, nil
|
||||
}
|
||||
|
||||
// DecrWSConn decrements the WebSocket connection counter for an IP.
|
||||
func DecrWSConn(ctx context.Context, redis RedisClient, clientIP string) {
|
||||
if redis == nil {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("ws_conn:%s", clientIP)
|
||||
_, _ = redis.Decr(ctx, key)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockRedis implements RedisClient for testing.
|
||||
type mockRedis struct {
|
||||
mu sync.Mutex
|
||||
store map[string]int64
|
||||
}
|
||||
|
||||
func newMockRedis() *mockRedis {
|
||||
return &mockRedis{store: make(map[string]int64)}
|
||||
}
|
||||
|
||||
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.store[key]++
|
||||
return m.store[key], nil
|
||||
}
|
||||
|
||||
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.store[key]--
|
||||
return m.store[key], nil
|
||||
}
|
||||
|
||||
func TestCheckMessageRate(t *testing.T) {
|
||||
redis := newMockRedis()
|
||||
ctx := context.Background()
|
||||
|
||||
// First 10 messages allowed
|
||||
for i := 0; i < 10; i++ {
|
||||
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckMessageRate #%d error = %v", i+1, err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Errorf("CheckMessageRate #%d = false, want true", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 11th message blocked
|
||||
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckMessageRate #11 error = %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("CheckMessageRate #11 = true, want false (rate limited)")
|
||||
}
|
||||
|
||||
// Different user not affected
|
||||
allowed, err = CheckMessageRate(ctx, redis, "user2", 10, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckMessageRate user2 error = %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("CheckMessageRate user2 = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMessageRate_NilRedis(t *testing.T) {
|
||||
allowed, err := CheckMessageRate(context.Background(), nil, "user1", 10, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckMessageRate nil redis error = %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("CheckMessageRate nil redis = false, want true (fail open)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLoginRate(t *testing.T) {
|
||||
redis := newMockRedis()
|
||||
ctx := context.Background()
|
||||
|
||||
// First 5 attempts allowed
|
||||
for i := 0; i < 5; i++ {
|
||||
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckLoginRate #%d error = %v", i+1, err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Errorf("CheckLoginRate #%d = false, want true", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 6th attempt blocked
|
||||
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckLoginRate #6 error = %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("CheckLoginRate #6 = true, want false (rate limited)")
|
||||
}
|
||||
|
||||
// Different IP not affected
|
||||
allowed, err = CheckLoginRate(ctx, redis, "10.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckLoginRate other IP error = %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("CheckLoginRate other IP = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLoginRate_NilRedis(t *testing.T) {
|
||||
allowed, err := CheckLoginRate(context.Background(), nil, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckLoginRate nil redis error = %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("CheckLoginRate nil redis = false, want true (fail open)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckWSConnRate(t *testing.T) {
|
||||
redis := newMockRedis()
|
||||
ctx := context.Background()
|
||||
|
||||
// First 5 connections allowed
|
||||
for i := 0; i < 5; i++ {
|
||||
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckWSConnRate #%d error = %v", i+1, err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Errorf("CheckWSConnRate #%d = false, want true", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 6th connection blocked
|
||||
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckWSConnRate #6 error = %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("CheckWSConnRate #6 = true, want false (rate limited)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckWSConnRate_NilRedis(t *testing.T) {
|
||||
allowed, err := CheckWSConnRate(context.Background(), nil, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckWSConnRate nil redis error = %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("CheckWSConnRate nil redis = false, want true (fail open)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrWSConn(t *testing.T) {
|
||||
redis := newMockRedis()
|
||||
ctx := context.Background()
|
||||
|
||||
// Increment to 5
|
||||
for i := 0; i < 5; i++ {
|
||||
CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||
}
|
||||
|
||||
// 6th should be blocked
|
||||
allowed, _ := CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||
if allowed {
|
||||
t.Error("6th connection should be blocked")
|
||||
}
|
||||
|
||||
// Decrement twice (simulate 2 disconnects)
|
||||
DecrWSConn(ctx, redis, "192.168.1.1")
|
||||
DecrWSConn(ctx, redis, "192.168.1.1")
|
||||
|
||||
// Now should be allowed again (counter 6→4, then incr→5)
|
||||
allowed, _ = CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||
if !allowed {
|
||||
t.Error("after 2x DecrWSConn, connection should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrWSConn_NilRedis(t *testing.T) {
|
||||
// Should not panic
|
||||
DecrWSConn(context.Background(), nil, "192.168.1.1")
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/db"
|
||||
"chat/src/ratelimit"
|
||||
)
|
||||
|
||||
// Upgrader is the WebSocket upgrader configuration.
|
||||
var Upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// ClientInfo stores metadata about a connected WebSocket client.
|
||||
type ClientInfo struct {
|
||||
Username string
|
||||
Room string
|
||||
}
|
||||
|
||||
// Hub manages all WebSocket connections and message routing.
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[*websocket.Conn]bool
|
||||
clientMeta map[*websocket.Conn]ClientInfo
|
||||
DB *gorm.DB
|
||||
Redis db.RedisClient
|
||||
InstanceID string
|
||||
RateLimitMax int
|
||||
RateLimitWindow time.Duration
|
||||
AdminUsername string
|
||||
DMsSent int64
|
||||
}
|
||||
|
||||
// NewHub creates a new WebSocket hub.
|
||||
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*websocket.Conn]bool),
|
||||
clientMeta: make(map[*websocket.Conn]ClientInfo),
|
||||
DB: gormDB,
|
||||
Redis: redis,
|
||||
InstanceID: instanceID,
|
||||
RateLimitMax: rateMax,
|
||||
RateLimitWindow: rateWindow,
|
||||
AdminUsername: adminUser,
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectedClients returns the number of currently connected clients.
|
||||
func (h *Hub) ConnectedClients() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all connected clients and persists to DB.
|
||||
func (h *Hub) Broadcast(msg db.Message) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// Persist to configured SQL backend.
|
||||
if h.DB != nil {
|
||||
h.DB.Create(&msg)
|
||||
}
|
||||
|
||||
// Local fan-out
|
||||
for client := range h.clients {
|
||||
if err := client.WriteJSON(msg); err != nil {
|
||||
client.Close()
|
||||
delete(h.clients, client)
|
||||
delete(h.clientMeta, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Redis pub/sub for multi-instance fan-out
|
||||
if h.Redis != nil && msg.Room != "" {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"instance": h.InstanceID,
|
||||
"msg": msg,
|
||||
})
|
||||
_ = h.Redis.Publish(context.Background(), fmt.Sprintf("room:%s:messages", msg.Room), payload)
|
||||
}
|
||||
}
|
||||
|
||||
// PushChatHistory sends the last N messages for a room via WebSocket.
|
||||
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
||||
if h.DB == nil || room == "" {
|
||||
return
|
||||
}
|
||||
var messages []db.Message
|
||||
h.DB.Where("room = ?", room).Order("timestamp desc").Limit(limit).Find(&messages)
|
||||
// Reverse to chronological order
|
||||
for i := len(messages)/2 - 1; i >= 0; i-- {
|
||||
opp := len(messages) - 1 - i
|
||||
messages[i], messages[opp] = messages[opp], messages[i]
|
||||
}
|
||||
h.mu.Lock()
|
||||
for _, msg := range messages {
|
||||
conn.WriteJSON(msg)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// HandleConnection handles a new WebSocket connection.
|
||||
func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := r.Header.Get("X-Forwarded-For")
|
||||
if clientIP == "" {
|
||||
clientIP = r.RemoteAddr
|
||||
}
|
||||
|
||||
// IP-based connection rate limiting
|
||||
allowed, err := ratelimit.CheckWSConnRate(context.Background(), h.Redis, clientIP)
|
||||
if err != nil {
|
||||
log.Printf("WS rate limit check error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, "too many connections from this IP", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
defer ratelimit.DecrWSConn(context.Background(), h.Redis, clientIP)
|
||||
|
||||
conn, err := Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Println("Upgrade error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.URL.Query().Get("username")
|
||||
room := r.URL.Query().Get("room")
|
||||
if username == "" {
|
||||
username = fmt.Sprintf("anon_%d", time.Now().UnixNano()%10000)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.clients[conn] = true
|
||||
h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
|
||||
h.mu.Unlock()
|
||||
|
||||
// Announce presence (ADR-007)
|
||||
if h.Redis != nil && room != "" {
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
|
||||
}
|
||||
|
||||
// Push chat history on join (last 50 messages)
|
||||
h.PushChatHistory(conn, room, 50)
|
||||
|
||||
// Heartbeat goroutine: refresh presence TTL every 30s
|
||||
heartbeatStop := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if h.Redis != nil && room != "" {
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||
}
|
||||
// Ping to detect dead connections
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
case <-heartbeatStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
close(heartbeatStop)
|
||||
h.mu.Lock()
|
||||
delete(h.clients, conn)
|
||||
delete(h.clientMeta, conn)
|
||||
h.mu.Unlock()
|
||||
if h.Redis != nil && room != "" {
|
||||
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
||||
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
var msg db.Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
msg.Timestamp = time.Now()
|
||||
msg.Username = username // enforce from query param for security
|
||||
|
||||
// Check if banned before processing
|
||||
if h.Redis != nil {
|
||||
if isBanned, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("banned:%s", username)); isBanned > 0 {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "You are banned from chat.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
if isMuted, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("muted:%s", username)); isMuted > 0 {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "You are muted.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting check
|
||||
allowed, _ := ratelimit.CheckMessageRate(context.Background(), h.Redis, msg.Username, h.RateLimitMax, h.RateLimitWindow)
|
||||
if !allowed {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "Rate limit exceeded. Please slow down.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle moderation commands
|
||||
if strings.HasPrefix(msg.Text, "/mod ") || strings.HasPrefix(msg.Text, "/mute ") || strings.HasPrefix(msg.Text, "/ban ") {
|
||||
h.handleModerationCommand(msg, conn)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle private DMs
|
||||
if strings.HasPrefix(msg.Room, "dm:") {
|
||||
h.handlePrivateDM(msg)
|
||||
continue
|
||||
}
|
||||
|
||||
msg.Type = "public"
|
||||
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
|
||||
h.Broadcast(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// handlePrivateDM routes private messages to the specific user pair.
|
||||
func (h *Hub) handlePrivateDM(msg db.Message) {
|
||||
msg.Type = "private"
|
||||
h.DMsSent++
|
||||
|
||||
// Persist for history
|
||||
if h.DB != nil {
|
||||
h.DB.Create(&msg)
|
||||
}
|
||||
|
||||
// Deliver to target if online
|
||||
h.mu.Lock()
|
||||
targetOnline := false
|
||||
for conn, info := range h.clientMeta {
|
||||
if info.Username == msg.Room || strings.Contains(msg.Room, info.Username) {
|
||||
if err := conn.WriteJSON(msg); err == nil {
|
||||
targetOnline = true
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if !targetOnline {
|
||||
log.Printf("DM stored for offline user: %s -> room %s", msg.Username, msg.Room)
|
||||
}
|
||||
|
||||
// Also deliver to sender for confirmation
|
||||
h.mu.Lock()
|
||||
for conn, info := range h.clientMeta {
|
||||
if info.Username == msg.Username {
|
||||
conn.WriteJSON(msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
log.Printf("Private DM from %s to room %s: %s", msg.Username, msg.Room, msg.Text)
|
||||
}
|
||||
|
||||
// logModerationAction logs an action to the immutable ModerationLog table.
|
||||
func (h *Hub) logModerationAction(action, target, moderator, reason string) {
|
||||
logEntry := db.ModerationLog{
|
||||
Action: action,
|
||||
Target: target,
|
||||
Moderator: moderator,
|
||||
Reason: reason,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if h.DB != nil {
|
||||
h.DB.Create(&logEntry)
|
||||
}
|
||||
log.Printf("Moderation action logged: %s %s by %s - %s", action, target, moderator, reason)
|
||||
}
|
||||
|
||||
// handleModerationCommand processes /mod, /mute, /ban commands.
|
||||
func (h *Hub) handleModerationCommand(msg db.Message, conn *websocket.Conn) {
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 3 {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "Usage: /mute <user> <duration_m> <reason> or /ban <user> <reason>", Timestamp: time.Now()})
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Username != h.AdminUsername {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "Only moderators can use moderation commands.", Timestamp: time.Now()})
|
||||
return
|
||||
}
|
||||
|
||||
action := strings.TrimPrefix(parts[0], "/")
|
||||
target := parts[1]
|
||||
reason := strings.Join(parts[2:], " ")
|
||||
|
||||
if h.Redis != nil {
|
||||
switch action {
|
||||
case "mute", "mod":
|
||||
durationMinutes := 10 // default
|
||||
if len(parts) >= 3 {
|
||||
if d, err := strconv.Atoi(parts[2]); err == nil && d > 0 {
|
||||
durationMinutes = d
|
||||
if len(parts) > 3 {
|
||||
reason = strings.Join(parts[3:], " ")
|
||||
} else {
|
||||
reason = "no reason given"
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("muted:%s", target), "1", time.Duration(durationMinutes)*time.Minute)
|
||||
case "ban":
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("banned:%s", target), "1", 0) // no expiry
|
||||
}
|
||||
}
|
||||
|
||||
h.logModerationAction(action, target, msg.Username, reason)
|
||||
|
||||
h.Broadcast(db.Message{
|
||||
Room: msg.Room,
|
||||
Username: "system",
|
||||
Type: "system",
|
||||
Text: fmt.Sprintf("%s was %s by %s: %s", target, action, msg.Username, reason),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// SubscribeRoomMessages listens for Redis pub/sub messages and fans out locally.
|
||||
func (h *Hub) SubscribeRoomMessages() {
|
||||
if h.Redis == nil {
|
||||
return
|
||||
}
|
||||
pubsub := h.Redis.PSubscribe(context.Background(), "room:*:messages")
|
||||
defer pubsub.Close()
|
||||
|
||||
log.Printf("Redis pub/sub subscriber started (instance %s)", h.InstanceID)
|
||||
|
||||
for {
|
||||
msg, err := pubsub.ReceiveMessage(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("Redis pub/sub error: %v — retrying in 5s", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
pubsub = h.Redis.PSubscribe(context.Background(), "room:*:messages")
|
||||
continue
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Instance string `json:"instance"`
|
||||
Msg db.Message `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip messages originating from this instance
|
||||
if envelope.Instance == h.InstanceID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fan out to local clients
|
||||
h.mu.Lock()
|
||||
for client := range h.clients {
|
||||
client.WriteJSON(envelope.Msg)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ScanPresence returns all active presence entries from Redis.
|
||||
func (h *Hub) ScanPresence(ctx context.Context) ([]db.Presence, error) {
|
||||
if h.Redis == nil {
|
||||
return nil, nil
|
||||
}
|
||||
keys, _, err := h.Redis.Scan(ctx, 0, "presence:*", 200)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var presences []db.Presence
|
||||
for _, key := range keys {
|
||||
parts := strings.SplitN(key, ":", 3)
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
presences = append(presences, db.Presence{
|
||||
Room: parts[1],
|
||||
Username: parts[2],
|
||||
Online: true,
|
||||
})
|
||||
}
|
||||
return presences, nil
|
||||
}
|
||||
|
||||
// MuteUser mutes a user for a duration.
|
||||
func (h *Hub) MuteUser(ctx context.Context, username string, duration time.Duration) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Set(ctx, fmt.Sprintf("muted:%s", username), "1", duration)
|
||||
}
|
||||
|
||||
// UnmuteUser removes a mute.
|
||||
func (h *Hub) UnmuteUser(ctx context.Context, username string) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Del(ctx, fmt.Sprintf("muted:%s", username))
|
||||
}
|
||||
|
||||
// BanUser bans a user permanently.
|
||||
func (h *Hub) BanUser(ctx context.Context, username string) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Set(ctx, fmt.Sprintf("banned:%s", username), "1", 0)
|
||||
}
|
||||
|
||||
// UnbanUser removes a ban.
|
||||
func (h *Hub) UnbanUser(ctx context.Context, username string) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Del(ctx, fmt.Sprintf("banned:%s", username))
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/gorilla/websocket"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/db"
|
||||
)
|
||||
|
||||
// mockRedis implements db.RedisClient for testing.
|
||||
type mockRedis struct {
|
||||
mu sync.Mutex
|
||||
store map[string]string
|
||||
pubsubs []*mockPubSub
|
||||
}
|
||||
|
||||
func newMockRedis() *mockRedis {
|
||||
return &mockRedis{store: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (m *mockRedis) Ping(ctx context.Context) error { return nil }
|
||||
func (m *mockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.store[key] = value.(string)
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Get(ctx context.Context, key string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.store[key], nil
|
||||
}
|
||||
func (m *mockRedis) Del(ctx context.Context, keys ...string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, k := range keys {
|
||||
delete(m.store, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, k := range keys {
|
||||
if _, ok := m.store[k]; ok {
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
payload, _ := json.Marshal(message)
|
||||
for _, ps := range m.pubsubs {
|
||||
ps.messages <- &db.PubSubMessage{Channel: channel, Payload: string(payload)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub {
|
||||
ps := &mockPubSub{messages: make(chan *db.PubSubMessage, 100)}
|
||||
m.mu.Lock()
|
||||
m.pubsubs = append(m.pubsubs, ps)
|
||||
m.mu.Unlock()
|
||||
return ps
|
||||
}
|
||||
func (m *mockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range m.store {
|
||||
if strings.Contains(k, strings.TrimSuffix(match, "*")) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
return keys, 0, nil
|
||||
}
|
||||
func (m *mockRedis) Info(ctx context.Context, sections ...string) (string, error) { return "", nil }
|
||||
func (m *mockRedis) Close() error { return nil }
|
||||
|
||||
type mockPubSub struct {
|
||||
messages chan *db.PubSubMessage
|
||||
}
|
||||
|
||||
func (m *mockPubSub) ReceiveMessage(ctx context.Context) (*db.PubSubMessage, error) {
|
||||
select {
|
||||
case msg := <-m.messages:
|
||||
return msg, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
func (m *mockPubSub) Close() error { return nil }
|
||||
|
||||
func setupTestHub(t *testing.T) (*Hub, *gorm.DB, *mockRedis) {
|
||||
t.Helper()
|
||||
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test DB: %v", err)
|
||||
}
|
||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||
mr := newMockRedis()
|
||||
hub := NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
|
||||
return hub, gormDB, mr
|
||||
}
|
||||
|
||||
func setupWSServer(hub *Hub) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(hub.HandleConnection))
|
||||
}
|
||||
|
||||
// wsClient wraps a websocket.Conn with a channel-based read helper.
|
||||
type wsClient struct {
|
||||
conn *websocket.Conn
|
||||
msgs chan db.Message
|
||||
errors chan error
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newWSClient(t *testing.T, url string) *wsClient {
|
||||
t.Helper()
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial error: %v", err)
|
||||
}
|
||||
wc := &wsClient{
|
||||
conn: conn,
|
||||
msgs: make(chan db.Message, 100),
|
||||
errors: make(chan error, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go wc.readLoop()
|
||||
return wc
|
||||
}
|
||||
|
||||
func (wc *wsClient) readLoop() {
|
||||
defer close(wc.msgs)
|
||||
for {
|
||||
var msg db.Message
|
||||
if err := wc.conn.ReadJSON(&msg); err != nil {
|
||||
wc.errors <- err
|
||||
return
|
||||
}
|
||||
select {
|
||||
case wc.msgs <- msg:
|
||||
case <-wc.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (wc *wsClient) close() {
|
||||
close(wc.done)
|
||||
wc.conn.Close()
|
||||
}
|
||||
|
||||
// recvMsg reads the next message with a timeout.
|
||||
func (wc *wsClient) recvMsg(timeout time.Duration) (*db.Message, error) {
|
||||
select {
|
||||
case msg := <-wc.msgs:
|
||||
return &msg, nil
|
||||
case err := <-wc.errors:
|
||||
return nil, err
|
||||
case <-time.After(timeout):
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
}
|
||||
|
||||
// skipSystem reads and discards system messages.
|
||||
func (wc *wsClient) skipSystem(timeout time.Duration) {
|
||||
for {
|
||||
msg, err := wc.recvMsg(timeout)
|
||||
if err != nil || msg.Type != "system" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (wc *wsClient) writeJSON(v interface{}) error {
|
||||
return wc.conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
func TestHub_Broadcast(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
|
||||
msg := db.Message{
|
||||
Room: "main",
|
||||
Username: "user1",
|
||||
Text: "hello",
|
||||
Timestamp: time.Now(),
|
||||
Type: "public",
|
||||
}
|
||||
hub.Broadcast(msg)
|
||||
|
||||
var count int64
|
||||
hub.DB.Model(&db.Message{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("message count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ConnectedClients(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
if hub.ConnectedClients() != 0 {
|
||||
t.Errorf("ConnectedClients() = %d, want 0", hub.ConnectedClients())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_WebSocketConnection(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
msg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg.Text, "joined") {
|
||||
t.Errorf("expected join message, got text=%s", msg.Text)
|
||||
}
|
||||
|
||||
if hub.ConnectedClients() != 1 {
|
||||
t.Errorf("ConnectedClients() = %d, want 1", hub.ConnectedClients())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SendMessage(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
wc.skipSystem(2 * time.Second)
|
||||
|
||||
if err := wc.writeJSON(db.Message{Room: "main", Text: "hello world"}); err != nil {
|
||||
t.Fatalf("writeJSON error: %v", err)
|
||||
}
|
||||
|
||||
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if recvMsg.Text != "hello world" {
|
||||
t.Errorf("recvMsg.Text = %q, want hello world", recvMsg.Text)
|
||||
}
|
||||
if recvMsg.Username != "testuser" {
|
||||
t.Errorf("recvMsg.Username = %q, want testuser", recvMsg.Username)
|
||||
}
|
||||
if recvMsg.Type != "public" {
|
||||
t.Errorf("recvMsg.Type = %q, want public", recvMsg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Disconnect(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
wc.skipSystem(2 * time.Second)
|
||||
|
||||
if hub.ConnectedClients() != 1 {
|
||||
t.Errorf("ConnectedClients() = %d, want 1", hub.ConnectedClients())
|
||||
}
|
||||
|
||||
wc.close()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if hub.ConnectedClients() != 0 {
|
||||
t.Errorf("ConnectedClients() after close = %d, want 0", hub.ConnectedClients())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_BannedUser(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
mr.Set(context.Background(), "banned:testuser", "1", 0)
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
wc.skipSystem(2 * time.Second)
|
||||
wc.writeJSON(db.Message{Room: "main", Text: "hello"})
|
||||
|
||||
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if !strings.Contains(recvMsg.Text, "banned") {
|
||||
t.Errorf("expected banned message, got %q", recvMsg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_MutedUser(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
mr.Set(context.Background(), "muted:testuser", "1", 0)
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
wc.skipSystem(2 * time.Second)
|
||||
wc.writeJSON(db.Message{Room: "main", Text: "hello"})
|
||||
|
||||
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if !strings.Contains(recvMsg.Text, "muted") {
|
||||
t.Errorf("expected muted message, got %q", recvMsg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ModerationCommand(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=admin&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
wc.skipSystem(2 * time.Second)
|
||||
wc.writeJSON(db.Message{Room: "main", Text: "/mute baduser 5 spamming"})
|
||||
|
||||
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if !strings.Contains(recvMsg.Text, "mute") {
|
||||
t.Errorf("expected mute broadcast, got text=%s", recvMsg.Text)
|
||||
}
|
||||
|
||||
var count int64
|
||||
hub.DB.Model(&db.ModerationLog{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("moderation log count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_NonAdminModerationCommand(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=regularuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
wc.skipSystem(2 * time.Second)
|
||||
wc.writeJSON(db.Message{Room: "main", Text: "/mute baduser 5 spamming"})
|
||||
|
||||
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if !strings.Contains(recvMsg.Text, "Only moderators") {
|
||||
t.Errorf("expected moderator-only message, got %q", recvMsg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_PrivateDM(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
wc1 := newWSClient(t, wsURL+"?username=alice&room=main")
|
||||
defer wc1.close()
|
||||
wc2 := newWSClient(t, wsURL+"?username=bob&room=main")
|
||||
defer wc2.close()
|
||||
|
||||
wc1.skipSystem(2 * time.Second)
|
||||
wc2.skipSystem(2 * time.Second)
|
||||
|
||||
wc1.writeJSON(db.Message{Room: "dm:bob", Text: "hey bob"})
|
||||
|
||||
recvMsg, err := wc2.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("bob recvMsg error: %v", err)
|
||||
}
|
||||
if recvMsg.Type != "private" {
|
||||
t.Errorf("expected private type, got %s", recvMsg.Type)
|
||||
}
|
||||
if recvMsg.Text != "hey bob" {
|
||||
t.Errorf("expected 'hey bob', got %q", recvMsg.Text)
|
||||
}
|
||||
|
||||
recvMsg2, err := wc1.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("alice recvMsg error: %v", err)
|
||||
}
|
||||
if recvMsg2.Type != "private" {
|
||||
t.Errorf("expected private confirmation, got %s", recvMsg2.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_PushChatHistory(t *testing.T) {
|
||||
hub, gormDB, _ := setupTestHub(t)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
gormDB.Create(&db.Message{
|
||||
Room: "main",
|
||||
Username: "user1",
|
||||
Text: "msg",
|
||||
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
|
||||
Type: "public",
|
||||
})
|
||||
}
|
||||
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=newuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
count := 0
|
||||
for i := 0; i < 10; i++ {
|
||||
msg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if msg.Type == "public" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 5 {
|
||||
t.Errorf("history message count = %d, want 5", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_MuteUser_UnmuteUser(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := hub.MuteUser(ctx, "user1", 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("MuteUser error = %v", err)
|
||||
}
|
||||
|
||||
exists, _ := mr.Exists(ctx, "muted:user1")
|
||||
if exists != 1 {
|
||||
t.Error("muted key not found after MuteUser")
|
||||
}
|
||||
|
||||
err = hub.UnmuteUser(ctx, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("UnmuteUser error = %v", err)
|
||||
}
|
||||
|
||||
exists, _ = mr.Exists(ctx, "muted:user1")
|
||||
if exists != 0 {
|
||||
t.Error("muted key still exists after UnmuteUser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_BanUser_UnbanUser(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := hub.BanUser(ctx, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("BanUser error = %v", err)
|
||||
}
|
||||
|
||||
exists, _ := mr.Exists(ctx, "banned:user1")
|
||||
if exists != 1 {
|
||||
t.Error("banned key not found after BanUser")
|
||||
}
|
||||
|
||||
err = hub.UnbanUser(ctx, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("UnbanUser error = %v", err)
|
||||
}
|
||||
|
||||
exists, _ = mr.Exists(ctx, "banned:user1")
|
||||
if exists != 0 {
|
||||
t.Error("banned key still exists after UnbanUser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ScanPresence(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mr.Set(ctx, "presence:main:user1", "online", 0)
|
||||
mr.Set(ctx, "presence:main:user2", "online", 0)
|
||||
mr.Set(ctx, "presence:lobby:user3", "online", 0)
|
||||
|
||||
presences, err := hub.ScanPresence(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanPresence error = %v", err)
|
||||
}
|
||||
if len(presences) != 3 {
|
||||
t.Errorf("ScanPresence len = %d, want 3", len(presences))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SubscribeRoomMessages(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
hub.SubscribeRoomMessages()
|
||||
}()
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"instance": "other-instance",
|
||||
"msg": db.Message{
|
||||
Room: "main",
|
||||
Username: "remoteuser",
|
||||
Text: "remote hello",
|
||||
Timestamp: time.Now(),
|
||||
Type: "public",
|
||||
},
|
||||
})
|
||||
mr.Publish(ctx, "room:main:messages", string(payload))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
ownPayload, _ := json.Marshal(map[string]any{
|
||||
"instance": "test-instance",
|
||||
"msg": db.Message{
|
||||
Room: "main",
|
||||
Username: "localuser",
|
||||
Text: "local hello",
|
||||
Timestamp: time.Now(),
|
||||
Type: "public",
|
||||
},
|
||||
})
|
||||
mr.Publish(ctx, "room:main:messages", string(ownPayload))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
@@ -2,11 +2,10 @@ FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/ ./src/
|
||||
COPY src/go.mod src/go.sum ./
|
||||
|
||||
# Initialize Go module if not present
|
||||
RUN go mod init playout || true
|
||||
RUN go mod tidy
|
||||
|
||||
# Download dependencies and build
|
||||
RUN go mod download
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /playlist-manager ./src/playlist.go
|
||||
|
||||
FROM alpine:3.20
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
module playout
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.4
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.35
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect
|
||||
github.com/aws/smithy-go v1.27.6 // indirect
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE=
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0 h1:OkYV+1171za+ab9otU1tGxMXhx6uZvwVEtVddjLuYTg=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc=
|
||||
github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA=
|
||||
github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
@@ -28,6 +28,9 @@ type VideoFile struct {
|
||||
|
||||
var (
|
||||
r2Client *s3.Client
|
||||
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
return client.PutObject(context.TODO(), input)
|
||||
}
|
||||
bucket = os.Getenv("R2_BUCKET_NAME")
|
||||
playoutMode = "starting" // "live", "slate", "degraded"
|
||||
restartCount = 0
|
||||
@@ -39,6 +42,9 @@ var (
|
||||
const (
|
||||
maxRestartsPerMinute = 5
|
||||
slateManifestName = "playlist.m3u8"
|
||||
// Cache-Control header values for R2 uploads.
|
||||
segmentCacheControl = "public, max-age=31536000, immutable"
|
||||
manifestCacheControl = "public, max-age=2"
|
||||
)
|
||||
|
||||
// initR2Client initializes the R2 (S3-compatible) client
|
||||
@@ -203,7 +209,7 @@ func validateKeyframeInterval(videoPath string) error {
|
||||
"-v", "quiet",
|
||||
"-select_streams", "v:0",
|
||||
"-show_frames",
|
||||
"-show_entries", "frame=key_frame,pkt_pts_time",
|
||||
"-show_entries", "frame=key_frame,best_effort_timestamp_time,pkt_pts_time",
|
||||
"-of", "csv=p=0",
|
||||
videoPath)
|
||||
output, err := cmd.Output()
|
||||
@@ -223,8 +229,16 @@ func validateKeyframeInterval(videoPath string) error {
|
||||
if len(parts) < 2 || parts[0] != "1" {
|
||||
continue
|
||||
}
|
||||
pts, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
if err != nil {
|
||||
var pts float64
|
||||
var found bool
|
||||
for _, raw := range parts[1:] {
|
||||
if value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64); err == nil {
|
||||
pts = value
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
keyframes = append(keyframes, kfEntry{pts})
|
||||
@@ -284,7 +298,11 @@ func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
// SyncToR2 uploads a file to R2 and returns the key
|
||||
// SyncToR2 uploads a file to R2 and returns the key.
|
||||
// Cache-Control is set automatically based on file extension:
|
||||
// - .m4s, .ts, .mp4, .m4a -> immutable (1 year)
|
||||
// - .m3u8 -> short-lived (2s, live manifest)
|
||||
// - everything else -> no cache header
|
||||
func SyncToR2(localPath, r2Key string) error {
|
||||
if r2Client == nil {
|
||||
if err := initR2Client(); err != nil {
|
||||
@@ -298,11 +316,23 @@ func SyncToR2(localPath, r2Key string) error {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = r2Client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
input := &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(r2Key),
|
||||
Body: file,
|
||||
})
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(r2Key))
|
||||
switch ext {
|
||||
case ".m4s", ".ts", ".mp4", ".m4a":
|
||||
input.CacheControl = aws.String(segmentCacheControl)
|
||||
input.ContentType = aws.String("video/MP2T")
|
||||
case ".m3u8":
|
||||
input.CacheControl = aws.String(manifestCacheControl)
|
||||
input.ContentType = aws.String("application/vnd.apple.mpegurl")
|
||||
}
|
||||
|
||||
_, err = putObject(r2Client, input)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
package main
|
||||
|
||||
// Test fixture generation commands (run from repository root):
|
||||
//
|
||||
// Valid 720p30 fixture (1.5s, H.264/AAC, yuv420p, 48000Hz, keyframe every 30 frames):
|
||||
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
|
||||
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 30 -keyint_min 30 \
|
||||
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||
// backend/playout/testdata/valid_720p30.mp4
|
||||
//
|
||||
// Invalid h265 fixture (1.5s, HEVC, otherwise compliant):
|
||||
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
|
||||
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||
// -c:v libx265 -preset ultrafast -crf 28 -pix_fmt yuv420p -g 30 -keyint_min 30 \
|
||||
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||
// -tag:v hvc1 backend/playout/testdata/invalid_h265.mp4
|
||||
//
|
||||
// Invalid 24fps fixture (1.5s, correct codec but wrong framerate):
|
||||
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=24 \
|
||||
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 24 -keyint_min 24 \
|
||||
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||
// backend/playout/testdata/invalid_24fps.mp4
|
||||
//
|
||||
// Test playlist fixtures are plain text files with paths relative to /app/media.
|
||||
// See backend/playout/testdata/*.txt.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
func ensureFFProbeInPath(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := exec.LookPath("ffprobe"); err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
windowsFFProbe := `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffprobe.exe`
|
||||
if _, err := os.Stat(windowsFFProbe); err == nil {
|
||||
t.Setenv("PATH", filepath.Dir(windowsFFProbe)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return
|
||||
}
|
||||
|
||||
t.Skip("ffprobe not available on PATH and Windows WinGet install path not found")
|
||||
}
|
||||
|
||||
func TestLoadPlaylist(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
wantLen int
|
||||
wantErr bool
|
||||
first string
|
||||
}{
|
||||
{
|
||||
name: "valid playlist",
|
||||
file: "valid_playlist.txt",
|
||||
wantLen: 2,
|
||||
first: "/app/media/demo1.mp4",
|
||||
},
|
||||
{
|
||||
name: "empty playlist",
|
||||
file: "empty_playlist.txt",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "comments only",
|
||||
file: "comments_only.txt",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "mixed valid invalid",
|
||||
file: "mixed_valid_invalid.txt",
|
||||
wantLen: 3,
|
||||
first: "/app/media/demo1.mp4",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := filepath.Join("..", "testdata", tt.file)
|
||||
|
||||
videos, err := LoadPlaylist(path)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if len(videos) != tt.wantLen {
|
||||
t.Errorf("len = %d, want %d", len(videos), tt.wantLen)
|
||||
}
|
||||
if tt.wantLen > 0 && videos[0].Path != tt.first {
|
||||
t.Errorf("videos[0].Path = %q, want %q", videos[0].Path, tt.first)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPlaylist_FileNotFound(t *testing.T) {
|
||||
_, err := LoadPlaylist("/nonexistent/playlist.txt")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateKeyframeInterval(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
csvData string
|
||||
wantErr bool
|
||||
errText string
|
||||
}{
|
||||
{
|
||||
name: "valid keyframes (gaps <=2s)",
|
||||
csvData: "1,0.00\n0,0.50\n1,1.00\n0,1.50\n1,2.00\n0,2.50\n1,3.00\n",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "gap too large >2s",
|
||||
csvData: "1,0.00\n0,1.00\n1,3.50\n0,4.00\n1,6.00\n",
|
||||
wantErr: true,
|
||||
errText: "keyframe interval too large",
|
||||
},
|
||||
{
|
||||
name: "insufficient keyframes (<2)",
|
||||
csvData: "1,0.00\n",
|
||||
wantErr: true,
|
||||
errText: "insufficient keyframes",
|
||||
},
|
||||
{
|
||||
name: "empty output",
|
||||
csvData: "",
|
||||
wantErr: true,
|
||||
errText: "keyframe probe failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// create temp ffprobe that outputs csvData
|
||||
tmpDir := t.TempDir()
|
||||
mockProbe := filepath.Join(tmpDir, "ffprobe")
|
||||
var script string
|
||||
if tt.csvData == "" {
|
||||
script = "@echo off\necho\nexit /b 1\n"
|
||||
} else {
|
||||
script = "@echo off\necho " + strings.ReplaceAll(tt.csvData, "\n", "& echo ") + "\nexit /b 0\n"
|
||||
}
|
||||
os.WriteFile(mockProbe+".bat", []byte(script), 0755)
|
||||
os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
defer os.Unsetenv("PATH")
|
||||
|
||||
err := validateKeyframeInterval("test.mp4")
|
||||
if tt.wantErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if tt.wantErr && tt.errText != "" && err != nil {
|
||||
if !strings.Contains(err.Error(), tt.errText) {
|
||||
t.Errorf("error = %v, want containing %q", err, tt.errText)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVideoStrict(t *testing.T) {
|
||||
ensureFFProbeInPath(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
wantValid bool
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid h264 fixture",
|
||||
file: "valid_720p30.mp4",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "invalid h265 fixture",
|
||||
file: "invalid_h265.mp4",
|
||||
wantErr: "video codec must be h264",
|
||||
},
|
||||
{
|
||||
name: "invalid framerate fixture",
|
||||
file: "invalid_24fps.mp4",
|
||||
wantErr: "framerate must be 30/1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := filepath.Join("..", "testdata", tt.file)
|
||||
result := ValidateVideoStrict(path)
|
||||
|
||||
valid, _ := result["valid"].(bool)
|
||||
if valid != tt.wantValid {
|
||||
t.Fatalf("valid = %v, want %v", valid, tt.wantValid)
|
||||
}
|
||||
|
||||
errs, _ := result["errors"].([]string)
|
||||
if tt.wantErr == "" {
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("errors = %v, want none", errs)
|
||||
}
|
||||
if got, ok := result["duration_seconds"].(float64); !ok || got <= 0 {
|
||||
t.Fatalf("duration_seconds = %v, want positive duration", result["duration_seconds"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(strings.Join(errs, "; "), tt.wantErr) {
|
||||
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHandler(t *testing.T) {
|
||||
// Save and restore globals
|
||||
prevMode := playoutMode
|
||||
prevCount := restartCount
|
||||
playoutMode = "live"
|
||||
restartCount = 3
|
||||
defer func() {
|
||||
playoutMode = prevMode
|
||||
restartCount = prevCount
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
HealthHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"healthy"`) {
|
||||
t.Errorf("body missing healthy: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"playout"`) {
|
||||
t.Errorf("body missing service: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"restarts":3`) {
|
||||
t.Errorf("body missing restarts: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHandler_Degraded(t *testing.T) {
|
||||
prevMode := playoutMode
|
||||
playoutMode = "slate"
|
||||
defer func() { playoutMode = prevMode }()
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
HealthHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"degraded"`) {
|
||||
t.Errorf("body missing degraded: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldEscalateToSlate(t *testing.T) {
|
||||
// Save globals
|
||||
prevCount := restartCount
|
||||
prevWindow := restartWindow
|
||||
defer func() {
|
||||
restartCount = prevCount
|
||||
restartWindow = prevWindow
|
||||
}()
|
||||
|
||||
// Under threshold
|
||||
restartWindow = time.Now()
|
||||
restartCount = 3
|
||||
if shouldEscalateToSlate() {
|
||||
t.Error("shouldEscalateToSlate() = true, want false (under threshold)")
|
||||
}
|
||||
|
||||
// At threshold
|
||||
restartCount = maxRestartsPerMinute
|
||||
if !shouldEscalateToSlate() {
|
||||
t.Error("shouldEscalateToSlate() = false, want true (at threshold)")
|
||||
}
|
||||
|
||||
// Over threshold
|
||||
restartCount = 7
|
||||
if !shouldEscalateToSlate() {
|
||||
t.Error("shouldEscalateToSlate() = false, want true (over threshold)")
|
||||
}
|
||||
|
||||
// Window expired
|
||||
restartWindow = time.Now().Add(-61 * time.Second)
|
||||
restartCount = 10
|
||||
if shouldEscalateToSlate() {
|
||||
t.Error("shouldEscalateToSlate() = true, want false (window expired, should reset)")
|
||||
}
|
||||
if restartCount != 0 {
|
||||
t.Errorf("restartCount after window reset = %d, want 0", restartCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetHLSOutput(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create some segment files
|
||||
os.MkdirAll(tmpDir, 0755)
|
||||
os.WriteFile(filepath.Join(tmpDir, "segment_test.ts"), []byte("data"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "segment_test.m4s"), []byte("data"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "playlist.m3u8"), []byte("#EXTM3U"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "init.mp4"), []byte("init"), 0644)
|
||||
|
||||
err := resetHLSOutput(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("resetHLSOutput error: %v", err)
|
||||
}
|
||||
|
||||
// Verify all cleaned
|
||||
for _, name := range []string{"segment_test.ts", "segment_test.m4s", "playlist.m3u8", "init.mp4"} {
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("file %s still exists after reset", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetHLSOutput_EmptyDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
err := resetHLSOutput(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("resetHLSOutput on empty dir error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateSlate_NoSlate(t *testing.T) {
|
||||
prevSlateDir := slateDir
|
||||
prevHLSDir := hlsDir
|
||||
prevMode := playoutMode
|
||||
tmpDir := t.TempDir()
|
||||
slateDir = filepath.Join(tmpDir, "slate")
|
||||
hlsDir = filepath.Join(tmpDir, "hls")
|
||||
os.MkdirAll(hlsDir, 0755)
|
||||
defer func() {
|
||||
slateDir = prevSlateDir
|
||||
hlsDir = prevHLSDir
|
||||
playoutMode = prevMode
|
||||
}()
|
||||
|
||||
err := activateSlate()
|
||||
if err == nil {
|
||||
t.Error("expected error when slate HLS missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateSlate_Success(t *testing.T) {
|
||||
prevSlateDir := slateDir
|
||||
prevHLSDir := hlsDir
|
||||
prevMode := playoutMode
|
||||
tmpDir := t.TempDir()
|
||||
slateDir = filepath.Join(tmpDir, "slate")
|
||||
hlsDir = filepath.Join(tmpDir, "hls")
|
||||
|
||||
// Create slate HLS structure
|
||||
slateHLS := filepath.Join(slateDir, "hls")
|
||||
os.MkdirAll(slateHLS, 0755)
|
||||
os.MkdirAll(hlsDir, 0755)
|
||||
os.WriteFile(filepath.Join(slateHLS, "slate.m3u8"), []byte("#EXTM3U\n#EXT-X-ENDLIST\n"), 0644)
|
||||
os.WriteFile(filepath.Join(slateHLS, "slate_segment.ts"), []byte("slate-data"), 0644)
|
||||
|
||||
defer func() {
|
||||
slateDir = prevSlateDir
|
||||
hlsDir = prevHLSDir
|
||||
playoutMode = prevMode
|
||||
}()
|
||||
|
||||
err := activateSlate()
|
||||
if err != nil {
|
||||
t.Fatalf("activateSlate error: %v", err)
|
||||
}
|
||||
|
||||
if playoutMode != "slate" {
|
||||
t.Errorf("playoutMode = %q, want slate", playoutMode)
|
||||
}
|
||||
|
||||
// Verify files copied
|
||||
if _, err := os.Stat(filepath.Join(hlsDir, "slate.m3u8")); os.IsNotExist(err) {
|
||||
t.Error("slate.m3u8 not copied to hls dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeactivateSlate(t *testing.T) {
|
||||
prevMode := playoutMode
|
||||
playoutMode = "slate"
|
||||
defer func() { playoutMode = prevMode }()
|
||||
|
||||
deactivateSlate()
|
||||
|
||||
if playoutMode != "live" {
|
||||
t.Errorf("playoutMode = %q, want live", playoutMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePlaylist(t *testing.T) {
|
||||
// ValidatePlaylist calls ValidateVideo which needs ffprobe
|
||||
t.Skip("requires ffprobe mock or test media fixtures")
|
||||
}
|
||||
|
||||
func TestSyncToR2(t *testing.T) {
|
||||
prevClient := r2Client
|
||||
prevBucket := bucket
|
||||
prevPutObject := putObject
|
||||
defer func() {
|
||||
r2Client = prevClient
|
||||
bucket = prevBucket
|
||||
putObject = prevPutObject
|
||||
}()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
localPath := filepath.Join(tmpDir, "segment.m4s")
|
||||
if err := os.WriteFile(localPath, []byte("segment-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
bucket = "linearvoid-media"
|
||||
r2Client = &s3.Client{}
|
||||
var gotBucket, gotKey, gotCacheControl, gotContentType string
|
||||
var gotBody []byte
|
||||
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
gotBucket = aws.ToString(input.Bucket)
|
||||
gotKey = aws.ToString(input.Key)
|
||||
gotCacheControl = aws.ToString(input.CacheControl)
|
||||
gotContentType = aws.ToString(input.ContentType)
|
||||
body, err := io.ReadAll(input.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gotBody = body
|
||||
return &s3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
if err := SyncToR2(localPath, "live/segment.m4s"); err != nil {
|
||||
t.Fatalf("SyncToR2 error: %v", err)
|
||||
}
|
||||
|
||||
if gotBucket != "linearvoid-media" {
|
||||
t.Fatalf("bucket = %q, want %q", gotBucket, "linearvoid-media")
|
||||
}
|
||||
if gotKey != "live/segment.m4s" {
|
||||
t.Fatalf("key = %q, want %q", gotKey, "live/segment.m4s")
|
||||
}
|
||||
if string(gotBody) != "segment-bytes" {
|
||||
t.Fatalf("body = %q, want %q", string(gotBody), "segment-bytes")
|
||||
}
|
||||
if gotCacheControl != segmentCacheControl {
|
||||
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, segmentCacheControl)
|
||||
}
|
||||
if gotContentType != "video/MP2T" {
|
||||
t.Fatalf("ContentType = %q, want %q", gotContentType, "video/MP2T")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncToR2_ManifestCacheHeaders(t *testing.T) {
|
||||
prevClient := r2Client
|
||||
prevBucket := bucket
|
||||
prevPutObject := putObject
|
||||
defer func() {
|
||||
r2Client = prevClient
|
||||
bucket = prevBucket
|
||||
putObject = prevPutObject
|
||||
}()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
localPath := filepath.Join(tmpDir, "playlist.m3u8")
|
||||
if err := os.WriteFile(localPath, []byte("#EXTM3U\n"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
bucket = "linearvoid-media"
|
||||
r2Client = &s3.Client{}
|
||||
var gotCacheControl, gotContentType string
|
||||
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
gotCacheControl = aws.ToString(input.CacheControl)
|
||||
gotContentType = aws.ToString(input.ContentType)
|
||||
return &s3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
if err := SyncToR2(localPath, "hls/playlist.m3u8"); err != nil {
|
||||
t.Fatalf("SyncToR2 error: %v", err)
|
||||
}
|
||||
|
||||
if gotCacheControl != manifestCacheControl {
|
||||
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, manifestCacheControl)
|
||||
}
|
||||
if gotContentType != "application/vnd.apple.mpegurl" {
|
||||
t.Fatalf("ContentType = %q, want %q", gotContentType, "application/vnd.apple.mpegurl")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
|
||||
prevClient := r2Client
|
||||
prevBucket := bucket
|
||||
prevPutObject := putObject
|
||||
defer func() {
|
||||
r2Client = prevClient
|
||||
bucket = prevBucket
|
||||
putObject = prevPutObject
|
||||
}()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
localPath := filepath.Join(tmpDir, "data.bin")
|
||||
if err := os.WriteFile(localPath, []byte("raw"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
bucket = "linearvoid-media"
|
||||
r2Client = &s3.Client{}
|
||||
var gotCacheControl, gotContentType string
|
||||
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
gotCacheControl = aws.ToString(input.CacheControl)
|
||||
gotContentType = aws.ToString(input.ContentType)
|
||||
return &s3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
if err := SyncToR2(localPath, "misc/data.bin"); err != nil {
|
||||
t.Fatalf("SyncToR2 error: %v", err)
|
||||
}
|
||||
|
||||
if gotCacheControl != "" {
|
||||
t.Fatalf("CacheControl = %q, want empty", gotCacheControl)
|
||||
}
|
||||
if gotContentType != "" {
|
||||
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# Linear Void test playlist
|
||||
# No media entries here
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
# Valid entries should still parse
|
||||
file '/app/media/demo1.mp4'
|
||||
|
||||
# Invalid or malformed lines are preserved as literal paths by the parser
|
||||
not-a-file-entry
|
||||
file '/app/media/demo2.mp4'
|
||||
BIN
Binary file not shown.
+2
@@ -0,0 +1,2 @@
|
||||
file '/app/media/demo1.mp4'
|
||||
file '/app/media/demo2.mp4'
|
||||
File renamed without changes.
@@ -8,6 +8,7 @@
|
||||
- [Production Target (Hetzner)](#production-target-hetzner)
|
||||
- [Planned Infrastructure](#planned-infrastructure)
|
||||
- [Cutover Plan](#cutover-plan)
|
||||
- [Production Preflight](#production-preflight)
|
||||
- [Firewall / Security Group](#firewall--security-group)
|
||||
- [Pipeline Readiness](#pipeline-readiness)
|
||||
- [Target Topology](#target-topology)
|
||||
@@ -64,6 +65,19 @@ Linear Void runs in two separate environments:
|
||||
6. Enable HTTPS, WAF, cache rules on production zone
|
||||
7. Decommission test lab as public target
|
||||
|
||||
### Production Preflight
|
||||
|
||||
Before the cutover in the plan above, treat the following as the minimum readiness gate:
|
||||
|
||||
1. Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner.
|
||||
2. Confirm all required production secrets are present under the `CI_PROD_*` prefix.
|
||||
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv` DNS targets are correct for the intended phase.
|
||||
4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
||||
5. Run the production smoke path end to end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, and playout health.
|
||||
6. Verify rollback is still possible before the final DNS switch.
|
||||
|
||||
Readiness is not complete until the checklist above and the manual evidence tracker are both satisfied.
|
||||
|
||||
### Firewall / Security Group
|
||||
|
||||
| Port | Source | Purpose |
|
||||
@@ -135,7 +149,7 @@ See .env.example and SETUP.md for full variable reference.
|
||||
|
||||
## Pull-and-Run Deployment
|
||||
|
||||
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning-and-releases) for the release procedure).
|
||||
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](../CONTRIBUTING.md#versioning-and-releases) for the release procedure).
|
||||
|
||||
Every push to `main` triggers CI (builds `:main` and `:latest` tags) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow which builds `:X.Y.Z`, `:X.Y`, and `:latest` tags and creates a Gitea Release.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# DEVELOPMENT.md — Linear Void Remaining Tasks
|
||||
|
||||
## Test Fixtures (COMPLETE)
|
||||
|
||||
- [x] Create test media fixtures in `backend/playout/testdata/`:
|
||||
- `valid_720p30.mp4` — small (1-2s) H.264/AAC compliant file
|
||||
- `invalid_h265.mp4` — wrong codec
|
||||
- `invalid_24fps.mp4` — wrong framerate
|
||||
- Generate via `ffmpeg` commands documented in test file headers
|
||||
- Windows dev path: `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffmpeg.exe`
|
||||
|
||||
- [x] Create test playlist fixtures in `backend/playout/testdata/`:
|
||||
- `valid_playlist.txt`, `empty_playlist.txt`, `comments_only.txt`, `mixed_valid_invalid.txt`
|
||||
|
||||
- [x] Document ffmpeg fixture-generation commands in `playlist_test.go` header
|
||||
|
||||
## User Frontend Tests (PARTIALLY COMPLETE)
|
||||
|
||||
- [x] Video player behavior (play, pause, seek, buffering, error handling) - HLS player startup, native/fallback, URL resolution covered
|
||||
- [x] Chat functionality (send/receive messages, presence, rate limiting, moderation) - send, receive, presence join/leave, DM flow, reconnect covered
|
||||
- [ ] State management and data flow
|
||||
- [ ] Responsive design and layout across different screen sizes
|
||||
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
||||
- [x] Integration with backend APIs (mocking API responses for testing)
|
||||
- [ ] Test coverage for all critical frontend components and interactions
|
||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||
- [ ] Document test cases and expected outcomes
|
||||
|
||||
## Admin Frontend Tests (PARTIALLY COMPLETE)
|
||||
|
||||
- [x] Playlist management UI (add/remove videos, reorder, normalize) - save/error handling, sortable rendering
|
||||
- [ ] User moderation tools (mute/ban/report users, view moderation logs)
|
||||
- [x] Metrics and monitoring dashboards (Prometheus/Grafana integration) - overview + analytics rendering
|
||||
- [ ] State management and data flow
|
||||
- [ ] Responsive design and layout across different screen sizes
|
||||
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
||||
- [x] Integration with backend APIs (mocking API responses for testing)
|
||||
- [ ] Test coverage for all critical admin UI components and interactions
|
||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||
- [ ] Document test cases and expected outcomes
|
||||
|
||||
## Cloudflare R2 Integration Test (PARTIALLY COMPLETE)
|
||||
|
||||
- [x] Upload test HLS segments and manifests to R2 bucket (SyncToR2 with mock, verified bucket/key/body)
|
||||
- [x] Verify correct cache headers are applied (immutable segments via segmentCacheControl, short-lived manifests via manifestCacheControl)
|
||||
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
||||
- [ ] Validate cache hit/miss behavior and performance metrics
|
||||
- [x] Ensure integration test can run in CI/CD pipeline with proper mocking/stubbing of R2 API (putObject mock, no network calls)
|
||||
- [x] Document test cases and expected outcomes (TestSyncToR2 + TestSyncToR2_ManifestCacheHeaders + TestSyncToR2_NoCacheHeadersForUnknownExt)
|
||||
- [x] Document required stubs or mocks (putObject variable injection pattern)
|
||||
|
||||
## Cloudflare Configuration (manual — Cloudflare dashboard)
|
||||
|
||||
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||
|
||||
- [ ] Cloudflare Pages: link Gitea repo, build `cd frontend && npm run build`, output `frontend/dist`
|
||||
- [ ] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier
|
||||
- [ ] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode
|
||||
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||
- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2
|
||||
- [ ] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS
|
||||
|
||||
## External Services
|
||||
|
||||
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||
|
||||
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
||||
- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
||||
|
||||
## Production Deployment (Hetzner — not provisioned)
|
||||
|
||||
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04
|
||||
- [ ] Install Docker Engine + Compose plugin, open firewall: 80/443 (public), 2375 (runner IP only)
|
||||
- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs)
|
||||
- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories
|
||||
- [ ] Add `CI_PROD_*` secrets to Gitea: DOCKER_HOST, R2 keys, admin password, DB password, Grafana password
|
||||
- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP
|
||||
- [ ] Verify all 11 containers start healthy, check `/health`, `/hls/playlist.m3u8`, WebSocket
|
||||
- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict), test HTTPS + WSS
|
||||
- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
|
||||
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
|
||||
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
|
||||
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
|
||||
|
||||
## Scaling & Performance (planning only)
|
||||
|
||||
- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration
|
||||
- [ ] Measure TTFB: WebPageTest from 3+ US regions, target <4s warm cache, document baseline
|
||||
- [ ] Load-test WebSocket: `artillery` or Go script, ramp 10→100→500→1000 connections, measure p95 latency + errors (scaffolding complete; benchmark execution pending)
|
||||
- [ ] Benchmark admin API: `wrk`/`k6` against `/api/admin/*`, target p95 <1s, identify slow queries (scaffolding complete; benchmark execution pending)
|
||||
- [ ] Track cache hit ratio: Cloudflare GraphQL → Grafana panel, alert <90%
|
||||
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, chat p95 >1s/5min, CPU >80%/10min, Redis memory >85%/10min
|
||||
- [ ] Phase 2 plan: document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources
|
||||
- [ ] Phase 3 plan: research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
|
||||
File renamed without changes.
@@ -0,0 +1,41 @@
|
||||
# Docs Folder
|
||||
|
||||
This folder contains the documentation for the Linear Void project. The documentation is organized into several markdown files, each covering different aspects of the project.
|
||||
|
||||
## Architecture
|
||||
|
||||
- [Architecture](architecture/README.md) - High-level system architecture and component interactions.
|
||||
|
||||
## API
|
||||
|
||||
- [API](API.md) - Implemented WebSocket and REST API reference.
|
||||
|
||||
## Deployment
|
||||
|
||||
- [Deployment](DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||
|
||||
## Monitoring
|
||||
|
||||
- [Monitoring](MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||
|
||||
## Security
|
||||
|
||||
- [Security](SECURITY.md) - Security model, controls, and hardening baseline.
|
||||
|
||||
## Setup
|
||||
|
||||
- [Setup](SETUP.md) - Test lab setup, local development, and CI/CD workflow.
|
||||
|
||||
## Streaming
|
||||
|
||||
- [Streaming](STREAMING.md) - Streaming architecture, playout, and delivery details.
|
||||
|
||||
## Development
|
||||
|
||||
- [Development](DEVELOPMENT.md) - Remaining tasks and test fixtures for development.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Marketing](marketing/README.md) - Marketing materials and resources.
|
||||
- [Operations](operations/README.md) - Operational procedures and checklists.
|
||||
- [Incident Runbook](incident-runbook.md) - Incident response and disaster recovery procedures.
|
||||
File renamed without changes.
@@ -223,7 +223,7 @@ flowchart LR
|
||||
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
|
||||
|
||||
6. **Apply cache behavior**:
|
||||
- Configure the `.ts` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
||||
- Configure the `.m4s` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
||||
- Enable Smart Tiered Cache for the R2-backed hostname.
|
||||
- If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone.
|
||||
|
||||
@@ -235,6 +235,8 @@ flowchart LR
|
||||
|
||||
Use this section for full edge setup (DNS, Pages, cache, security, and monitoring).
|
||||
|
||||
Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
||||
|
||||
### 1. DNS Setup
|
||||
|
||||
1. Add apex record for `linearvoid.tv` pointing to Cloudflare Pages target.
|
||||
@@ -262,7 +264,12 @@ Configure Cloudflare Cache Rules:
|
||||
- Cache: eligible
|
||||
- Edge TTL: `2s`
|
||||
- Revalidation: enabled
|
||||
3. Validate behavior using response headers:
|
||||
3. Static assets (`*.js`, `*.css`):
|
||||
- Cache: eligible
|
||||
- Edge TTL: `1y`
|
||||
- Browser TTL: long-lived
|
||||
- Cache key: include full path
|
||||
4. Validate behavior using response headers:
|
||||
- `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
|
||||
|
||||
Reference: `infrastructure/cloudflare/cache-rules.md`.
|
||||
@@ -564,7 +571,7 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs
|
||||
- **R2 Upload Fails**: Check credentials in `.env` and ensure the bucket exists. Verify with `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
|
||||
- **Normalization Errors**: Run `backend/playout/start-playout.sh` manually to see ffprobe output. All videos must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001).
|
||||
- **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
|
||||
- **Cache Not Working**: Verify Cloudflare cache rules (`.ts` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
|
||||
- **Cache Not Working**: Verify Cloudflare cache rules (`.m4s` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
|
||||
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
|
||||
- **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow.
|
||||
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Streaming Pipeline
|
||||
|
||||
This document is the operational contract for Linear Void video ingest, playout, publication, caching, and browser playback. General deployment, chat, and admin details remain in `DEPLOYMENT.md`, `SETUP.md`, and `API.md`.
|
||||
|
||||
## Production flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Normalized MP4 assets"] --> B["Playlist and schedule"]
|
||||
B --> C["Playout service and FFmpeg"]
|
||||
C --> D["Local HLS staging"]
|
||||
D --> E["R2 object storage"]
|
||||
E --> F["Cloudflare edge"]
|
||||
F --> G["hls.js or native HLS"]
|
||||
```
|
||||
|
||||
The playout service is the timeline authority. It reads the ordered playlist, runs FFmpeg continuously, stages a live HLS window under `data/hls/`, and publishes complete objects to R2. Viewers read only the public R2 custom domain through Cloudflare:
|
||||
|
||||
```text
|
||||
https://media.linearvoid.tv/hls/playlist.m3u8
|
||||
```
|
||||
|
||||
Nginx `/hls/` is a local/test fallback, not the production media origin. The SPA, admin API, and chat use `linearvoid.tv`; media uses `media.linearvoid.tv`.
|
||||
|
||||
## Source-media contract
|
||||
|
||||
Only validated files may enter the production playlist.
|
||||
|
||||
| Property | Required profile |
|
||||
| ---------- | ------------------------------------------------------------------------ |
|
||||
| Container | MP4 |
|
||||
| Video | H.264/AVC, `yuv420p` |
|
||||
| Resolution | 1280x720 or 1920x1080; avoid changing resolution within a playlist block |
|
||||
| Frame rate | Constant 30 fps |
|
||||
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
|
||||
| Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s |
|
||||
|
||||
The ingest gate uses `ffprobe` to reject incompatible codecs, variable frame rate, missing audio, invalid dimensions, or incorrect keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID and object key.
|
||||
|
||||
Reference normalization:
|
||||
|
||||
```bash
|
||||
ffmpeg -i input.mov \
|
||||
-c:v libx264 -preset medium -crf 21 \
|
||||
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
||||
-pix_fmt yuv420p \
|
||||
-c:a aac -b:a 160k -ar 48000 -ac 2 \
|
||||
-movflags +faststart \
|
||||
output-normalized.mp4
|
||||
```
|
||||
|
||||
## Playlist and timeline
|
||||
|
||||
- `data/playlist/playlist.txt` contains one local media path per line; blank lines and `#` comments are ignored.
|
||||
- The service validates the complete playlist before activating a revision.
|
||||
- Playlist edits are written atomically and receive a revision ID. The active FFmpeg run keeps its current item and adopts the new revision at the next item boundary.
|
||||
- All viewers follow the same live manifest; clients do not calculate the schedule independently.
|
||||
- Schedule metadata must be derived from the same active playlist revision and timeline so “now/next” agrees with the video.
|
||||
- When the last item ends, playout returns to the first item unless an active schedule override says otherwise.
|
||||
|
||||
## Playout and HLS packaging
|
||||
|
||||
The playout service wraps FFmpeg, tracks the active item and playlist revision, restarts failed processes, and exposes `/health` plus Prometheus metrics.
|
||||
|
||||
Baseline packaging:
|
||||
|
||||
| Setting | Value |
|
||||
| ---------------- | ------------------------------------------------------------------------------------- |
|
||||
| Protocol | Standard live HLS, not LL-HLS |
|
||||
| Segment format | fMP4 (`.m4s`) with an initialization segment; `.ts` may be supported during migration |
|
||||
| Segment target | 6 seconds (`HLS_SEGMENT_DURATION=6`) |
|
||||
| Live window | 24 segments (`HLS_WINDOW_SIZE=24`), about 144 seconds |
|
||||
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
||||
| Expected latency | 15–35 seconds |
|
||||
|
||||
Segment boundaries must align with keyframes. Timestamps must remain monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new initialization segment.
|
||||
|
||||
Every segment name is globally unique and never reused, including after a restart or playlist loop. Use a run/session ID plus a monotonically increasing media sequence, for example:
|
||||
|
||||
```text
|
||||
hls/r-20260807T170412Z/init-000001.mp4
|
||||
hls/r-20260807T170412Z/seg-000001234.m4s
|
||||
```
|
||||
|
||||
This naming rule is essential: a URL advertised as immutable must never later contain different bytes.
|
||||
|
||||
## Safe publication to R2
|
||||
|
||||
Publication order prevents a manifest from referencing an unavailable segment:
|
||||
|
||||
1. FFmpeg writes a segment to a temporary local name and closes it.
|
||||
2. The publisher uploads the initialization segment, if new, and media segment to their final immutable R2 keys.
|
||||
3. It verifies successful upload and records object size/checksum.
|
||||
4. It renders the next media manifest referencing only confirmed objects.
|
||||
5. It uploads the manifest to a temporary key, then replaces `hls/playlist.m3u8` as the final commit step.
|
||||
|
||||
Never upload an actively written segment or publish the manifest first. Use a single publisher instance, or fencing/leader election, so two playout workers cannot update the live manifest concurrently.
|
||||
|
||||
The local `data/hls/` directory is staging data and may be regenerated. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
|
||||
|
||||
## Cache contract
|
||||
|
||||
| Objects | Edge policy | Browser policy | Required headers/behavior |
|
||||
| -------------------------- | ---------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `.m4s`, `.ts`, init `.mp4` | Cache eligible; TTL 1 year | TTL 1 year | `Cache-Control: public, max-age=31536000, immutable`; full path in cache key |
|
||||
| Live `.m3u8` | Cache eligible; edge TTL 2 seconds | Do not retain beyond freshness interval | `Cache-Control: public, max-age=1, s-maxage=2, must-revalidate` |
|
||||
| Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
|
||||
|
||||
Further rules:
|
||||
|
||||
- Do not add query-string cache busters to segment URLs; uniqueness belongs in the path.
|
||||
- Cache keys must preserve hostname and full path. Ignore only explicitly approved tracking parameters.
|
||||
- HLS responses require `Access-Control-Allow-Origin: https://linearvoid.tv` (or the reviewed origin allowlist), `GET`, `HEAD`, and `OPTIONS` support, and correct MIME types.
|
||||
- Enable Smart Tiered Cache for `media.linearvoid.tv` to reduce R2 reads on regional misses.
|
||||
- Purge manifests when necessary, but never purge immutable segments during normal operation.
|
||||
- Retain expired-window segments in R2 for at least the maximum manifest TTL plus client retry/grace time. A practical minimum is one hour; lifecycle deletion may run later according to storage policy.
|
||||
|
||||
Expected validation:
|
||||
|
||||
```bash
|
||||
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
||||
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
|
||||
```
|
||||
|
||||
The manifest must refresh within a few seconds. Repeated segment requests should progress from `CF-Cache-Status: MISS` to `HIT`, retain the same ETag/content length, and carry the immutable cache header.
|
||||
|
||||
## Player behavior
|
||||
|
||||
The SPA uses native HLS where supported and hls.js elsewhere.
|
||||
|
||||
- Start at the live edge with a conservative buffer; do not seek to playlist start.
|
||||
- Treat the manifest as the synchronization source and target roughly three segments behind the newest segment unless testing supports a smaller margin.
|
||||
- Recover transient network/media errors with bounded retries and exponential backoff.
|
||||
- On a fatal hls.js network error, reload the manifest; on a fatal media error, attempt media recovery once before recreating the player.
|
||||
- When tab suspension or drift leaves playback materially behind the target live position, seek forward to the target rather than playing the backlog.
|
||||
- Surface a clear reconnecting state. Never silently loop stale media.
|
||||
- Schedule/chat timestamps should account for the measured player latency rather than assuming wall-clock playout is already visible.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
The service retries an item failure with a bounded backoff. After the configured failure threshold it switches to a pre-normalized looping slate and reports `mode=slate`. It periodically retries normal playout and returns only after a segment and committed manifest have been published successfully.
|
||||
|
||||
On process or host restart:
|
||||
|
||||
- create a new run/session ID and continue with globally unique segment paths;
|
||||
- resume from the schedule-derived current position when reliable state exists, otherwise start the current playlist item;
|
||||
- never overwrite old segment objects;
|
||||
- keep the last valid manifest available until the replacement stream is playable;
|
||||
- emit a discontinuity when timeline continuity cannot be guaranteed.
|
||||
|
||||
Health is not merely “process running.” `/health` should report at least mode (`live`, `slate`, `starting`, `degraded`), active item, playlist revision, run ID, last segment sequence/time, last successful R2 publication, consecutive failures, and uptime. Mark the service unhealthy if the manifest or media sequence has not advanced for more than two segment durations.
|
||||
|
||||
## Monitoring and acceptance checks
|
||||
|
||||
Monitor and alert on:
|
||||
|
||||
- manifest age and media-sequence advancement;
|
||||
- time since last successfully published segment;
|
||||
- FFmpeg restarts and consecutive item failures;
|
||||
- live/slate mode;
|
||||
- R2 upload latency and errors;
|
||||
- segment 404/5xx rate and manifest/segment request latency;
|
||||
- Cloudflare cache-hit ratio, origin requests, and R2 Class B operations;
|
||||
- player startup time, fatal errors, rebuffer ratio, and live-edge drift;
|
||||
- local staging disk usage.
|
||||
|
||||
A release is streaming-ready only when one full playlist boundary has played without timestamp errors, the manifest never references a missing object, a second viewer joins the same timeline, cache headers match the table above, a forced FFmpeg failure activates the slate, and normal playout recovers without reusing an object URL.
|
||||
|
||||
## Required configuration
|
||||
|
||||
| Variable | Purpose |
|
||||
| ------------------------------------------ | -------------------------------------------------- |
|
||||
| `PLAYLIST_PATH` | Active playlist file |
|
||||
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `6` |
|
||||
| `HLS_WINDOW_SIZE` | Manifest window; baseline `24` |
|
||||
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
||||
| `R2_BUCKET_NAME` | Media bucket |
|
||||
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, available only to the publisher |
|
||||
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
||||
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
||||
|
||||
Production values are `MEDIA_DOMAIN=media.linearvoid.tv` and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. Local fallback may use `http://localhost/hls/playlist.m3u8`.
|
||||
|
||||
## Explicitly deferred
|
||||
|
||||
Adaptive-bitrate ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any of them requires an ADR and an update to this document.
|
||||
+33
-31
@@ -87,10 +87,12 @@
|
||||
|
||||
## Companion Documentation
|
||||
|
||||
- [API.md](../../API.md) - Implemented WebSocket and REST API reference.
|
||||
- [DEPLOYMENT.md](../../DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||
- [MONITORING.md](../../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||
- [SECURITY.md](../../SECURITY.md) - Security model, controls, and hardening baseline.
|
||||
- [API.md](../API.md) - Implemented WebSocket and REST API reference.
|
||||
- [DEPLOYMENT.md](../DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||
- [MONITORING.md](../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||
- [SECURITY.md](../SECURITY.md) - Security model, controls, and hardening baseline.
|
||||
- [SETUP.md](../SETUP.md) - Test lab setup, local development, and CI/CD workflow.
|
||||
- [STREAMING.md](../STREAMING.md) - Streaming architecture, playout, and delivery details.
|
||||
|
||||
## Introduction and Goals
|
||||
|
||||
@@ -113,14 +115,14 @@ Full decision rationale, alternatives, and consequences in [`adr/ADR-001.md`](ad
|
||||
|
||||
### Quality Goals
|
||||
|
||||
| Priority | Quality Attribute | Goal | Scenario |
|
||||
| -------- | ------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| 1 | **Cost Efficiency** | <$25/month for moderate scale (100s concurrent) | R2 $0 egress + immutable `.ts` edge caching eliminates per-viewer bandwidth cost |
|
||||
| 2 | **Performance / Latency** | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) |
|
||||
| 3 | **Availability** | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart loop, fallback slate, monitoring |
|
||||
| 4 | **Scalability** | Grow from 10s to 1000s viewers without proportional cost | Edge caching + R2; only request volume limits |
|
||||
| 5 | **Maintainability** | Lightweight, clear configs, low ops overhead | Static SPA, Go/Node chat, SQLite/PostgreSQL+Redis |
|
||||
| 6 | **Security / Usability** | Secure transport, moderation tools, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player |
|
||||
| Priority | Quality Attribute | Goal | Scenario |
|
||||
| -------- | ------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| 1 | **Cost Efficiency** | <$25/month for moderate scale (100s concurrent) | R2 $0 egress + immutable `.m4s` and `.ts` edge caching eliminates per-viewer bandwidth cost |
|
||||
| 2 | **Performance / Latency** | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) |
|
||||
| 3 | **Availability** | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart loop, fallback slate, monitoring |
|
||||
| 4 | **Scalability** | Grow from 10s to 1000s viewers without proportional cost | Edge caching + R2; only request volume limits |
|
||||
| 5 | **Maintainability** | Lightweight, clear configs, low ops overhead | Static SPA, Go/Node chat, SQLite/PostgreSQL+Redis |
|
||||
| 6 | **Security / Usability** | Secure transport, moderation tools, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player |
|
||||
|
||||
### Stakeholders
|
||||
|
||||
@@ -186,7 +188,7 @@ flowchart TD
|
||||
|
||||
**External Interfaces**:
|
||||
|
||||
- **Cloudflare**: CDN (cache rules for `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Pages (static hosting), WAF/TLS.
|
||||
- **Cloudflare**: CDN (cache rules for `.m4s` and `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Pages (static hosting), WAF/TLS.
|
||||
- **Hetzner VPS**: Compute for playout and chat (low-cost US-East with good peering).
|
||||
- **Browsers**: Native HLS (`<video>` + hls.js) and WebSocket APIs.
|
||||
- **Source**: Pre-normalized MP4 files uploaded to R2.
|
||||
@@ -196,7 +198,7 @@ This diagram synthesizes the context views from all original ADRs. Full decision
|
||||
### Technical Context
|
||||
|
||||
- **Playout**: FFmpeg consuming normalized playlist → continuous HLS.
|
||||
- **Packaging/Delivery**: MediaMTX/SRS/Nginx → immutable `.ts` segments to R2; Cloudflare edge cache.
|
||||
- **Packaging/Delivery**: MediaMTX/SRS/Nginx → immutable `.m4s` and `.ts` segments to R2; Cloudflare edge cache.
|
||||
- **Frontend**: Static SPA with hls.js.
|
||||
- **Chat**: Lightweight WebSocket service (Node/Go) + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation).
|
||||
- **Normalization**: All source videos must be H.264/AAC, 30 fps constant, 2s keyframes.
|
||||
@@ -212,7 +214,7 @@ This diagram synthesizes the context views from all original ADRs. Full decision
|
||||
|
||||
1. **US-East Playout on Hetzner Ashburn VPS** (`CX22` or equivalent): FFmpeg + MediaMTX/SRS for continuous playlist-to-HLS conversion (`-c copy` where possible).
|
||||
2. **Immutable HLS to Cloudflare R2**: Segments use unique names; R2 acts as origin with $0 egress.
|
||||
3. **Aggressive Edge Caching**: Long TTL for `.ts` (immutable), short TTL (1–10s) for live `.m3u8`. Tiered Cache enabled. This makes viewer delivery essentially free.
|
||||
3. **Aggressive Edge Caching**: Long TTL for `.m4s` and `.ts` (immutable), short TTL (1–10s) for live `.m3u8`. Tiered Cache enabled. This makes viewer delivery essentially free.
|
||||
4. **Lightweight Custom Frontend**: Static HTML/JS SPA + hls.js player + embedded chat sidebar.
|
||||
5. **Separate but co-located Chat Service**: WebSockets with persistence and moderation (low bandwidth).
|
||||
6. **Normalization & Monitoring**: Pre-process all videos; use dashboards + alerts for playout health, cache hit ratio, R2 ops.
|
||||
@@ -230,7 +232,7 @@ Overview (Mermaid diagram):
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Cloudflare
|
||||
CDN[CDN + Cache Rules<br/>.ts immutable long TTL<br/>.m3u8 short TTL]
|
||||
CDN[CDN + Cache Rules<br/>.m4s and .ts immutable long TTL<br/>.m3u8 short TTL]
|
||||
R2[R2 - HLS + Source]
|
||||
Pages[Pages - Static SPA]
|
||||
end
|
||||
@@ -306,7 +308,7 @@ graph TB
|
||||
|
||||
- **Role**: Continuous HLS encoding loop using FFmpeg concat demuxer
|
||||
- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths
|
||||
- **Output**: HLS segments (`.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
|
||||
- **Output**: HLS segments (`.m4s` and `.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
|
||||
- **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081
|
||||
- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval)
|
||||
|
||||
@@ -383,7 +385,7 @@ Module-level breakdown per service.
|
||||
2. Playout worker (FFmpeg loop with concat demuxer) reads next file, packages as HLS segments (4-6s).
|
||||
3. Segments uploaded to R2 with unique immutable names; live `.m3u8` updated.
|
||||
4. Viewer loads SPA → requests master/live manifest → hls.js player follows current edge.
|
||||
5. Cloudflare serves `.ts` from nearest edge cache (origin/R2 hit only once per POP/region).
|
||||
5. Cloudflare serves `.m4s` and `.ts` from nearest edge cache (origin/R2 hit only once per POP/region).
|
||||
6. Parallel WebSocket connection to chat service for synchronized interaction.
|
||||
|
||||
Notable aspects: Manifest freshness ensures all viewers stay on same timeline; cache hit ratio directly impacts cost and latency. Fallback slate activates on playout failure. Full cache policy, example configs (FFmpeg bash loop, Nginx RTMP HLS), and storage tables in `adr/ADR-001.md`.
|
||||
@@ -469,7 +471,7 @@ flowchart LR
|
||||
**Cloudflare Layer**:
|
||||
|
||||
- R2 bucket for media.
|
||||
- Cache Rules (`.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate).
|
||||
- Cache Rules (`.m4s` and `.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate).
|
||||
- WAF, rate limiting, Tiered Cache.
|
||||
- Pages for SPA hosting.
|
||||
|
||||
@@ -479,7 +481,7 @@ Future evolution: Docker Compose, managed DB, secondary regions (detailed in sub
|
||||
|
||||
### Caching & Cost Optimization
|
||||
|
||||
Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = near-zero viewer bandwidth cost. Long TTL on `.ts`, short on live manifests. Monitor hit ratio and R2 Class B ops.
|
||||
Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = near-zero viewer bandwidth cost. Long TTL on `.m4s` and `.ts`, short on live manifests. Monitor hit ratio and R2 Class B ops.
|
||||
|
||||
### Video Normalization & Playout
|
||||
|
||||
@@ -888,16 +890,16 @@ Document and rehearse this runbook quarterly; recovery that is not exercised is
|
||||
|
||||
## Glossary
|
||||
|
||||
| Term | Definition |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------ |
|
||||
| HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (`.ts` or fMP4). |
|
||||
| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
|
||||
| R2 | Cloudflare object storage ($0 egress, ideal origin for cached HLS). |
|
||||
| Edge Cache / POP | Cloudflare points-of-presence that cache and serve content close to viewers. |
|
||||
| Playout | Continuous generation of a scheduled linear program from a playlist. |
|
||||
| Normalization | Pre-processing videos to consistent codec/fps/keyframe settings for seamless playback. |
|
||||
| Cache Rule | Cloudflare configuration defining caching behavior, TTL, and headers per URL pattern. |
|
||||
| SPOF | Single Point of Failure (current single VPS origin). |
|
||||
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
||||
| Term | Definition |
|
||||
| ----------------- | -------------------------------------------------------------------------------------- |
|
||||
| HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (fMP4). |
|
||||
| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
|
||||
| R2 | Cloudflare object storage ($0 egress, ideal origin for cached HLS). |
|
||||
| Edge Cache / POP | Cloudflare points-of-presence that cache and serve content close to viewers. |
|
||||
| Playout | Continuous generation of a scheduled linear program from a playlist. |
|
||||
| Normalization | Pre-processing videos to consistent codec/fps/keyframe settings for seamless playback. |
|
||||
| Cache Rule | Cloudflare configuration defining caching behavior, TTL, and headers per URL pattern. |
|
||||
| SPOF | Single Point of Failure (current single VPS origin). |
|
||||
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
||||
|
||||
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted.
|
||||
@@ -19,11 +19,11 @@ We require a completely independent 24/7 linear video channel that broadcasts a
|
||||
**Key distinctions**:
|
||||
|
||||
- **Playout**: Continuous scheduled program from playlist.
|
||||
- **Packaging**: FFmpeg → standard HLS (`.m3u8` + `.ts` segments, 4–6s duration).
|
||||
- **Packaging**: FFmpeg → standard HLS (`.m3u8` + `.m4s` segments, 4–6s duration).
|
||||
- **Delivery**: Immutable segments served via Cloudflare edge caching + R2 (zero egress cost).
|
||||
- **Interaction**: Separate lightweight WebSocket service for chat.
|
||||
|
||||
Bandwidth (not storage or compute) is the dominant cost driver. A single viewer at ~4 Mbps consumes ~1.3–1.8 TB/month; 100 concurrent viewers = ~130–180 TB/month. Direct VPS delivery is only viable for PoC/small audiences.
|
||||
Bandwidth (not storage or compute) is the dominant cost driver. A single viewer at ~4 Mbps consumes ~1.3–1.8 TB/month; 100 concurrent viewers = ~130–180 TB/month. Direct VPS delivery is only viable for PoC/small audiences. HLS segments are typically `.m4s` files, with `.m4s` being the default for fMP4 segments.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
@@ -31,7 +31,7 @@ Bandwidth (not storage or compute) is the dominant cost driver. A single viewer
|
||||
2. Full independence and ownership (no video platform lock-in).
|
||||
3. Strong US performance and low latency.
|
||||
4. True/near-true synchronization via live-style HLS (not pure VOD).
|
||||
5. Aggressive immutable caching of `.ts` segments to achieve near-zero egress.
|
||||
5. Aggressive immutable caching of `.m4s` segments to achieve near-zero egress.
|
||||
6. Browser-native playback (`hls.js` + native HLS).
|
||||
7. Lightweight, maintainable stack with modest operational complexity.
|
||||
8. Support for custom multi-room + private chat with persistence and moderation.
|
||||
@@ -58,9 +58,9 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
||||
|
||||
### 2. Storage & Delivery (Cloudflare R2 + CDN)
|
||||
|
||||
- HLS segments (`.ts`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`).
|
||||
- Custom domain (`media.example.com`) fronted by Cloudflare with **Cache Rules**:
|
||||
- `*.ts` (and init `.mp4` if using fMP4 later): `Cache Everything`, immutable, Edge TTL 24h–1yr (`Cache-Control: public, max-age=31536000, immutable`).
|
||||
- HLS segments (`.m4s`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`).
|
||||
- Custom domain (`media.linearvoid.tv`) fronted by Cloudflare with **Cache Rules**:
|
||||
- `*.m4s` (and init `.mp4` if using fMP4 later): `Cache Everything`, immutable, Edge TTL 24h–1yr (`Cache-Control: public, max-age=31536000, immutable`).
|
||||
- `*.m3u8`: Short TTL (1–10s) or `must-revalidate` (`Cache-Control: public, max-age=1..10, must-revalidate`).
|
||||
- Master playlist: TTL ~30s.
|
||||
- Tiered Cache enabled where possible.
|
||||
@@ -90,7 +90,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
||||
|
||||
**Initial Targets**:
|
||||
|
||||
- Protocol: Standard HLS (`.ts` segments). Defer LL-HLS/CMAF/WebRTC.
|
||||
- Protocol: Standard HLS (`.m4s` segments). Defer LL-HLS/CMAF/WebRTC.
|
||||
- Max rendition: 720p30 initially (upgrade to 1080p30 or ABR later).
|
||||
- Latency: 15–35s (acceptable for linear programming/chat).
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ We must explicitly document language/runtime choices per module and the assumpti
|
||||
|
||||
1. All video assets **must** be normalized before being added to any playlist (H.264, AAC, 30 fps constant, 2s keyframes, CRF encoding).
|
||||
2. Deployment is **Docker-first**; services will be containerized.
|
||||
3. Cloudflare is the primary CDN and will be configured with specific Cache Rules (`.ts` immutable long TTL, `.m3u8` short TTL).
|
||||
3. Cloudflare is the primary CDN and will be configured with specific [Cache Rules](../../../infrastructure/cloudflare/cache-rules.md).
|
||||
4. Chat does **not** require end-to-end encryption in v1 (separate ADR if needed).
|
||||
5. Single VPS origin initially; multi-region is a later evolution.
|
||||
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
||||
|
||||
@@ -24,13 +24,13 @@ contributors. See <https://arc42.org>.
|
||||
## Stakeholders {#_stakeholders}
|
||||
|
||||
+-------------+---------------------------+---------------------------+
|
||||
| Role/Name | Contact | Expectations |
|
||||
| Role/Name | Contact | Expectations |
|
||||
+=============+===========================+===========================+
|
||||
| * | *\<Contact-1\>* | *\<Expectation-1\>* |
|
||||
| \<Role-1\>* | | |
|
||||
| * | _\<Contact-1\>_ | _\<Expectation-1\>_ |
|
||||
| \<Role-1\>* | | |
|
||||
+-------------+---------------------------+---------------------------+
|
||||
| * | *\<Contact-2\>* | *\<Expectation-2\>* |
|
||||
| \<Role-2\>* | | |
|
||||
| * | _\<Contact-2\>_ | _\<Expectation-2\>_ |
|
||||
| \<Role-2\>* | | |
|
||||
+-------------+---------------------------+---------------------------+
|
||||
|
||||
# Architecture Constraints {#section-architecture-constraints}
|
||||
@@ -57,41 +57,41 @@ contributors. See <https://arc42.org>.
|
||||
|
||||
## Whitebox Overall System {#_whitebox_overall_system}
|
||||
|
||||
***\<Overview Diagram\>***
|
||||
_**\<Overview Diagram\>**_
|
||||
|
||||
Motivation
|
||||
|
||||
: *\<text explanation\>*
|
||||
: _\<text explanation\>_
|
||||
|
||||
Contained Building Blocks
|
||||
|
||||
: *\<Description of contained building block (black boxes)\>*
|
||||
: _\<Description of contained building block (black boxes)\>_
|
||||
|
||||
Important Interfaces
|
||||
|
||||
: *\<Description of important interfaces\>*
|
||||
: _\<Description of important interfaces\>_
|
||||
|
||||
### \<Name black box 1\> {#_name_black_box_1}
|
||||
|
||||
*\<Purpose/Responsibility\>*
|
||||
_\<Purpose/Responsibility\>_
|
||||
|
||||
*\<Interface(s)\>*
|
||||
_\<Interface(s)\>_
|
||||
|
||||
*\<(Optional) Quality/Performance Characteristics\>*
|
||||
_\<(Optional) Quality/Performance Characteristics\>_
|
||||
|
||||
*\<(Optional) Directory/File Location\>*
|
||||
_\<(Optional) Directory/File Location\>_
|
||||
|
||||
*\<(Optional) Fulfilled Requirements\>*
|
||||
_\<(Optional) Fulfilled Requirements\>_
|
||||
|
||||
*\<(optional) Open Issues/Problems/Risks\>*
|
||||
_\<(optional) Open Issues/Problems/Risks\>_
|
||||
|
||||
### \<Name black box 2\> {#_name_black_box_2}
|
||||
|
||||
*\<black box template\>*
|
||||
_\<black box template\>_
|
||||
|
||||
### \<Name black box n\> {#_name_black_box_n}
|
||||
|
||||
*\<black box template\>*
|
||||
_\<black box template\>_
|
||||
|
||||
### \<Name interface 1\> {#_name_interface_1}
|
||||
|
||||
@@ -101,42 +101,42 @@ Important Interfaces
|
||||
|
||||
## Level 2 {#_level_2}
|
||||
|
||||
### White Box *\<building block 1\>* {#_white_box_building_block_1}
|
||||
### White Box _\<building block 1\>_ {#_white_box_building_block_1}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
### White Box *\<building block 2\>* {#_white_box_building_block_2}
|
||||
### White Box _\<building block 2\>_ {#_white_box_building_block_2}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
...
|
||||
|
||||
### White Box *\<building block m\>* {#_white_box_building_block_m}
|
||||
### White Box _\<building block m\>_ {#_white_box_building_block_m}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
## Level 3 {#_level_3}
|
||||
|
||||
### White Box \<\_building block x.1\_\> {#_white_box_building_block_x_1}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
### White Box \<\_building block x.2\_\> {#_white_box_building_block_x_2}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
### White Box \<\_building block y.1\_\> {#_white_box_building_block_y_1}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
# Runtime View {#section-runtime-view}
|
||||
|
||||
## \<Runtime Scenario 1\> {#_runtime_scenario_1}
|
||||
|
||||
- *\<insert runtime diagram or textual description of the scenario\>*
|
||||
- _\<insert runtime diagram or textual description of the scenario\>_
|
||||
|
||||
- *\<insert description of the notable aspects of the interactions
|
||||
between the building block instances depicted in this diagram.\>*
|
||||
- _\<insert description of the notable aspects of the interactions
|
||||
between the building block instances depicted in this diagram.\>_
|
||||
|
||||
## \<Runtime Scenario 2\> {#_runtime_scenario_2}
|
||||
|
||||
@@ -148,51 +148,51 @@ Important Interfaces
|
||||
|
||||
## Infrastructure Level 1 {#_infrastructure_level_1}
|
||||
|
||||
***\<Overview Diagram\>***
|
||||
_**\<Overview Diagram\>**_
|
||||
|
||||
Motivation
|
||||
|
||||
: *\<explanation in text form\>*
|
||||
: _\<explanation in text form\>_
|
||||
|
||||
Quality and/or Performance Features
|
||||
|
||||
: *\<explanation in text form\>*
|
||||
: _\<explanation in text form\>_
|
||||
|
||||
Mapping of Building Blocks to Infrastructure
|
||||
|
||||
: *\<description of the mapping\>*
|
||||
: _\<description of the mapping\>_
|
||||
|
||||
## Infrastructure Level 2 {#_infrastructure_level_2}
|
||||
|
||||
### *\<Infrastructure Element 1\>* {#_infrastructure_element_1}
|
||||
### _\<Infrastructure Element 1\>_ {#_infrastructure_element_1}
|
||||
|
||||
*\<diagram + explanation\>*
|
||||
_\<diagram + explanation\>_
|
||||
|
||||
### *\<Infrastructure Element 2\>* {#_infrastructure_element_2}
|
||||
### _\<Infrastructure Element 2\>_ {#_infrastructure_element_2}
|
||||
|
||||
*\<diagram + explanation\>*
|
||||
_\<diagram + explanation\>_
|
||||
|
||||
...
|
||||
|
||||
### *\<Infrastructure Element n\>* {#_infrastructure_element_n}
|
||||
### _\<Infrastructure Element n\>_ {#_infrastructure_element_n}
|
||||
|
||||
*\<diagram + explanation\>*
|
||||
_\<diagram + explanation\>_
|
||||
|
||||
# Cross-cutting Concepts {#section-concepts}
|
||||
|
||||
## *\<Concept 1\>* {#_concept_1}
|
||||
## _\<Concept 1\>_ {#_concept_1}
|
||||
|
||||
*\<explanation\>*
|
||||
_\<explanation\>_
|
||||
|
||||
## *\<Concept 2\>* {#_concept_2}
|
||||
## _\<Concept 2\>_ {#_concept_2}
|
||||
|
||||
*\<explanation\>*
|
||||
_\<explanation\>_
|
||||
|
||||
...
|
||||
|
||||
## *\<Concept n\>* {#_concept_n}
|
||||
## _\<Concept n\>_ {#_concept_n}
|
||||
|
||||
*\<explanation\>*
|
||||
_\<explanation\>_
|
||||
|
||||
# Architecture Decisions {#section-design-decisions}
|
||||
|
||||
@@ -207,9 +207,9 @@ Mapping of Building Blocks to Infrastructure
|
||||
# Glossary {#section-glossary}
|
||||
|
||||
+----------------------+-----------------------------------------------+
|
||||
| Term | Definition |
|
||||
| Term | Definition |
|
||||
+======================+===============================================+
|
||||
| *\<Term-1\>* | *\<definition-1\>* |
|
||||
| _\<Term-1\>_ | _\<definition-1\>_ |
|
||||
+----------------------+-----------------------------------------------+
|
||||
| *\<Term-2\>* | *\<definition-2\>* |
|
||||
| _\<Term-2\>_ | _\<definition-2\>_ |
|
||||
+----------------------+-----------------------------------------------+
|
||||
@@ -161,4 +161,69 @@ docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
|
||||
3. Add prevention to docs if actionable
|
||||
4. Update this runbook if new failure mode discovered
|
||||
|
||||
**Recovery targets:** RPO ≤24h, RTO ≤60min. Quarterly DR drill: simulate host loss, restore from backup.
|
||||
## Quarterly Disaster Recovery Drill
|
||||
|
||||
Goal: validate recovery targets under controlled host-loss simulation.
|
||||
|
||||
**Recovery targets:** RPO <= 24h, RTO <= 60min.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Latest backup artifacts available from `scripts/backup-db.sh` and `scripts/backup-weekly.sh`
|
||||
- Operator access to Docker host and DNS controls
|
||||
- Current deployment variables available (`.env`, compose file, image tags)
|
||||
|
||||
### Drill Scope
|
||||
|
||||
- Simulate complete origin loss (all app containers unavailable)
|
||||
- Rebuild stack on clean host
|
||||
- Restore DB and media/playlist mounts
|
||||
- Verify viewer playback, chat, admin auth, monitoring targets
|
||||
|
||||
### Drill Steps
|
||||
|
||||
1. Record drill start timestamp and on-call participants.
|
||||
2. Capture pre-drill baseline:
|
||||
- `curl -fsS http://<origin>/health`
|
||||
- `curl -fsS http://<origin>:3000/health`
|
||||
- `curl -fsS http://<origin>:8081/health`
|
||||
3. Simulate origin outage:
|
||||
- `docker compose -f docker-compose.prod.yml down`
|
||||
4. Provision clean recovery host (or clean test-lab VM) and copy deployment bundle.
|
||||
5. Restore required data paths:
|
||||
- `data/playlist/`
|
||||
- `data/media/`
|
||||
- `data/chat/`
|
||||
- `data/hls/` (optional warm cache)
|
||||
6. Restore database from latest backup dump.
|
||||
7. Start stack:
|
||||
- `docker compose -f docker-compose.prod.yml up -d`
|
||||
8. Validate service health and runtime:
|
||||
- `docker ps --format "table {{.Names}}\t{{.Status}}"`
|
||||
- `curl -fsS http://<origin>/health`
|
||||
- `curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5`
|
||||
- `curl -fsS http://<origin>:9090/-/ready`
|
||||
9. Validate app behavior:
|
||||
- Viewer SPA loads and HLS plays
|
||||
- WebSocket chat connects and message round-trip works
|
||||
- Admin login/session endpoint returns success
|
||||
10. Run backup integrity check:
|
||||
- `scripts/verify-backups.sh`
|
||||
11. Record drill end timestamp and compute measured RTO/RPO.
|
||||
|
||||
### Pass/Fail Criteria
|
||||
|
||||
- Pass if all core services healthy and functional in <= 60 minutes.
|
||||
- Pass if latest recoverable data point is <= 24 hours old.
|
||||
- Fail if any critical path remains degraded (playout, chat, admin auth, monitoring).
|
||||
|
||||
### Evidence Template
|
||||
|
||||
Document each drill in incident tracker with:
|
||||
|
||||
- Date/time window
|
||||
- Environment (test-lab, staging, production)
|
||||
- Backup artifact IDs and timestamps
|
||||
- Measured RTO and RPO
|
||||
- Issues found and remediation owner
|
||||
- Follow-up ticket links
|
||||
@@ -0,0 +1,140 @@
|
||||
# Cloudflare and UptimeRobot Manual Completion Checklist
|
||||
|
||||
Purpose: execute and record all manual dashboard tasks that cannot be automated from repository workflows.
|
||||
|
||||
Recommended execution order:
|
||||
|
||||
1. Bind `media.linearvoid.tv` and confirm the HLS origin is separate from the app origin.
|
||||
2. Enable Pages, Tiered Cache, and cache behavior for the media zone.
|
||||
3. Apply WAF, bot, rate limiting, HTTPS, and HSTS settings.
|
||||
4. Verify monitor targets and alert delivery in UptimeRobot.
|
||||
5. Capture evidence and fill in the sign-off block.
|
||||
|
||||
## Status Legend
|
||||
|
||||
- [ ] Pending
|
||||
- [x] Done
|
||||
|
||||
## 1) Cloudflare Pages Link and Build
|
||||
|
||||
- [ ] Connect repo to Cloudflare Pages project
|
||||
- [ ] Build command set to: `cd frontend && npm run build`
|
||||
- [ ] Output directory set to: `frontend/dist`
|
||||
- [ ] Production domain attached to `linearvoid.tv`
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- Screenshot of Pages deployment settings
|
||||
- Deployment URL and latest deployment timestamp
|
||||
- Confirmed production build output path in the Pages project
|
||||
|
||||
## 2) Tiered Cache Enablement and Validation
|
||||
|
||||
- [ ] Smart Tiered Cache enabled for zone
|
||||
- [ ] Warm request made to hot HLS segment
|
||||
- [ ] Warm request made to live manifest
|
||||
- [ ] `Cf-Cache-Status: HIT` observed on repeated requests
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
||||
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
|
||||
```
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- Headers from first request (expected MISS)
|
||||
- Headers from repeated request (expected HIT)
|
||||
- Screenshot or export showing Smart Tiered Cache enabled
|
||||
|
||||
## 3) WAF and Bot Protection
|
||||
|
||||
- [ ] OWASP Managed Ruleset enabled
|
||||
- [ ] Mode set to Simulate
|
||||
- [ ] Simulate mode reviewed for 48h
|
||||
- [ ] Mode switched to Block after false-positive review
|
||||
- [ ] Bot Fight Mode enabled
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- WAF ruleset status screenshot
|
||||
- Count of blocked/challenged events after cutover
|
||||
- Note on any false positives and the final allow/exception rules applied
|
||||
|
||||
## 4) Rate Limiting Rules
|
||||
|
||||
- [ ] Rule created for `/api/admin/login`
|
||||
- [ ] Rule created for `/api/admin/*`
|
||||
- [ ] Threshold set to `10 requests / 10 seconds / IP`
|
||||
- [ ] Action set to Managed Challenge
|
||||
- [ ] Rule order validated
|
||||
|
||||
Validation approach:
|
||||
|
||||
- Use burst requests from a test client and verify challenge behavior without blocking legitimate admin flow.
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- Rule definitions screenshot
|
||||
- Test result notes (challenge seen / no lockout)
|
||||
- Keep a copy of the test source IP and timestamps used during validation
|
||||
|
||||
## 5) HTTPS and WSS Hardening
|
||||
|
||||
- [ ] Always Use HTTPS enabled
|
||||
- [ ] HSTS enabled with max-age 31536000
|
||||
- [ ] HSTS includeSubDomains enabled
|
||||
- [ ] HSTS preload enabled
|
||||
- [ ] Minimum TLS version set to 1.2+
|
||||
- [ ] SSL mode verified as Full (strict)
|
||||
- [ ] WSS endpoint validated through proxy (`wss://linearvoid.tv/ws`)
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
curl -I https://linearvoid.tv
|
||||
curl -I https://linearvoid.tv/health
|
||||
```
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- Response headers showing HSTS
|
||||
- Successful browser WebSocket connect in devtools
|
||||
- TLS mode screenshot from Cloudflare showing Full (strict)
|
||||
|
||||
## 6) UptimeRobot Monitors
|
||||
|
||||
- [ ] HTTP monitor added for `https://linearvoid.tv/health` (5m)
|
||||
- [ ] Keyword monitor added for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5m)
|
||||
- [ ] Alert trigger set to 2 consecutive failures
|
||||
- [ ] Notification channels configured (email + Discord/Slack webhook)
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- Monitor dashboard screenshot
|
||||
- Test alert delivery confirmation
|
||||
- List of recipients/webhook destinations verified during the alert test
|
||||
|
||||
## 7) Sign-Off
|
||||
|
||||
- [ ] Cloudflare manual tasks complete
|
||||
- [ ] UptimeRobot tasks complete
|
||||
- [ ] TODO items updated to done
|
||||
- [ ] Changelog entry added for completion batch
|
||||
|
||||
Completion summary:
|
||||
|
||||
- Pages build URL:
|
||||
- Cache validation result:
|
||||
- WAF/rate-limit status:
|
||||
- HTTPS/HSTS status:
|
||||
- UptimeRobot monitor status:
|
||||
- Evidence folder or note link:
|
||||
|
||||
Sign-off fields:
|
||||
|
||||
- Owner:
|
||||
- Date:
|
||||
- Environment (test-lab or production):
|
||||
- Notes:
|
||||
@@ -0,0 +1,442 @@
|
||||
# Scaling and Performance Playbook
|
||||
|
||||
Purpose: define repeatable method to measure performance, capture baseline data, and decide scaling actions.
|
||||
|
||||
Scope: planning and execution guide for TODO items under "Scaling & Performance (planning only)".
|
||||
|
||||
## 1) Manifest TTL Tuning Plan
|
||||
|
||||
Goal: tune `.m3u8` TTL to about `0.5x` segment duration while keeping playback stable.
|
||||
|
||||
Inputs:
|
||||
|
||||
- Segment duration (`HLS_SEGMENT_DURATION`)
|
||||
- Current manifest TTL (Cloudflare cache rule)
|
||||
- Cache outcome distribution (`Cf-Cache-Status`)
|
||||
|
||||
Procedure:
|
||||
|
||||
1. Start from current manifest TTL (2s).
|
||||
2. Capture 24h baseline headers from repeated `playlist.m3u8` requests.
|
||||
3. Test candidate TTLs: 1s, 2s, 3s.
|
||||
4. Compare:
|
||||
- rebuffer events from player logs
|
||||
- origin request increase/decrease
|
||||
- HIT ratio changes
|
||||
5. Select lowest TTL that keeps playback stable and origin load acceptable.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- No material increase in rebuffer rate.
|
||||
- Manifest staleness not observable by users.
|
||||
|
||||
## 2) TTFB Baseline Plan
|
||||
|
||||
Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`.
|
||||
|
||||
Suggested regions:
|
||||
|
||||
- US East (Virginia)
|
||||
- US Central
|
||||
- US West (California/Oregon)
|
||||
|
||||
Method:
|
||||
|
||||
1. Use WebPageTest with repeat view enabled.
|
||||
2. Run at least 5 samples per region.
|
||||
3. Record median and p95 TTFB.
|
||||
4. Store results in table (date, region, median, p95, test URL).
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Warm-cache median < 4s in all tested regions.
|
||||
- Outliers documented with likely cause.
|
||||
|
||||
### TTFB Baseline Worksheet
|
||||
|
||||
Use one row per region and keep the raw WebPageTest result URL with the summary so later runs can be compared against the same evidence.
|
||||
|
||||
- US East (Virginia)
|
||||
- Date/time:
|
||||
- Test URL: <https://linearvoid.tv>
|
||||
- WebPageTest result URL:
|
||||
- Median TTFB:
|
||||
- p95 TTFB:
|
||||
- Notes:
|
||||
- US Central
|
||||
- Date/time:
|
||||
- Test URL: <https://linearvoid.tv>
|
||||
- WebPageTest result URL:
|
||||
- Median TTFB:
|
||||
- p95 TTFB:
|
||||
- Notes:
|
||||
- US West (California/Oregon)
|
||||
- Date/time:
|
||||
- Test URL: <https://linearvoid.tv>
|
||||
- WebPageTest result URL:
|
||||
- Median TTFB:
|
||||
- p95 TTFB:
|
||||
- Notes:
|
||||
|
||||
Suggested capture notes:
|
||||
|
||||
- Use repeat view for the warm-cache measurement.
|
||||
- Keep the same browser profile and test URL across regions.
|
||||
- Note any CDN cache-rule changes or deploys that occurred between samples.
|
||||
|
||||
## 3) WebSocket Load-Test Plan
|
||||
|
||||
Goal: characterize chat service behavior across 10 -> 100 -> 500 -> 1000 concurrent clients.
|
||||
|
||||
Tooling option A (Artillery):
|
||||
|
||||
```bash
|
||||
npx artillery run scripts/loadtest/ws-chat.yml
|
||||
```
|
||||
|
||||
Tooling option B (custom Go harness):
|
||||
|
||||
- open N concurrent WebSocket clients
|
||||
- send message every X seconds
|
||||
- measure round-trip latency and failures
|
||||
|
||||
Metrics to capture:
|
||||
|
||||
- p50/p95 message latency
|
||||
- error rate (connect, send, receive)
|
||||
- dropped connections
|
||||
- CPU/memory for `chat`, `redis`, `nginx`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- p95 chat latency <= 1s at target concurrency tier.
|
||||
- error rate <= 1% during steady state.
|
||||
|
||||
### WebSocket Load-Test Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Test URL:
|
||||
- Tool used:
|
||||
- Scenario duration:
|
||||
- Concurrency tiers reached:
|
||||
- p50 message latency:
|
||||
- p95 message latency:
|
||||
- Error rate:
|
||||
- Dropped connections:
|
||||
- CPU/memory notes for chat:
|
||||
- CPU/memory notes for redis:
|
||||
- CPU/memory notes for nginx:
|
||||
- Raw results location:
|
||||
- Conclusion:
|
||||
- Next action:
|
||||
|
||||
## 4) Admin API Benchmark Plan
|
||||
|
||||
Goal: measure admin API p95 and identify slow endpoints.
|
||||
|
||||
Tooling option A (`k6`):
|
||||
|
||||
```bash
|
||||
k6 run scripts/loadtest/admin-api.js
|
||||
```
|
||||
|
||||
Tooling option B (`wrk`):
|
||||
|
||||
```bash
|
||||
wrk -t4 -c100 -d60s http://<host>/api/admin/overview
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `/api/admin/overview`
|
||||
- `/api/admin/analytics`
|
||||
- `/api/admin/chat-log`
|
||||
- `/api/admin/users`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- p95 < 1s for primary admin endpoints.
|
||||
- slow endpoint list with remediation notes.
|
||||
|
||||
### Admin API Benchmark Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Test URL:
|
||||
- Tool used:
|
||||
- Scenario duration:
|
||||
- Concurrency / virtual users:
|
||||
- `/api/admin/overview` p95:
|
||||
- `/api/admin/analytics` p95:
|
||||
- `/api/admin/chat-log` p95:
|
||||
- `/api/admin/users` p95:
|
||||
- Error rate:
|
||||
- Slowest endpoint:
|
||||
- Cache or DB notes:
|
||||
- Raw results location:
|
||||
- Conclusion:
|
||||
- Next action:
|
||||
|
||||
## 5) Cache Hit Ratio Tracking Plan
|
||||
|
||||
Goal: track Cloudflare cache hit ratio and alert when below threshold.
|
||||
|
||||
Implementation outline:
|
||||
|
||||
1. Pull Cloudflare GraphQL analytics on schedule.
|
||||
2. Export to Prometheus-compatible metric path.
|
||||
3. Add Grafana panel with 5m/1h/24h windows.
|
||||
4. Alert when hit ratio < 90% for 15m.
|
||||
|
||||
Reference exporter:
|
||||
|
||||
- `scripts/cache-hit-ratio.js`
|
||||
- `scripts/cache-hit-ratio.ps1` for Windows scheduled tasks / manual runs
|
||||
- Example run: `CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_TAG=... CLOUDFLARE_HOSTNAME=media.linearvoid.tv node scripts/cache-hit-ratio.js`
|
||||
- Windows example: `pwsh -File scripts/cache-hit-ratio.ps1`
|
||||
- Intended output: Prometheus text format on stdout, suitable for cron, a Node wrapper, or a small scrape endpoint.
|
||||
|
||||
Metric target:
|
||||
|
||||
- `cache_hit_ratio >= 0.90`
|
||||
|
||||
### Cache Hit Ratio Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Hostname:
|
||||
- Window minutes:
|
||||
- Cloudflare zone tag:
|
||||
- Export command:
|
||||
- Raw exporter output location:
|
||||
- Hit requests:
|
||||
- Total requests:
|
||||
- Cache hit ratio:
|
||||
- Threshold met:
|
||||
- Notes:
|
||||
|
||||
## 6) Capacity Trigger Rules
|
||||
|
||||
Define operational triggers:
|
||||
|
||||
- Cache miss ratio > 10% for 15m
|
||||
- Chat p95 latency > 1s for 5m
|
||||
- Host CPU > 80% for 10m
|
||||
- Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m
|
||||
|
||||
Action mapping:
|
||||
|
||||
- Level 1: investigate + restart non-critical components
|
||||
- Level 2: scale service resources (CPU/RAM)
|
||||
- Level 3: activate standby planning path
|
||||
|
||||
### Capacity Trigger Review
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Trigger hit:
|
||||
- Threshold value:
|
||||
- Observed value:
|
||||
- Duration above threshold:
|
||||
- Level selected:
|
||||
- Immediate action taken:
|
||||
- Services involved:
|
||||
- Follow-up owner:
|
||||
- Raw metrics / alert link:
|
||||
- Notes:
|
||||
|
||||
## 7) Phase 2 Service Separation Plan
|
||||
|
||||
Objective: separate bottlenecked services while keeping simple ops.
|
||||
|
||||
Boundaries:
|
||||
|
||||
- `playout` isolated first (CPU-heavy)
|
||||
- `chat` + `redis` grouped for latency
|
||||
- `db` isolated for IO stability
|
||||
- monitoring stack optionally separated after sustained load
|
||||
|
||||
Initial sizing guidance:
|
||||
|
||||
- playout host: 4 vCPU / 8 GB
|
||||
- chat host: 2-4 vCPU / 4-8 GB
|
||||
- db host: 2 vCPU / 4 GB + fast disk
|
||||
|
||||
Exit criteria for Phase 2:
|
||||
|
||||
- repeated trigger events over 7 days
|
||||
- capacity saturation despite tuning
|
||||
|
||||
### Phase 2 Boundary Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Trigger history summary:
|
||||
- Repeated trigger count over 7 days:
|
||||
- Primary bottleneck:
|
||||
- Proposed split:
|
||||
- `playout` target host size:
|
||||
- `chat` target host size:
|
||||
- `db` target host size:
|
||||
- Monitoring separation needed:
|
||||
- Data/state migration notes:
|
||||
- Implementation owner:
|
||||
- Review decision:
|
||||
- Next action:
|
||||
|
||||
## 8) Phase 3 Warm Standby Plan
|
||||
|
||||
Objective: second-region resilience with DNS failover.
|
||||
|
||||
Candidate layout:
|
||||
|
||||
- Primary: Hetzner Ashburn (US-East)
|
||||
- Standby: Hetzner Hillsboro (US-West)
|
||||
|
||||
Design:
|
||||
|
||||
- Warm standby stack deployed and health-checked
|
||||
- Redis replication or fallback mode documented
|
||||
- Postgres replication strategy selected (streaming replica or periodic snapshot restore)
|
||||
- Cloudflare DNS failover policy defined (health-based or manual emergency switch)
|
||||
|
||||
Failover drill requirements:
|
||||
|
||||
### Phase 3 Warm Standby Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Primary region:
|
||||
- Standby region:
|
||||
- DNS failover mode:
|
||||
- Redis strategy:
|
||||
- Postgres strategy:
|
||||
- Health check endpoints:
|
||||
- Simulated cutover performed:
|
||||
- Measured RTO:
|
||||
- Measured RPO:
|
||||
- Open risks:
|
||||
- Implementation owner:
|
||||
- Review decision:
|
||||
- Notes:
|
||||
|
||||
## 9) Data Capture Template
|
||||
|
||||
For each benchmark run, capture:
|
||||
|
||||
- Date/time
|
||||
- Environment (test-lab/staging/production)
|
||||
- Commit SHA / image tag
|
||||
- Test scenario and duration
|
||||
- Raw metrics (latency, errors, resource usage)
|
||||
- Conclusion and next action
|
||||
|
||||
## 10) Execution Order
|
||||
|
||||
1. TTFB baseline
|
||||
2. WebSocket load profile
|
||||
3. Admin API benchmark
|
||||
4. Cache ratio panel + alerting
|
||||
5. Trigger tuning
|
||||
6. Phase 2 boundary decision
|
||||
7. Phase 3 standby design review
|
||||
|
||||
## 11) Benchmark Run Sheet
|
||||
|
||||
Use the same worksheet for every baseline or re-test so results stay comparable.
|
||||
|
||||
Preconditions:
|
||||
|
||||
- Confirm the target environment and commit SHA/image tag.
|
||||
- Confirm Cloudflare cache rules are active when testing TTFB or manifest TTL.
|
||||
- Confirm the chat, admin, and playout services are healthy before load testing.
|
||||
|
||||
Run template:
|
||||
|
||||
| Item | Value |
|
||||
| -------------------------- | ----- |
|
||||
| Date/time | |
|
||||
| Environment | |
|
||||
| Commit SHA / image tag | |
|
||||
| Test scenario | |
|
||||
| Concurrency / sample count | |
|
||||
| Command or tool | |
|
||||
| Raw results location | |
|
||||
| Conclusion | |
|
||||
| Next action | |
|
||||
|
||||
Suggested command mapping:
|
||||
|
||||
- TTFB baseline: WebPageTest repeat-view run against `https://linearvoid.tv`.
|
||||
- WebSocket profile: `npx artillery run scripts/loadtest/ws-chat.yml` or the Go harness referenced above.
|
||||
- Admin API benchmark: `k6 run scripts/loadtest/admin-api.js` or `wrk -t4 -c100 -d60s http://<host>/api/admin/overview`.
|
||||
|
||||
## 12) Scaling Decision Gate
|
||||
|
||||
Treat the following as the default review gate after any benchmark batch:
|
||||
|
||||
1. If warm-cache TTFB meets target but cache hit ratio falls below 90%, tune cache behavior before increasing capacity.
|
||||
2. If chat or admin latency exceeds target while CPU and memory remain low, prioritize query or handler optimization.
|
||||
3. If CPU, memory, or Redis saturation appears first, treat the service boundary guidance in Phase 2 as the next action.
|
||||
4. If the same threshold trips on multiple runs across 7 days, open the Phase 2 or Phase 3 planning path instead of tuning in place.
|
||||
|
||||
Decision note:
|
||||
|
||||
- Keep the smallest change that restores the target metric and record the reason in the run sheet.
|
||||
|
||||
## 13) Artifact-to-Worksheet Field Mapping
|
||||
|
||||
Use this section when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
|
||||
|
||||
### WebSocket worksheet mapping
|
||||
|
||||
Artifact sources:
|
||||
|
||||
- `artifacts/<timestamp>/ws-chat-report.json`
|
||||
- `artifacts/<timestamp>/ws-chat-summary.txt`
|
||||
|
||||
Field mapping:
|
||||
|
||||
- Worksheet `Tool used` -> `artillery` (or `run-loadtests.ps1 -Scenario ws`)
|
||||
- Worksheet `Raw results location` -> artifact folder path for this run
|
||||
- Worksheet `Concurrency tiers reached` -> `ws-chat.yml` phases (`warmup-10`, `sustain-100`, `sustain-500`, `sustain-1000`)
|
||||
- Worksheet `Error rate` -> `vusers.failure_rate_percent` from `ws-chat-summary.txt`
|
||||
- Worksheet `Dropped connections` -> derive from `vusers.created - vusers.completed` (use `vusers.failed` as failure signal)
|
||||
- Worksheet `p95 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `p95=...`
|
||||
- Worksheet `p50 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `median=...`
|
||||
|
||||
Notes:
|
||||
|
||||
- The exact `summary.*` metric names in Artillery output can vary by engine/plugins.
|
||||
- If no latency summary field is emitted, record this explicitly and use server-side latency metrics as fallback evidence.
|
||||
|
||||
### Admin API worksheet mapping
|
||||
|
||||
Artifact sources:
|
||||
|
||||
- `artifacts/<timestamp>/admin-api-summary.json`
|
||||
- `artifacts/<timestamp>/admin-api-output.txt`
|
||||
- `artifacts/<timestamp>/admin-api-endpoint-summary.txt`
|
||||
|
||||
Field mapping:
|
||||
|
||||
- Worksheet `Tool used` -> `k6` (or `run-loadtests.ps1 -Scenario admin`)
|
||||
- Worksheet `Raw results location` -> artifact folder path for this run
|
||||
- Worksheet `/api/admin/overview` p95 -> `overview` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `/api/admin/analytics` p95 -> `analytics` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `/api/admin/chat-log` p95 -> `chat_log` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `/api/admin/users` p95 -> `users` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `Error rate` -> `Global failure rate` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `Slowest endpoint` -> endpoint row with highest `P95Ms`
|
||||
|
||||
Notes:
|
||||
|
||||
- Keep the raw `admin-api-summary.json` file for audits and re-parsing.
|
||||
- If an endpoint row is missing, treat as test/data issue and re-run with full endpoint coverage.
|
||||
+115
-27
@@ -1,42 +1,130 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void Admin</title>
|
||||
<link rel="stylesheet" href="../src/style.css" />
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body class="admin-body">
|
||||
<body class="admin-body">
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-sidebar" aria-label="Primary">
|
||||
<div class="brand-block">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div class="brand-text">Admin</div>
|
||||
</div>
|
||||
<aside class="admin-sidebar" aria-label="Primary">
|
||||
<div class="brand-block">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div class="brand-text">Admin</div>
|
||||
</div>
|
||||
|
||||
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
||||
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
||||
|
||||
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
||||
<button type="button" id="tab-trigger-dashboard" role="tab" class="tab-trigger is-active" data-tab="dashboard" aria-controls="tab-dashboard" aria-selected="true">Dashboard</button>
|
||||
<button type="button" id="tab-trigger-upload" role="tab" class="tab-trigger" data-tab="upload" aria-controls="tab-upload" aria-selected="false">Upload Video</button>
|
||||
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">Playlist</button>
|
||||
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">Schedule</button>
|
||||
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">Moderation</button>
|
||||
<button type="button" id="tab-trigger-chat-log" role="tab" class="tab-trigger" data-tab="chat-log" aria-controls="tab-chat-log" aria-selected="false">Chat Log</button>
|
||||
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">Users</button>
|
||||
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">Analytics</button>
|
||||
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">System</button>
|
||||
</nav>
|
||||
</aside>
|
||||
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-dashboard"
|
||||
role="tab"
|
||||
class="tab-trigger is-active"
|
||||
data-tab="dashboard"
|
||||
aria-controls="tab-dashboard"
|
||||
aria-selected="true"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-upload"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="upload"
|
||||
aria-controls="tab-upload"
|
||||
aria-selected="false"
|
||||
>
|
||||
Upload Video
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-playlist"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="playlist"
|
||||
aria-controls="tab-playlist"
|
||||
aria-selected="false"
|
||||
>
|
||||
Playlist
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-schedule"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="schedule"
|
||||
aria-controls="tab-schedule"
|
||||
aria-selected="false"
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-moderation"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="moderation"
|
||||
aria-controls="tab-moderation"
|
||||
aria-selected="false"
|
||||
>
|
||||
Moderation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-chat-log"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="chat-log"
|
||||
aria-controls="tab-chat-log"
|
||||
aria-selected="false"
|
||||
>
|
||||
Chat Log
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-users"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="users"
|
||||
aria-controls="tab-users"
|
||||
aria-selected="false"
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-analytics"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="analytics"
|
||||
aria-controls="tab-analytics"
|
||||
aria-selected="false"
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-system"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="system"
|
||||
aria-controls="tab-system"
|
||||
aria-selected="false"
|
||||
>
|
||||
System
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="admin-main" id="admin-main" tabindex="-1">
|
||||
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
||||
</main>
|
||||
<main class="admin-main" id="admin-main" tabindex="-1">
|
||||
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/admin.ts"></script>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,67 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void Admin Login</title>
|
||||
<link rel="stylesheet" href="../../src/style.css" />
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body class="auth-body">
|
||||
<body class="auth-body">
|
||||
<main class="auth-shell" aria-label="Admin authentication">
|
||||
<section class="auth-card" aria-labelledby="auth-title">
|
||||
<p class="auth-kicker">Linear Void</p>
|
||||
<h1 id="auth-title">Admin Login</h1>
|
||||
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
||||
<section class="auth-card" aria-labelledby="auth-title">
|
||||
<p class="auth-kicker">Linear Void</p>
|
||||
<h1 id="auth-title">Admin Login</h1>
|
||||
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
||||
|
||||
<form id="login-form" class="form-stack" novalidate>
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
||||
<form id="login-form" class="form-stack" novalidate>
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
|
||||
|
||||
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||
<p id="status" class="status-line" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||
<p id="status" class="status-line" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('login-form');
|
||||
const username = document.getElementById('username');
|
||||
const password = document.getElementById('password');
|
||||
const submit = document.getElementById('submit');
|
||||
const status = document.getElementById('status');
|
||||
const form = document.getElementById("login-form");
|
||||
const username = document.getElementById("username");
|
||||
const password = document.getElementById("password");
|
||||
const submit = document.getElementById("submit");
|
||||
const status = document.getElementById("status");
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
submit.disabled = true;
|
||||
status.textContent = 'Signing in...';
|
||||
status.className = 'status-line';
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
submit.disabled = true;
|
||||
status.textContent = "Signing in...";
|
||||
status.className = "status-line";
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.value, password: password.value })
|
||||
});
|
||||
try {
|
||||
const response = await fetch("/api/admin/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
window.location.href = '/admin/';
|
||||
} catch (error) {
|
||||
console.error('Failed to sign in', error);
|
||||
status.textContent = 'Login failed. Check your credentials.';
|
||||
status.className = 'status-line status-error';
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
window.location.href = "/admin/";
|
||||
} catch (error) {
|
||||
console.error("Failed to sign in", error);
|
||||
status.textContent = "Login failed. Check your credentials.";
|
||||
status.className = "status-line status-error";
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+66
-55
@@ -1,65 +1,76 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
|
||||
<body class="lv-body">
|
||||
<main class="viewer-shell" aria-label="Live player and chat">
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-brand-group">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div>
|
||||
<h1 class="viewer-title">Linear Void</h1>
|
||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||
</div>
|
||||
</div>
|
||||
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">Hide Chat</button>
|
||||
</header>
|
||||
|
||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||
<section class="player-stage" aria-label="Video stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing" class="now-playing-overlay" aria-live="polite">
|
||||
<span id="np-title">Loading...</span>
|
||||
<span id="np-next" class="np-next"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||
<header class="chat-header">
|
||||
<div class="chat-room-title">
|
||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||
<span id="room-name">Main Channel</span>
|
||||
<body class="lv-body">
|
||||
<main class="viewer-shell" aria-label="Live player and chat">
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-brand-group">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div>
|
||||
<h1 class="viewer-title">Linear Void</h1>
|
||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</header>
|
||||
</div>
|
||||
<button
|
||||
id="chat-toggle"
|
||||
type="button"
|
||||
class="btn btn-secondary chat-toggle-btn"
|
||||
onclick="toggleChatPanel()"
|
||||
aria-controls="chat-panel"
|
||||
aria-expanded="true"
|
||||
>
|
||||
Hide Chat
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
||||
|
||||
<section id="presence-panel" class="presence-panel" aria-label="Online users">
|
||||
<div id="presence-list" class="presence-list"></div>
|
||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||
<section class="player-stage" aria-label="Video stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing" class="now-playing-overlay" aria-live="polite">
|
||||
<span id="np-title">Loading...</span>
|
||||
<span id="np-next" class="np-next"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<div class="chat-input-row">
|
||||
<label for="message-input" class="sr-only">Message input</label>
|
||||
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
|
||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||
<header class="chat-header">
|
||||
<div class="chat-room-title">
|
||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||
<span id="room-name">Main Channel</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</header>
|
||||
|
||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
||||
|
||||
<section id="presence-panel" class="presence-panel" aria-label="Online users">
|
||||
<div id="presence-list" class="presence-list"></div>
|
||||
</section>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<div class="chat-input-row">
|
||||
<label for="message-input" class="sr-only">Message input</label>
|
||||
<input
|
||||
id="message-input"
|
||||
type="text"
|
||||
placeholder="Type a message..."
|
||||
class="input"
|
||||
onkeydown="if (event.key === 'Enter') sendMessage();"
|
||||
/>
|
||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
<p class="chat-note">Synced with video • Moderation active</p>
|
||||
</div>
|
||||
<p class="chat-note">
|
||||
Synced with video • Moderation active
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1005
-3
File diff suppressed because it is too large.
Load diff
@@ -1,17 +1,22 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.16"
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Analytics</h1>
|
||||
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<select id="analytics-range" class="text-input" style="width:auto">
|
||||
<option value="1h">Last hour</option>
|
||||
<option value="6h">Last 6 hours</option>
|
||||
<option value="24h" selected>Last 24 hours</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
</select>
|
||||
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Analytics</h1>
|
||||
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<select id="analytics-range" class="text-input" style="width: auto">
|
||||
<option value="1h">Last hour</option>
|
||||
<option value="6h">Last 6 hours</option>
|
||||
<option value="24h" selected>Last 24 hours</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
</select>
|
||||
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<article class="stat-card">
|
||||
<h2>Total Messages</h2>
|
||||
<p id="analytics-total-messages" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Last 24 Hours</h2>
|
||||
<p id="analytics-messages-24h" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Distinct Rooms</h2>
|
||||
<p id="analytics-distinct-rooms" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Moderation Actions</h2>
|
||||
<p id="analytics-moderation-actions" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Total Messages</h2>
|
||||
<p id="analytics-total-messages" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Last 24 Hours</h2>
|
||||
<p id="analytics-messages-24h" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Distinct Rooms</h2>
|
||||
<p id="analytics-distinct-rooms" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Moderation Actions</h2>
|
||||
<p id="analytics-moderation-actions" class="stat-value">0</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Top Rooms</h2>
|
||||
<p id="analytics-generated-at" class="small-muted">loading...</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Room</th>
|
||||
<th>Messages</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="analytics-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
|
||||
<div class="panel-header-row">
|
||||
<h2>Top Rooms</h2>
|
||||
<p id="analytics-generated-at" class="small-muted">loading...</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Room</th>
|
||||
<th>Messages</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="analytics-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
|
||||
</div>
|
||||
@@ -1,46 +1,46 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Chat Log</h1>
|
||||
<p>Browse and manage stored chat messages.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-chat-log-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||
<button id="bulk-delete-chat-log-btn" type="button" class="btn btn-danger" disabled>Delete Selected</button>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Chat Log</h1>
|
||||
<p>Browse and manage stored chat messages.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-chat-log-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||
<button id="bulk-delete-chat-log-btn" type="button" class="btn btn-danger" disabled>Delete Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="chat-log-filters">
|
||||
<input id="chat-log-filter-room" class="text-input" type="text" placeholder="Room" />
|
||||
<input id="chat-log-filter-username" class="text-input" type="text" placeholder="Username" />
|
||||
<input id="chat-log-filter-text" class="text-input" type="text" placeholder="Message text" />
|
||||
<button id="chat-log-filter-btn" type="button" class="btn btn-secondary">Filter</button>
|
||||
<button id="chat-log-clear-filter-btn" type="button" class="btn btn-secondary">Clear</button>
|
||||
</div>
|
||||
<div class="chat-log-filters">
|
||||
<input id="chat-log-filter-room" class="text-input" type="text" placeholder="Room" />
|
||||
<input id="chat-log-filter-username" class="text-input" type="text" placeholder="Username" />
|
||||
<input id="chat-log-filter-text" class="text-input" type="text" placeholder="Message text" />
|
||||
<button id="chat-log-filter-btn" type="button" class="btn btn-secondary">Filter</button>
|
||||
<button id="chat-log-clear-filter-btn" type="button" class="btn btn-secondary">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
||||
<th>ID</th>
|
||||
<th>Room</th>
|
||||
<th>Username</th>
|
||||
<th>Message</th>
|
||||
<th>Type</th>
|
||||
<th>Timestamp</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="chat-log-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="chat-log-pagination">
|
||||
<button id="chat-log-prev-btn" type="button" class="btn btn-secondary" disabled>Previous</button>
|
||||
<span id="chat-log-page-info" class="small-muted">Page 1</span>
|
||||
<button id="chat-log-next-btn" type="button" class="btn btn-secondary" disabled>Next</button>
|
||||
</div>
|
||||
<p id="chat-log-status" class="status-line" aria-live="polite">Loading chat log...</p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
||||
<th>ID</th>
|
||||
<th>Room</th>
|
||||
<th>Username</th>
|
||||
<th>Message</th>
|
||||
<th>Type</th>
|
||||
<th>Timestamp</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="chat-log-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="chat-log-pagination">
|
||||
<button id="chat-log-prev-btn" type="button" class="btn btn-secondary" disabled>Previous</button>
|
||||
<span id="chat-log-page-info" class="small-muted">Page 1</span>
|
||||
<button id="chat-log-next-btn" type="button" class="btn btn-secondary" disabled>Next</button>
|
||||
</div>
|
||||
<p id="chat-log-status" class="status-line" aria-live="polite">Loading chat log...</p>
|
||||
</div>
|
||||
@@ -1,21 +1,21 @@
|
||||
<h1>Dashboard</h1>
|
||||
<div class="stat-grid">
|
||||
<article class="stat-card">
|
||||
<h2>Concurrent Viewers</h2>
|
||||
<p id="stat-viewers" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Stored Messages</h2>
|
||||
<p id="stat-stored-messages" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Moderation Actions</h2>
|
||||
<p id="stat-moderation-actions" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Media Runtime</h2>
|
||||
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
|
||||
<p id="stat-media-count" class="stat-caption">0 files</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Concurrent Viewers</h2>
|
||||
<p id="stat-viewers" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Stored Messages</h2>
|
||||
<p id="stat-stored-messages" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Moderation Actions</h2>
|
||||
<p id="stat-moderation-actions" class="stat-value">0</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>Media Runtime</h2>
|
||||
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
|
||||
<p id="stat-media-count" class="stat-caption">0 files</p>
|
||||
</article>
|
||||
</div>
|
||||
<p id="overview-status" class="status-line" aria-live="polite">Loading live admin overview...</p>
|
||||
@@ -1,61 +1,71 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Moderation</h1>
|
||||
<p>Create moderation actions and review the recent immutable audit log.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
|
||||
<button id="export-chat-log-btn" type="button" class="btn btn-secondary">Export CSV</button>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Moderation</h1>
|
||||
<p>Create moderation actions and review the recent immutable audit log.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
|
||||
<button id="export-chat-log-btn" type="button" class="btn btn-secondary">Export CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="moderation-grid">
|
||||
<section class="panel-card">
|
||||
<h2>New Action</h2>
|
||||
<form id="moderation-form" class="form-stack">
|
||||
<label>Action
|
||||
<select id="mod-action" class="text-input">
|
||||
<option value="mute">mute</option>
|
||||
<option value="ban">ban</option>
|
||||
<option value="delete">delete</option>
|
||||
<option value="timeout">timeout</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Target
|
||||
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
|
||||
</label>
|
||||
<label>Moderator
|
||||
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
|
||||
</label>
|
||||
<label>Room
|
||||
<input id="mod-room" class="text-input" type="text" placeholder="main" />
|
||||
</label>
|
||||
<label>Reason
|
||||
<textarea id="mod-reason" class="text-input text-area" placeholder="Explain why this action is needed" required></textarea>
|
||||
</label>
|
||||
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
|
||||
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel-card">
|
||||
<h2>New Action</h2>
|
||||
<form id="moderation-form" class="form-stack">
|
||||
<label
|
||||
>Action
|
||||
<select id="mod-action" class="text-input">
|
||||
<option value="mute">mute</option>
|
||||
<option value="ban">ban</option>
|
||||
<option value="delete">delete</option>
|
||||
<option value="timeout">timeout</option>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
>Target
|
||||
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
|
||||
</label>
|
||||
<label
|
||||
>Moderator
|
||||
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
|
||||
</label>
|
||||
<label
|
||||
>Room
|
||||
<input id="mod-room" class="text-input" type="text" placeholder="main" />
|
||||
</label>
|
||||
<label
|
||||
>Reason
|
||||
<textarea
|
||||
id="mod-reason"
|
||||
class="text-input text-area"
|
||||
placeholder="Explain why this action is needed"
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
|
||||
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Recent Audit Log</h2>
|
||||
<p class="small-muted">Latest entries</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Moderator</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="moderation-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Recent Audit Log</h2>
|
||||
<p class="small-muted">Latest entries</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Moderator</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="moderation-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1,18 +1,18 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Playlist</h1>
|
||||
<p>Edit the active concat playlist. Drag entries to reorder.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||
<button id="save-playlist-btn" type="button" class="btn btn-primary">Save playlist</button>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Playlist</h1>
|
||||
<p>Edit the active concat playlist. Drag entries to reorder.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
||||
<button id="save-playlist-btn" type="button" class="btn btn-primary">Save playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div id="playlist-warnings" class="status-warnings" hidden></div>
|
||||
<ul id="playlist-sortable" class="sortable-list" aria-label="Playlist entries"></ul>
|
||||
<label for="playlist-content" class="sr-only">Playlist content (raw)</label>
|
||||
<textarea id="playlist-content" class="text-input text-area" spellcheck="false" rows="6"></textarea>
|
||||
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
|
||||
<div id="playlist-warnings" class="status-warnings" hidden></div>
|
||||
<ul id="playlist-sortable" class="sortable-list" aria-label="Playlist entries"></ul>
|
||||
<label for="playlist-content" class="sr-only">Playlist content (raw)</label>
|
||||
<textarea id="playlist-content" class="text-input text-area" spellcheck="false" rows="6"></textarea>
|
||||
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
|
||||
</div>
|
||||
@@ -1,53 +1,56 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Schedule</h1>
|
||||
<p>Read-only view of the currently configured playout order.</p>
|
||||
</div>
|
||||
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
|
||||
<div>
|
||||
<h1>Schedule</h1>
|
||||
<p>Read-only view of the currently configured playout order.</p>
|
||||
</div>
|
||||
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<p class="small-muted"><span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime <span id="schedule-total-runtime">00:00:00</span></p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Label</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedule-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
||||
<p class="small-muted">
|
||||
<span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime
|
||||
<span id="schedule-total-runtime">00:00:00</span>
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Label</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedule-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<h2>Schedule Overrides</h2>
|
||||
<p>Insert scheduled overrides to play specific videos at specific times.</p>
|
||||
<form id="override-form" class="form-stack" style="max-width:500px">
|
||||
<label>Media ID <input id="override-media-id" type="number" class="text-input" required /></label>
|
||||
<label>Start Time <input id="override-start" type="datetime-local" class="text-input" required /></label>
|
||||
<label>End Time <input id="override-end" type="datetime-local" class="text-input" required /></label>
|
||||
<label>Reason <input id="override-reason" type="text" class="text-input" placeholder="e.g. special event" /></label>
|
||||
<button type="submit" class="btn btn-primary">Add Override</button>
|
||||
</form>
|
||||
<p id="override-status" class="status-line" aria-live="polite"></p>
|
||||
<div class="table-wrap" style="margin-top:1rem">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Media ID</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Reason</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="override-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h2>Schedule Overrides</h2>
|
||||
<p>Insert scheduled overrides to play specific videos at specific times.</p>
|
||||
<form id="override-form" class="form-stack" style="max-width: 500px">
|
||||
<label>Media ID <input id="override-media-id" type="number" class="text-input" required /></label>
|
||||
<label>Start Time <input id="override-start" type="datetime-local" class="text-input" required /></label>
|
||||
<label>End Time <input id="override-end" type="datetime-local" class="text-input" required /></label>
|
||||
<label>Reason <input id="override-reason" type="text" class="text-input" placeholder="e.g. special event" /></label>
|
||||
<button type="submit" class="btn btn-primary">Add Override</button>
|
||||
</form>
|
||||
<p id="override-status" class="status-line" aria-live="polite"></p>
|
||||
<div class="table-wrap" style="margin-top: 1rem">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Media ID</th>
|
||||
<th>Start</th>
|
||||
<th>End</th>
|
||||
<th>Reason</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="override-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,75 +1,75 @@
|
||||
<h1>System Configuration</h1>
|
||||
<div class="panel-card split-grid">
|
||||
<section>
|
||||
<h2>Redis Stats</h2>
|
||||
<dl class="kv-list">
|
||||
<div>
|
||||
<dt>Memory Used</dt>
|
||||
<dd id="system-redis-memory">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Connected Clients</dt>
|
||||
<dd id="system-redis-clients">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Redis Enabled</dt>
|
||||
<dd id="system-redis-enabled">no</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Storage</h2>
|
||||
<dl class="kv-list">
|
||||
<div>
|
||||
<dt>Database Driver</dt>
|
||||
<dd id="system-db-driver">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stored Messages</dt>
|
||||
<dd id="system-stored-messages">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Refresh</dt>
|
||||
<dd id="system-generated-at">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Playlist Path</dt>
|
||||
<dd id="system-playlist-path">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Playlist Present</dt>
|
||||
<dd id="system-playlist-present">no</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Redis Stats</h2>
|
||||
<dl class="kv-list">
|
||||
<div>
|
||||
<dt>Memory Used</dt>
|
||||
<dd id="system-redis-memory">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Connected Clients</dt>
|
||||
<dd id="system-redis-clients">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Redis Enabled</dt>
|
||||
<dd id="system-redis-enabled">no</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Storage</h2>
|
||||
<dl class="kv-list">
|
||||
<div>
|
||||
<dt>Database Driver</dt>
|
||||
<dd id="system-db-driver">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stored Messages</dt>
|
||||
<dd id="system-stored-messages">0</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Refresh</dt>
|
||||
<dd id="system-generated-at">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Playlist Path</dt>
|
||||
<dd id="system-playlist-path">loading...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Playlist Present</dt>
|
||||
<dd id="system-playlist-present">no</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Service Health</h2>
|
||||
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
|
||||
</div>
|
||||
<div id="system-services" class="service-list"></div>
|
||||
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
||||
<div class="panel-header-row">
|
||||
<h2>Service Health</h2>
|
||||
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
|
||||
</div>
|
||||
<div id="system-services" class="service-list"></div>
|
||||
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<h2>Runtime Configuration</h2>
|
||||
<form id="config-form" class="form-stack" style="max-width:400px">
|
||||
<label>Rate Limit Max <input id="config-rate-max" type="number" class="text-input" /></label>
|
||||
<label>Rate Limit Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label>
|
||||
<button type="submit" class="btn btn-primary">Save Config</button>
|
||||
</form>
|
||||
<p id="config-status" class="status-line" aria-live="polite"></p>
|
||||
<h2>Runtime Configuration</h2>
|
||||
<form id="config-form" class="form-stack" style="max-width: 400px">
|
||||
<label>Rate Limit Max <input id="config-rate-max" type="number" class="text-input" /></label>
|
||||
<label>Rate Limit Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label>
|
||||
<button type="submit" class="btn btn-primary">Save Config</button>
|
||||
</form>
|
||||
<p id="config-status" class="status-line" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<h2>Service Control</h2>
|
||||
<p class="small-muted">Restart individual services (manual — container restart commands returned by API).</p>
|
||||
<div class="button-row">
|
||||
<button class="btn btn-secondary restart-btn" data-service="playout">Restart Playout</button>
|
||||
<button class="btn btn-secondary restart-btn" data-service="chat">Restart Chat</button>
|
||||
<button class="btn btn-secondary restart-btn" data-service="nginx">Restart Nginx</button>
|
||||
</div>
|
||||
<p id="restart-status" class="status-line" aria-live="polite"></p>
|
||||
<h2>Service Control</h2>
|
||||
<p class="small-muted">Restart individual services (manual — container restart commands returned by API).</p>
|
||||
<div class="button-row">
|
||||
<button class="btn btn-secondary restart-btn" data-service="playout">Restart Playout</button>
|
||||
<button class="btn btn-secondary restart-btn" data-service="chat">Restart Chat</button>
|
||||
<button class="btn btn-secondary restart-btn" data-service="nginx">Restart Nginx</button>
|
||||
</div>
|
||||
<p id="restart-status" class="status-line" aria-live="polite"></p>
|
||||
</div>
|
||||
@@ -1,41 +1,43 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Upload Video</h1>
|
||||
<p>Upload media files into the local media library mounted for playout.</p>
|
||||
</div>
|
||||
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
|
||||
<div>
|
||||
<h1>Upload Video</h1>
|
||||
<p>Upload media files into the local media library mounted for playout.</p>
|
||||
</div>
|
||||
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
|
||||
</div>
|
||||
|
||||
<div id="upload-dropzone" class="upload-dropzone">
|
||||
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
|
||||
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">Drag and Drop or Click to Upload</button>
|
||||
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
|
||||
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
|
||||
<progress id="media-upload-progress" value="0" max="100"></progress>
|
||||
<p id="media-upload-progress-text" class="small-muted">Uploading (0%)</p>
|
||||
</div>
|
||||
<p id="media-status" class="status-line" aria-live="polite">Loading media library...</p>
|
||||
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
|
||||
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">
|
||||
Drag and Drop or Click to Upload
|
||||
</button>
|
||||
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
|
||||
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
|
||||
<progress id="media-upload-progress" value="0" max="100"></progress>
|
||||
<p id="media-upload-progress-text" class="small-muted">Uploading (0%)</p>
|
||||
</div>
|
||||
<p id="media-status" class="status-line" aria-live="polite">Loading media library...</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-header-row">
|
||||
<h2>Media Library</h2>
|
||||
<p id="media-generated-at" class="small-muted">loading...</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Title</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Size</th>
|
||||
<th>Modified</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="media-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel-header-row">
|
||||
<h2>Media Library</h2>
|
||||
<p id="media-generated-at" class="small-muted">loading...</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Title</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Size</th>
|
||||
<th>Modified</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="media-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,24 +1,24 @@
|
||||
<div class="panel-header-row">
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<p>Current room presence derived from Redis presence keys.</p>
|
||||
</div>
|
||||
<button id="reload-users-btn" type="button" class="btn btn-secondary">Reload users</button>
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<p>Current room presence derived from Redis presence keys.</p>
|
||||
</div>
|
||||
<button id="reload-users-btn" type="button" class="btn btn-secondary">Reload users</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Room</th>
|
||||
<th>Online</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="users-status" class="status-line" aria-live="polite">Loading active users...</p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Room</th>
|
||||
<th>Online</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="users-status" class="status-line" aria-live="polite">Loading active users...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,273 @@
|
||||
/// <reference types="vitest" />
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin";
|
||||
|
||||
// Mock fetch globally
|
||||
let fetchCalls: { url: string; options?: RequestInit }[] = [];
|
||||
let mockFetchResponse: any = { ok: true, payload: {} };
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCalls = [];
|
||||
globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => {
|
||||
fetchCalls.push({ url, options });
|
||||
const payload = mockFetchResponse.payload ?? {};
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}) as any;
|
||||
|
||||
// Mock localStorage
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: vi.fn((key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, val: string) => storage.set(key, val)),
|
||||
removeItem: vi.fn((key: string) => storage.delete(key)),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock window.confirm/prompt
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
vi.spyOn(window, "prompt").mockReturnValue(null);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Import after mocks are set
|
||||
import "../src/admin";
|
||||
|
||||
describe("admin.ts initialization", () => {
|
||||
it("loads and exposes tab switching", () => {
|
||||
// After import, init() runs on DOMContentLoaded
|
||||
// Test that the document has admin page structure
|
||||
expect(document.body).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload progress helpers", () => {
|
||||
it("renders upload progress correctly", async () => {
|
||||
// Set up DOM for upload page
|
||||
document.body.innerHTML = `
|
||||
<div id="media-upload-progress-wrap" hidden>
|
||||
<progress id="media-upload-progress" max="100" value="0"></progress>
|
||||
<span id="media-upload-progress-text"></span>
|
||||
</div>
|
||||
<div id="tab-content"></div>
|
||||
`;
|
||||
|
||||
// Re-import triggers init again — but we need fresh module state
|
||||
// For now test internal helper access via body
|
||||
const wrap = document.getElementById("media-upload-progress-wrap")!;
|
||||
const bar = document.getElementById("media-upload-progress") as HTMLProgressElement;
|
||||
const text = document.getElementById("media-upload-progress-text")!;
|
||||
|
||||
expect(wrap.hidden).toBe(true);
|
||||
|
||||
// Simulate what setUploadProgress would do
|
||||
wrap.hidden = false;
|
||||
bar.max = 100;
|
||||
bar.value = 50;
|
||||
text.textContent = "Uploading (50%)";
|
||||
|
||||
expect(wrap.hidden).toBe(false);
|
||||
expect(bar.value).toBe(50);
|
||||
expect(text.textContent).toBe("Uploading (50%)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("file fingerprint", () => {
|
||||
it("generates unique fingerprints for different files", () => {
|
||||
const f1 = new File(["a"], "video1.mp4", { lastModified: 1 });
|
||||
const f2 = new File(["b"], "video2.mp4", { lastModified: 2 });
|
||||
|
||||
const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`;
|
||||
const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`;
|
||||
|
||||
expect(fp1).not.toBe(fp2);
|
||||
expect(fp1).toBe("video1.mp4:1:1");
|
||||
expect(fp2).toBe("video2.mp4:1:2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAX_UPLOAD_BYTES constant", () => {
|
||||
it("equals 2GB", () => {
|
||||
expect(2 * 1024 * 1024 * 1024).toBe(2147483648);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API calls on tab load", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="tab-content"><div id="overview-content"></div></div>
|
||||
<nav class="admin-nav">
|
||||
<button data-tab="dashboard">Dashboard</button>
|
||||
<button data-tab="analytics">Analytics</button>
|
||||
<button data-tab="playlist">Playlist</button>
|
||||
<button data-tab="schedule">Schedule</button>
|
||||
<button data-tab="system">System</button>
|
||||
<button data-tab="upload">Upload</button>
|
||||
<button data-tab="users">Users</button>
|
||||
<button data-tab="moderation">Moderation</button>
|
||||
<button data-tab="chat-log">Chat Log</button>
|
||||
</nav>
|
||||
<div id="overview-stats"></div>
|
||||
<div id="analytics-stats"></div>
|
||||
<div id="playlist-editor"></div>
|
||||
<div id="schedule-list"></div>
|
||||
<div id="system-summary"></div>
|
||||
<div id="media-upload-progress-wrap" hidden>
|
||||
<progress id="media-upload-progress"></progress>
|
||||
<span id="media-upload-progress-text"></span>
|
||||
</div>
|
||||
<div id="media-table-body"></div>
|
||||
<div id="users-table-body"></div>
|
||||
<div id="moderation-log-table-body"></div>
|
||||
<div id="chat-log-table-body"></div>
|
||||
<div id="chat-log-pagination"></div>
|
||||
<div id="config-editor"></div>
|
||||
<span id="restart-status"></span>
|
||||
<div id="schedule-override-list"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("can be imported without crashing", () => {
|
||||
// admin.ts initialization should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin data refresh", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="overview-status"></div>
|
||||
<span id="stat-viewers"></span>
|
||||
<span id="stat-stored-messages"></span>
|
||||
<span id="stat-moderation-actions"></span>
|
||||
<span id="stat-media-runtime"></span>
|
||||
<span id="stat-media-count"></span>
|
||||
<span id="system-stored-messages"></span>
|
||||
<span id="system-redis-clients"></span>
|
||||
<span id="system-redis-enabled"></span>
|
||||
<span id="system-redis-memory"></span>
|
||||
<span id="system-db-driver"></span>
|
||||
<span id="system-generated-at"></span>
|
||||
<div id="analytics-status"></div>
|
||||
<span id="analytics-total-messages"></span>
|
||||
<span id="analytics-messages-24h"></span>
|
||||
<span id="analytics-distinct-rooms"></span>
|
||||
<span id="analytics-moderation-actions"></span>
|
||||
<span id="analytics-generated-at"></span>
|
||||
<table><tbody id="analytics-table-body"></tbody></table>
|
||||
`;
|
||||
});
|
||||
|
||||
it("renders overview stats from the admin overview API", async () => {
|
||||
mockFetchResponse = {
|
||||
ok: true,
|
||||
payload: {
|
||||
viewers: 12,
|
||||
storedMessages: 345,
|
||||
moderationActions: 9,
|
||||
mediaTotalRuntime: "01:23:45",
|
||||
mediaCount: 7,
|
||||
redisClients: 4,
|
||||
redisEnabled: true,
|
||||
redisMemoryUsed: "18 MB",
|
||||
databaseDriver: "sqlite",
|
||||
generatedAt: "2026-08-08T12:00:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
await refreshOverview();
|
||||
|
||||
expect(document.getElementById("stat-viewers")?.textContent).toBe("12");
|
||||
expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345");
|
||||
expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes");
|
||||
expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z");
|
||||
});
|
||||
|
||||
it("renders analytics rows from the analytics API", async () => {
|
||||
mockFetchResponse = {
|
||||
ok: true,
|
||||
payload: {
|
||||
totalMessages: 1000,
|
||||
messagesLast24Hours: 75,
|
||||
distinctRooms: 3,
|
||||
moderationActions: 11,
|
||||
generatedAt: "2026-08-08T12:30:00Z",
|
||||
topRooms: [
|
||||
{ room: "main", messageCount: 55 },
|
||||
{ room: "lobby", messageCount: 20 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await refreshAnalytics();
|
||||
|
||||
expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000");
|
||||
expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75");
|
||||
expect(document.getElementById("analytics-table-body")?.textContent).toContain("main");
|
||||
expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("playlist management", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="playlist-status"></div>
|
||||
<textarea id="playlist-content">file '/app/media/demo1.mp4'
|
||||
file '/app/media/demo2.mp4'
|
||||
</textarea>
|
||||
<ul id="playlist-sortable"></ul>
|
||||
<div id="playlist-warnings" hidden></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("saves playlist via PUT request", async () => {
|
||||
mockFetchResponse = { ok: true, payload: {} };
|
||||
|
||||
await savePlaylist();
|
||||
|
||||
const putCall = fetchCalls.find((c) => c.options?.method === "PUT");
|
||||
expect(putCall).toBeDefined();
|
||||
expect(putCall?.url).toBe("/api/admin/playlist");
|
||||
expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" });
|
||||
const body = JSON.parse(putCall?.options?.body as string);
|
||||
expect(body.content).toContain("demo1.mp4");
|
||||
});
|
||||
|
||||
it("sets error status on save failure", async () => {
|
||||
mockFetchResponse = { ok: false, status: 500, payload: {} };
|
||||
|
||||
await savePlaylist();
|
||||
|
||||
const status = document.getElementById("playlist-status");
|
||||
expect(status?.textContent).toContain("Failed");
|
||||
});
|
||||
|
||||
it("renders sortable playlist with indexes and paths", () => {
|
||||
renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n");
|
||||
|
||||
const list = document.getElementById("playlist-sortable")!;
|
||||
expect(list.children).toHaveLength(2);
|
||||
expect(list.innerHTML).toContain("demo1.mp4");
|
||||
expect(list.innerHTML).toContain("demo2.mp4");
|
||||
expect(list.querySelector(".sortable-index")?.textContent).toBe("1");
|
||||
expect(list.querySelector("[data-remove]")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("skips comment and blank lines in sortable", () => {
|
||||
renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n");
|
||||
|
||||
const list = document.getElementById("playlist-sortable")!;
|
||||
expect(list.children).toHaveLength(1);
|
||||
expect(list.innerHTML).toContain("demo1.mp4");
|
||||
});
|
||||
});
|
||||
+152
-249
@@ -19,18 +19,12 @@ type MediaEntry = {
|
||||
};
|
||||
|
||||
const bodyDataset = document.body.dataset;
|
||||
const moderationLogLimit = Number.parseInt(
|
||||
bodyDataset.adminModerationLogLimit || "",
|
||||
10,
|
||||
);
|
||||
const moderationLogLimit = Number.parseInt(bodyDataset.adminModerationLogLimit || "", 10);
|
||||
|
||||
const adminConfig = {
|
||||
defaultModerator: bodyDataset.adminDefaultModerator || "admin",
|
||||
defaultRoom: bodyDataset.adminDefaultRoom || "main",
|
||||
moderationLogLimit:
|
||||
Number.isFinite(moderationLogLimit) && moderationLogLimit > 0
|
||||
? moderationLogLimit
|
||||
: 50,
|
||||
moderationLogLimit: Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 ? moderationLogLimit : 50,
|
||||
};
|
||||
|
||||
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
@@ -43,16 +37,11 @@ const state = {
|
||||
playlistSaving: false,
|
||||
};
|
||||
|
||||
const uploadProgressWrap = () =>
|
||||
document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
||||
const uploadProgressWrap = () => document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
||||
|
||||
const uploadProgressBar = () =>
|
||||
document.getElementById(
|
||||
"media-upload-progress",
|
||||
) as HTMLProgressElement | null;
|
||||
const uploadProgressBar = () => document.getElementById("media-upload-progress") as HTMLProgressElement | null;
|
||||
|
||||
const uploadProgressText = () =>
|
||||
document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
||||
const uploadProgressText = () => document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
||||
|
||||
const setUploadProgress = (value: number, total: number, label: string) => {
|
||||
const wrap = uploadProgressWrap();
|
||||
@@ -73,11 +62,9 @@ const hideUploadProgress = () => {
|
||||
if (wrap) wrap.hidden = true;
|
||||
};
|
||||
|
||||
const getFileFingerprint = (file: File) =>
|
||||
`${file.name}:${file.size}:${file.lastModified}`;
|
||||
const getFileFingerprint = (file: File) => `${file.name}:${file.size}:${file.lastModified}`;
|
||||
|
||||
const getResumeStorageKey = (fingerprint: string) =>
|
||||
`${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
|
||||
const getResumeStorageKey = (fingerprint: string) => `${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
|
||||
|
||||
const getStoredUploadId = (fingerprint: string) => {
|
||||
try {
|
||||
@@ -185,7 +172,7 @@ async function switchTab(tabName: string) {
|
||||
if (tabName === "system") refreshSystemSummary();
|
||||
}
|
||||
|
||||
async function refreshOverview() {
|
||||
export async function refreshOverview() {
|
||||
try {
|
||||
const response = await fetch("/api/admin/overview");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
@@ -211,7 +198,7 @@ async function refreshOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAnalytics() {
|
||||
export async function refreshAnalytics() {
|
||||
setStatus("analytics-status", "Loading analytics...");
|
||||
try {
|
||||
const response = await fetch("/api/admin/analytics");
|
||||
@@ -221,10 +208,7 @@ async function refreshAnalytics() {
|
||||
setText("analytics-total-messages", String(payload.totalMessages ?? 0));
|
||||
setText("analytics-messages-24h", String(payload.messagesLast24Hours ?? 0));
|
||||
setText("analytics-distinct-rooms", String(payload.distinctRooms ?? 0));
|
||||
setText(
|
||||
"analytics-moderation-actions",
|
||||
String(payload.moderationActions ?? 0),
|
||||
);
|
||||
setText("analytics-moderation-actions", String(payload.moderationActions ?? 0));
|
||||
setText("analytics-generated-at", payload.generatedAt || "unavailable");
|
||||
|
||||
const tbody = document.getElementById("analytics-table-body");
|
||||
@@ -260,9 +244,7 @@ async function refreshPlaylist() {
|
||||
|
||||
const payload = await response.json();
|
||||
const content: string = payload.content || "";
|
||||
const textarea = document.getElementById(
|
||||
"playlist-content",
|
||||
) as HTMLTextAreaElement | null;
|
||||
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
||||
if (textarea) textarea.value = content;
|
||||
renderPlaylistSortable(content);
|
||||
await showPlaylistWarnings(content);
|
||||
@@ -273,13 +255,16 @@ async function refreshPlaylist() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlaylistSortable(content: string) {
|
||||
export function renderPlaylistSortable(content: string) {
|
||||
const list = document.getElementById("playlist-sortable");
|
||||
if (!list) return;
|
||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||
list.innerHTML = lines
|
||||
.map((line, i) => {
|
||||
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
|
||||
const path = line
|
||||
.replace(/^file\s+/, "")
|
||||
.replace(/['"]/g, "")
|
||||
.trim();
|
||||
return `<li class="sortable-item" draggable="true" data-index="${i}">
|
||||
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
||||
<span class="sortable-index">${i + 1}</span>
|
||||
@@ -348,12 +333,17 @@ async function showPlaylistWarnings(content: string) {
|
||||
const mediaPaths = new Set((media.files || []).map((f: { path: string }) => f.path));
|
||||
const nonNorm = (media.files || []).filter((f: { status: string }) => f.status === "needs_normalization");
|
||||
for (const line of lines) {
|
||||
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
|
||||
const path = line
|
||||
.replace(/^file\s+/, "")
|
||||
.replace(/['"]/g, "")
|
||||
.trim();
|
||||
if (path && !mediaPaths.has(path)) msgs.push(`Missing from media library: ${path}`);
|
||||
}
|
||||
if (nonNorm.length > 0) msgs.push(`${nonNorm.length} file(s) need normalization. Run scripts/normalize.sh.`);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (msgs.length > 0) {
|
||||
warnings.innerHTML = msgs.map((m) => `<div class="warning-msg">⚠ ${m}</div>`).join("");
|
||||
warnings.hidden = false;
|
||||
@@ -362,15 +352,13 @@ async function showPlaylistWarnings(content: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlaylist() {
|
||||
export async function savePlaylist() {
|
||||
if (state.playlistSaving) return;
|
||||
state.playlistSaving = true;
|
||||
setStatus("playlist-status", "Saving playlist...");
|
||||
|
||||
try {
|
||||
const textarea = document.getElementById(
|
||||
"playlist-content",
|
||||
) as HTMLTextAreaElement | null;
|
||||
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
||||
const content = textarea?.value || "";
|
||||
|
||||
const response = await fetch("/api/admin/playlist", {
|
||||
@@ -415,10 +403,7 @@ async function refreshSchedule() {
|
||||
});
|
||||
}
|
||||
|
||||
setStatus(
|
||||
"schedule-status",
|
||||
`Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`,
|
||||
);
|
||||
setStatus("schedule-status", `Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`);
|
||||
} catch (error) {
|
||||
console.error("Failed to load schedule", error);
|
||||
setStatus("schedule-status", "Failed to load schedule.", true);
|
||||
@@ -445,7 +430,9 @@ async function refreshOverrides() {
|
||||
tbody.querySelectorAll("[data-delete-override]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => deleteOverride(Number((btn as HTMLElement).dataset.deleteOverride)));
|
||||
});
|
||||
} catch { /* non-critical */ }
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOverride(e: Event) {
|
||||
@@ -460,18 +447,37 @@ async function submitOverride(e: Event) {
|
||||
const resp = await fetch("/api/admin/schedule/overrides", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mediaId: Number(mediaId), startTime: new Date(startTime).toISOString(), endTime: new Date(endTime).toISOString(), reason }),
|
||||
body: JSON.stringify({
|
||||
mediaId: Number(mediaId),
|
||||
startTime: new Date(startTime).toISOString(),
|
||||
endTime: new Date(endTime).toISOString(),
|
||||
reason,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) { const text = await resp.text(); throw new Error(text); }
|
||||
if (status) { status.textContent = "Override added."; status.className = "status-line"; }
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = "Override added.";
|
||||
status.className = "status-line";
|
||||
}
|
||||
refreshOverrides();
|
||||
} catch (err) {
|
||||
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
|
||||
if (status) {
|
||||
status.textContent = `Failed: ${err}`;
|
||||
status.className = "status-line status-error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOverride(id: number) {
|
||||
try { await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" }); refreshOverrides(); } catch { /* ignore */ }
|
||||
try {
|
||||
await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" });
|
||||
refreshOverrides();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSystemSummary() {
|
||||
@@ -540,12 +546,8 @@ function renderMediaRows(files: MediaEntry[]) {
|
||||
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () =>
|
||||
editMedia(file),
|
||||
);
|
||||
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () =>
|
||||
deleteMedia(file),
|
||||
);
|
||||
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => editMedia(file));
|
||||
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
@@ -612,18 +614,11 @@ async function uploadVideo(file: File) {
|
||||
|
||||
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
|
||||
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
|
||||
const resumeFrom = Math.min(
|
||||
Math.max(alreadyUploaded, 0),
|
||||
effectiveTotalChunks,
|
||||
);
|
||||
const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks);
|
||||
|
||||
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
|
||||
|
||||
for (
|
||||
let chunkIndex = resumeFrom;
|
||||
chunkIndex < effectiveTotalChunks;
|
||||
chunkIndex += 1
|
||||
) {
|
||||
for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) {
|
||||
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
|
||||
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
|
||||
const chunk = file.slice(start, end);
|
||||
@@ -641,23 +636,13 @@ async function uploadVideo(file: File) {
|
||||
throw new Error(await readErrorMessage(chunkResponse));
|
||||
}
|
||||
|
||||
setUploadProgress(
|
||||
chunkIndex + 1,
|
||||
effectiveTotalChunks,
|
||||
"Uploading chunks",
|
||||
);
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
|
||||
);
|
||||
setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks");
|
||||
setStatus("media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`);
|
||||
}
|
||||
|
||||
const completeResponse = await fetch(
|
||||
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
const completeResponse = await fetch(`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!completeResponse.ok) {
|
||||
throw new Error(await readErrorMessage(completeResponse));
|
||||
@@ -674,13 +659,8 @@ async function uploadVideo(file: File) {
|
||||
await refreshSchedule();
|
||||
} catch (error) {
|
||||
console.error("Failed to upload media", error);
|
||||
const details =
|
||||
error instanceof Error ? error.message : "Unknown upload error.";
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Upload interrupted. Re-select the same file to resume. (${details})`,
|
||||
true,
|
||||
);
|
||||
const details = error instanceof Error ? error.message : "Unknown upload error.";
|
||||
setStatus("media-status", `Upload interrupted. Re-select the same file to resume. (${details})`, true);
|
||||
} finally {
|
||||
state.mediaUploading = false;
|
||||
setTimeout(() => hideUploadProgress(), 1200);
|
||||
@@ -749,9 +729,7 @@ async function deleteMedia(file: MediaEntry) {
|
||||
async function refreshModerationLogs() {
|
||||
setStatus("moderation-status", "Loading moderation logs...");
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`,
|
||||
);
|
||||
const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
@@ -787,10 +765,8 @@ async function submitModerationAction(event: Event) {
|
||||
const payload = {
|
||||
action: (document.getElementById("mod-action") as HTMLSelectElement).value,
|
||||
target: (document.getElementById("mod-target") as HTMLInputElement).value,
|
||||
moderator: (document.getElementById("mod-moderator") as HTMLInputElement)
|
||||
.value,
|
||||
reason: (document.getElementById("mod-reason") as HTMLTextAreaElement)
|
||||
.value,
|
||||
moderator: (document.getElementById("mod-moderator") as HTMLInputElement).value,
|
||||
reason: (document.getElementById("mod-reason") as HTMLTextAreaElement).value,
|
||||
room: (document.getElementById("mod-room") as HTMLInputElement).value,
|
||||
};
|
||||
|
||||
@@ -865,10 +841,7 @@ async function refreshChatLog() {
|
||||
renderChatLogTable();
|
||||
updateChatLogPagination();
|
||||
updateBulkDeleteButton();
|
||||
setStatus(
|
||||
"chat-log-status",
|
||||
`Loaded ${chatLogState.entries.length} entries.`,
|
||||
);
|
||||
setStatus("chat-log-status", `Loaded ${chatLogState.entries.length} entries.`);
|
||||
} catch (error) {
|
||||
console.error("Failed to load chat log", error);
|
||||
setStatus("chat-log-status", "Failed to load chat log.", true);
|
||||
@@ -908,10 +881,7 @@ function renderChatLogTable() {
|
||||
tr.querySelector('[data-action="chat-ban"]')?.addEventListener("click", () => {
|
||||
if (confirm(`Ban ${entry.username}?`)) userAction("ban", entry.username);
|
||||
});
|
||||
tr.querySelector('[data-action="delete-chat"]')?.addEventListener(
|
||||
"click",
|
||||
() => deleteChatEntry(entry.id),
|
||||
);
|
||||
tr.querySelector('[data-action="delete-chat"]')?.addEventListener("click", () => deleteChatEntry(entry.id));
|
||||
tr.querySelector(".chat-log-checkbox")?.addEventListener("change", (e) => {
|
||||
const cb = e.target as HTMLInputElement;
|
||||
if (cb.checked) {
|
||||
@@ -929,38 +899,27 @@ function renderChatLogTable() {
|
||||
}
|
||||
|
||||
function updateSelectAllCheckbox() {
|
||||
const selectAll = document.getElementById(
|
||||
"chat-log-select-all",
|
||||
) as HTMLInputElement | null;
|
||||
const selectAll = document.getElementById("chat-log-select-all") as HTMLInputElement | null;
|
||||
if (!selectAll) return;
|
||||
const allSelected =
|
||||
chatLogState.entries.length > 0 &&
|
||||
chatLogState.entries.every((e) => chatLogState.selected.has(e.id));
|
||||
chatLogState.entries.length > 0 && chatLogState.entries.every((e) => chatLogState.selected.has(e.id));
|
||||
selectAll.checked = allSelected;
|
||||
}
|
||||
|
||||
function updateBulkDeleteButton() {
|
||||
const btn = document.getElementById(
|
||||
"bulk-delete-chat-log-btn",
|
||||
) as HTMLButtonElement | null;
|
||||
const btn = document.getElementById("bulk-delete-chat-log-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.disabled = chatLogState.selected.size === 0;
|
||||
}
|
||||
|
||||
function updateChatLogPagination() {
|
||||
const prevBtn = document.getElementById(
|
||||
"chat-log-prev-btn",
|
||||
) as HTMLButtonElement | null;
|
||||
const nextBtn = document.getElementById(
|
||||
"chat-log-next-btn",
|
||||
) as HTMLButtonElement | null;
|
||||
const prevBtn = document.getElementById("chat-log-prev-btn") as HTMLButtonElement | null;
|
||||
const nextBtn = document.getElementById("chat-log-next-btn") as HTMLButtonElement | null;
|
||||
const info = document.getElementById("chat-log-page-info");
|
||||
|
||||
if (prevBtn) prevBtn.disabled = !chatLogState.before;
|
||||
if (nextBtn)
|
||||
nextBtn.disabled = chatLogState.entries.length < chatLogState.limit;
|
||||
if (info)
|
||||
info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`;
|
||||
if (nextBtn) nextBtn.disabled = chatLogState.entries.length < chatLogState.limit;
|
||||
if (info) info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`;
|
||||
}
|
||||
|
||||
function chatLogNextPage() {
|
||||
@@ -975,29 +934,17 @@ function chatLogPrevPage() {
|
||||
}
|
||||
|
||||
function chatLogApplyFilter() {
|
||||
chatLogState.room =
|
||||
(document.getElementById("chat-log-filter-room") as HTMLInputElement)
|
||||
?.value || "";
|
||||
chatLogState.username =
|
||||
(document.getElementById("chat-log-filter-username") as HTMLInputElement)
|
||||
?.value || "";
|
||||
chatLogState.text =
|
||||
(document.getElementById("chat-log-filter-text") as HTMLInputElement)
|
||||
?.value || "";
|
||||
chatLogState.room = (document.getElementById("chat-log-filter-room") as HTMLInputElement)?.value || "";
|
||||
chatLogState.username = (document.getElementById("chat-log-filter-username") as HTMLInputElement)?.value || "";
|
||||
chatLogState.text = (document.getElementById("chat-log-filter-text") as HTMLInputElement)?.value || "";
|
||||
chatLogState.before = "";
|
||||
refreshChatLog();
|
||||
}
|
||||
|
||||
function chatLogClearFilter() {
|
||||
const roomEl = document.getElementById(
|
||||
"chat-log-filter-room",
|
||||
) as HTMLInputElement | null;
|
||||
const userEl = document.getElementById(
|
||||
"chat-log-filter-username",
|
||||
) as HTMLInputElement | null;
|
||||
const textEl = document.getElementById(
|
||||
"chat-log-filter-text",
|
||||
) as HTMLInputElement | null;
|
||||
const roomEl = document.getElementById("chat-log-filter-room") as HTMLInputElement | null;
|
||||
const userEl = document.getElementById("chat-log-filter-username") as HTMLInputElement | null;
|
||||
const textEl = document.getElementById("chat-log-filter-text") as HTMLInputElement | null;
|
||||
if (roomEl) roomEl.value = "";
|
||||
if (userEl) userEl.value = "";
|
||||
if (textEl) textEl.value = "";
|
||||
@@ -1009,8 +956,7 @@ function chatLogClearFilter() {
|
||||
}
|
||||
|
||||
async function deleteChatEntry(id: number) {
|
||||
if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`))
|
||||
return;
|
||||
if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`)) return;
|
||||
|
||||
setStatus("chat-log-status", `Deleting message #${id}...`);
|
||||
try {
|
||||
@@ -1030,17 +976,9 @@ async function deleteChatEntry(id: number) {
|
||||
async function bulkDeleteChatLog() {
|
||||
if (chatLogState.selected.size === 0) return;
|
||||
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (!window.confirm(`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`)) return;
|
||||
|
||||
setStatus(
|
||||
"chat-log-status",
|
||||
`Deleting ${chatLogState.selected.size} messages...`,
|
||||
);
|
||||
setStatus("chat-log-status", `Deleting ${chatLogState.selected.size} messages...`);
|
||||
try {
|
||||
const ids = Array.from(chatLogState.selected);
|
||||
const response = await fetch("/api/admin/chat-log/bulk", {
|
||||
@@ -1050,10 +988,7 @@ async function bulkDeleteChatLog() {
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
setStatus(
|
||||
"chat-log-status",
|
||||
`${payload.deleted || ids.length} messages deleted.`,
|
||||
);
|
||||
setStatus("chat-log-status", `${payload.deleted || ids.length} messages deleted.`);
|
||||
await refreshChatLog();
|
||||
await refreshOverview();
|
||||
} catch (error) {
|
||||
@@ -1088,17 +1023,23 @@ async function refreshUsers() {
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbody.querySelectorAll(".user-mute").forEach(b => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-ban").forEach(b => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-unmute").forEach(b => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-unban").forEach(b => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!)));
|
||||
tbody
|
||||
.querySelectorAll(".user-mute")
|
||||
.forEach((b) => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
|
||||
tbody
|
||||
.querySelectorAll(".user-ban")
|
||||
.forEach((b) => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!)));
|
||||
tbody
|
||||
.querySelectorAll(".user-unmute")
|
||||
.forEach((b) => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!)));
|
||||
tbody
|
||||
.querySelectorAll(".user-unban")
|
||||
.forEach((b) => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!)));
|
||||
}
|
||||
|
||||
setStatus(
|
||||
"users-status",
|
||||
payload.redisEnabled
|
||||
? "Active users loaded."
|
||||
: "Redis disabled; no live presence data available.",
|
||||
payload.redisEnabled ? "Active users loaded." : "Redis disabled; no live presence data available.",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load users", error);
|
||||
@@ -1127,7 +1068,9 @@ async function loadConfig() {
|
||||
const cfg = await resp.json();
|
||||
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
||||
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
||||
} catch { /* non-critical */ }
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(e: Event) {
|
||||
@@ -1144,9 +1087,15 @@ async function saveConfig(e: Event) {
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
if (status) { status.textContent = "Config saved."; status.className = "status-line"; }
|
||||
if (status) {
|
||||
status.textContent = "Config saved.";
|
||||
status.className = "status-line";
|
||||
}
|
||||
} catch (err) {
|
||||
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
|
||||
if (status) {
|
||||
status.textContent = `Failed: ${err}`;
|
||||
status.className = "status-line status-error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,14 +1113,12 @@ async function restartService(service: string) {
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>(".tab-trigger")
|
||||
.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const tab = button.dataset.tab;
|
||||
if (tab) switchTab(tab);
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".tab-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const tab = button.dataset.tab;
|
||||
if (tab) switchTab(tab);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
@@ -1186,17 +1133,11 @@ function bindEvents() {
|
||||
function bindPageEvents(tabName: string) {
|
||||
switch (tabName) {
|
||||
case "upload":
|
||||
document
|
||||
.getElementById("reload-media-btn")
|
||||
?.addEventListener("click", refreshMediaLibrary);
|
||||
document.getElementById("reload-media-btn")?.addEventListener("click", refreshMediaLibrary);
|
||||
|
||||
const uploadInput = document.getElementById(
|
||||
"video-upload",
|
||||
) as HTMLInputElement | null;
|
||||
const uploadInput = document.getElementById("video-upload") as HTMLInputElement | null;
|
||||
const uploadDropzone = document.getElementById("upload-dropzone");
|
||||
document
|
||||
.getElementById("upload-trigger-btn")
|
||||
?.addEventListener("click", () => uploadInput?.click());
|
||||
document.getElementById("upload-trigger-btn")?.addEventListener("click", () => uploadInput?.click());
|
||||
|
||||
uploadDropzone?.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
@@ -1221,99 +1162,61 @@ function bindPageEvents(tabName: string) {
|
||||
break;
|
||||
|
||||
case "playlist":
|
||||
document
|
||||
.getElementById("reload-playlist-btn")
|
||||
?.addEventListener("click", refreshPlaylist);
|
||||
document
|
||||
.getElementById("save-playlist-btn")
|
||||
?.addEventListener("click", savePlaylist);
|
||||
document.getElementById("reload-playlist-btn")?.addEventListener("click", refreshPlaylist);
|
||||
document.getElementById("save-playlist-btn")?.addEventListener("click", savePlaylist);
|
||||
break;
|
||||
|
||||
case "schedule":
|
||||
document
|
||||
.getElementById("reload-schedule-btn")
|
||||
?.addEventListener("click", refreshSchedule);
|
||||
document
|
||||
.getElementById("override-form")
|
||||
?.addEventListener("submit", submitOverride);
|
||||
document.getElementById("reload-schedule-btn")?.addEventListener("click", refreshSchedule);
|
||||
document.getElementById("override-form")?.addEventListener("submit", submitOverride);
|
||||
refreshOverrides();
|
||||
break;
|
||||
|
||||
case "moderation":
|
||||
document
|
||||
.getElementById("reload-moderation-btn")
|
||||
?.addEventListener("click", refreshModerationLogs);
|
||||
document
|
||||
.getElementById("export-chat-log-btn")
|
||||
?.addEventListener("click", () => {
|
||||
window.open("/api/admin/chat-log/export", "_blank");
|
||||
});
|
||||
document
|
||||
.getElementById("moderation-form")
|
||||
?.addEventListener("submit", submitModerationAction);
|
||||
(document.getElementById("mod-moderator") as HTMLInputElement).value =
|
||||
adminConfig.defaultModerator;
|
||||
(document.getElementById("mod-room") as HTMLInputElement).value =
|
||||
adminConfig.defaultRoom;
|
||||
document.getElementById("reload-moderation-btn")?.addEventListener("click", refreshModerationLogs);
|
||||
document.getElementById("export-chat-log-btn")?.addEventListener("click", () => {
|
||||
window.open("/api/admin/chat-log/export", "_blank");
|
||||
});
|
||||
document.getElementById("moderation-form")?.addEventListener("submit", submitModerationAction);
|
||||
(document.getElementById("mod-moderator") as HTMLInputElement).value = adminConfig.defaultModerator;
|
||||
(document.getElementById("mod-room") as HTMLInputElement).value = adminConfig.defaultRoom;
|
||||
break;
|
||||
|
||||
case "chat-log":
|
||||
document
|
||||
.getElementById("reload-chat-log-btn")
|
||||
?.addEventListener("click", refreshChatLog);
|
||||
document
|
||||
.getElementById("bulk-delete-chat-log-btn")
|
||||
?.addEventListener("click", bulkDeleteChatLog);
|
||||
document
|
||||
.getElementById("chat-log-filter-btn")
|
||||
?.addEventListener("click", chatLogApplyFilter);
|
||||
document
|
||||
.getElementById("chat-log-clear-filter-btn")
|
||||
?.addEventListener("click", chatLogClearFilter);
|
||||
document
|
||||
.getElementById("chat-log-prev-btn")
|
||||
?.addEventListener("click", chatLogPrevPage);
|
||||
document
|
||||
.getElementById("chat-log-next-btn")
|
||||
?.addEventListener("click", chatLogNextPage);
|
||||
document
|
||||
.getElementById("chat-log-select-all")
|
||||
?.addEventListener("change", (e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
chatLogState.entries.forEach((entry) => {
|
||||
if (checked) {
|
||||
chatLogState.selected.add(entry.id);
|
||||
} else {
|
||||
chatLogState.selected.delete(entry.id);
|
||||
}
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
renderChatLogTable();
|
||||
document.getElementById("reload-chat-log-btn")?.addEventListener("click", refreshChatLog);
|
||||
document.getElementById("bulk-delete-chat-log-btn")?.addEventListener("click", bulkDeleteChatLog);
|
||||
document.getElementById("chat-log-filter-btn")?.addEventListener("click", chatLogApplyFilter);
|
||||
document.getElementById("chat-log-clear-filter-btn")?.addEventListener("click", chatLogClearFilter);
|
||||
document.getElementById("chat-log-prev-btn")?.addEventListener("click", chatLogPrevPage);
|
||||
document.getElementById("chat-log-next-btn")?.addEventListener("click", chatLogNextPage);
|
||||
document.getElementById("chat-log-select-all")?.addEventListener("change", (e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
chatLogState.entries.forEach((entry) => {
|
||||
if (checked) {
|
||||
chatLogState.selected.add(entry.id);
|
||||
} else {
|
||||
chatLogState.selected.delete(entry.id);
|
||||
}
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
renderChatLogTable();
|
||||
});
|
||||
break;
|
||||
|
||||
case "analytics":
|
||||
document
|
||||
.getElementById("reload-analytics-btn")
|
||||
?.addEventListener("click", refreshAnalytics);
|
||||
document.getElementById("reload-analytics-btn")?.addEventListener("click", refreshAnalytics);
|
||||
break;
|
||||
|
||||
case "users":
|
||||
document
|
||||
.getElementById("reload-users-btn")
|
||||
?.addEventListener("click", refreshUsers);
|
||||
document.getElementById("reload-users-btn")?.addEventListener("click", refreshUsers);
|
||||
break;
|
||||
|
||||
case "system":
|
||||
document.getElementById("reload-system-btn")?.addEventListener("click", refreshSystemSummary);
|
||||
document.getElementById("config-form")?.addEventListener("submit", saveConfig);
|
||||
document
|
||||
.getElementById("reload-system-btn")
|
||||
?.addEventListener("click", refreshSystemSummary);
|
||||
document
|
||||
.getElementById("config-form")
|
||||
?.addEventListener("submit", saveConfig);
|
||||
document.querySelectorAll(".restart-btn").forEach((btn) =>
|
||||
btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!))
|
||||
);
|
||||
.querySelectorAll(".restart-btn")
|
||||
.forEach((btn) => btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!)));
|
||||
loadConfig();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export function setupCounter(element: HTMLButtonElement) {
|
||||
let counter = 0
|
||||
let counter = 0;
|
||||
const setCounter = (count: number) => {
|
||||
counter = count
|
||||
element.innerHTML = `Count is ${counter}`
|
||||
}
|
||||
element.addEventListener('click', () => setCounter(counter + 1))
|
||||
setCounter(0)
|
||||
counter = count;
|
||||
element.innerHTML = `Count is ${counter}`;
|
||||
};
|
||||
element.addEventListener("click", () => setCounter(counter + 1));
|
||||
setCounter(0);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/// <reference types="vitest" />
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { resetViewerStateForTests, resolveManifestUrl, resolveWebSocketBaseUrl } from "../src/main";
|
||||
|
||||
const hlsInstance = {
|
||||
loadSource: vi.fn(),
|
||||
attachMedia: vi.fn(),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
|
||||
const HlsMock = Object.assign(
|
||||
vi.fn().mockImplementation(() => hlsInstance),
|
||||
{
|
||||
isSupported: vi.fn(() => true),
|
||||
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
|
||||
},
|
||||
);
|
||||
|
||||
// Mock hls.js
|
||||
vi.mock("hls.js", () => ({
|
||||
default: HlsMock,
|
||||
}));
|
||||
|
||||
// Mock WebSocket
|
||||
const websocketInstances: MockWebSocket[] = [];
|
||||
class MockWebSocket {
|
||||
url: string;
|
||||
readyState = 0;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
send = vi.fn();
|
||||
close = vi.fn();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = String(url);
|
||||
websocketInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress console noise
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
let origWebSocket: any;
|
||||
let origFetch: any;
|
||||
|
||||
beforeEach(() => {
|
||||
origWebSocket = (globalThis as any).WebSocket;
|
||||
origFetch = globalThis.fetch;
|
||||
(globalThis as any).WebSocket = MockWebSocket;
|
||||
websocketInstances.length = 0;
|
||||
resetViewerStateForTests();
|
||||
|
||||
// Mock fetch (schedule, etc.) — return empty schedule
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ entries: [], total: 0, generatedAt: "" }),
|
||||
});
|
||||
|
||||
// Set up DOM from index.html
|
||||
document.body.innerHTML = `
|
||||
<main class="viewer-shell">
|
||||
<header class="viewer-header">
|
||||
<button id="chat-toggle" type="button">Hide Chat</button>
|
||||
</header>
|
||||
<section class="viewer-content">
|
||||
<section class="player-stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing">
|
||||
<span id="np-title">Loading...</span>
|
||||
<span id="np-next" class="np-next"></span>
|
||||
</div>
|
||||
</section>
|
||||
<aside id="chat-panel" class="chat-sidebar">
|
||||
<div class="chat-header">
|
||||
<span id="room-name">Main Channel</span>
|
||||
<button type="button" onclick="togglePrivateDM()">New DM</button>
|
||||
</div>
|
||||
<section id="messages" class="chat-messages"></section>
|
||||
<section id="presence-panel" class="presence-panel">
|
||||
<div id="presence-list" class="presence-list"></div>
|
||||
</section>
|
||||
<div class="chat-input-area">
|
||||
<input id="message-input" type="text" placeholder="Type a message..." />
|
||||
<button type="button" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
|
||||
// Stub prompt for DM
|
||||
(globalThis as any).prompt = vi.fn();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).WebSocket = origWebSocket;
|
||||
globalThis.fetch = origFetch;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("player startup", () => {
|
||||
it("starts native HLS playback on loadedmetadata", async () => {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
vi.spyOn(video, "canPlayType").mockReturnValue("probably");
|
||||
const playSpy = vi.spyOn(video, "play").mockResolvedValue(undefined as any);
|
||||
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat());
|
||||
|
||||
expect(video.src).toContain("/hls/playlist.m3u8?stream=");
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
expect(playSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts hls.js playback when native HLS is unavailable", async () => {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
Object.defineProperty(video, "canPlayType", {
|
||||
value: vi.fn(() => ""),
|
||||
configurable: true,
|
||||
});
|
||||
vi.spyOn(video, "play").mockResolvedValue(undefined as any);
|
||||
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
expect(HlsMock.isSupported).toHaveBeenCalled();
|
||||
expect(HlsMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat toggle", () => {
|
||||
it("toggles chat panel visibility", () => {
|
||||
const toggle = document.getElementById("chat-toggle")!;
|
||||
|
||||
expect(toggle.textContent).toBe("Hide Chat");
|
||||
|
||||
window.toggleChatPanel();
|
||||
expect(toggle.textContent).toBe("Show Chat");
|
||||
|
||||
window.toggleChatPanel();
|
||||
expect(toggle.textContent).toBe("Hide Chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("clears input after sending non-empty text", () => {
|
||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||
input.value = "hello!";
|
||||
|
||||
window.sendMessage();
|
||||
expect(input.value).toBe("hello!");
|
||||
});
|
||||
|
||||
it("does nothing with empty input", () => {
|
||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||
input.value = " ";
|
||||
window.sendMessage();
|
||||
// No error thrown = pass
|
||||
});
|
||||
});
|
||||
|
||||
describe("switchRoom", () => {
|
||||
it("updates room name on switch", () => {
|
||||
window.switchRoom("lobby");
|
||||
const roomName = document.getElementById("room-name")!;
|
||||
expect(roomName.textContent).toBe("Lobby Room");
|
||||
});
|
||||
|
||||
it("shows Main Channel for main room", () => {
|
||||
window.switchRoom("main");
|
||||
const roomName = document.getElementById("room-name")!;
|
||||
expect(roomName.textContent).toBe("Main Channel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("togglePrivateDM", () => {
|
||||
it("prompts for username and switches room", () => {
|
||||
// Stub prompt to return a value this time
|
||||
vi.spyOn(window, "prompt").mockReturnValue("bob");
|
||||
|
||||
const switchRoomSpy = vi.spyOn(window, "switchRoom");
|
||||
window.togglePrivateDM();
|
||||
|
||||
expect(window.prompt).toHaveBeenCalled();
|
||||
expect(switchRoomSpy).not.toHaveBeenCalled(); // username is "viewerNNN" which gets set randomly
|
||||
|
||||
switchRoomSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket reconnect", () => {
|
||||
it("reconnects after onclose", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
window.switchRoom("lobby");
|
||||
expect(websocketInstances).toHaveLength(1);
|
||||
|
||||
websocketInstances[0].onclose?.();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
expect(websocketInstances).toHaveLength(2);
|
||||
expect(websocketInstances[1].url).toContain("room=lobby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL resolution", () => {
|
||||
it("falls back to same-origin manifest and websocket URLs", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_URL", "");
|
||||
vi.stubEnv("VITE_PUBLIC_WS_URL", "");
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "");
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
value: new URL("https://linearvoid.tv/viewer"),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||
expect(resolveWebSocketBaseUrl()).toBe("wss://linearvoid.tv/ws");
|
||||
});
|
||||
|
||||
it("preserves an allowed cross-origin manifest URL", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "true");
|
||||
|
||||
expect(resolveManifestUrl()).toMatch(/^https:\/\/media\.linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||
});
|
||||
|
||||
it("falls back to same-origin manifest when cross-origin is not allowed", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "false");
|
||||
|
||||
expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||
});
|
||||
|
||||
it("uses configured websocket URL when present", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_WS_URL", "wss://chat.linearvoid.tv/ws");
|
||||
|
||||
expect(resolveWebSocketBaseUrl()).toBe("wss://chat.linearvoid.tv/ws");
|
||||
});
|
||||
});
|
||||
|
||||
describe("presence updates", () => {
|
||||
it("adds user to presence list on join system message", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||
|
||||
const presenceList = document.getElementById("presence-list")!;
|
||||
expect(presenceList.textContent).toContain("alice");
|
||||
});
|
||||
|
||||
it("removes user from presence list on leave system message", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
|
||||
|
||||
const presenceList = document.getElementById("presence-list")!;
|
||||
expect(presenceList.textContent).not.toContain("alice");
|
||||
});
|
||||
|
||||
it("renders multiple users sorted", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "zoe joined the room", username: "system" }) });
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||
|
||||
const presenceList = document.getElementById("presence-list")!;
|
||||
const text = presenceList.textContent ?? "";
|
||||
const aliceIdx = text.indexOf("alice");
|
||||
const zoeIdx = text.indexOf("zoe");
|
||||
expect(aliceIdx).toBeLessThan(zoeIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("private DM flow", () => {
|
||||
it("creates dm: room name from sorted usernames", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
vi.spyOn(window, "prompt").mockReturnValue("bob");
|
||||
window.togglePrivateDM();
|
||||
|
||||
const roomName = document.getElementById("room-name")!;
|
||||
// username is "viewer" (reset by tests), bob is target -> sorted = "bob-viewer"
|
||||
// switchRoom capitalizes first char: "Dm:bob-viewer Room"
|
||||
expect(roomName.textContent).toContain("bob-viewer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("message rendering", () => {
|
||||
it("renders system messages", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "Server restarting", username: "system" }) });
|
||||
|
||||
const messages = document.getElementById("messages")!;
|
||||
expect(messages.innerHTML).toContain("Server restarting");
|
||||
expect(messages.querySelector(".message-system")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders self messages as message-self", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({ type: "public", text: "hello world", username: "viewer", timestamp: Date.now() }),
|
||||
});
|
||||
|
||||
const messages = document.getElementById("messages")!;
|
||||
expect(messages.innerHTML).toContain("hello world");
|
||||
expect(messages.querySelector(".message-self")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders other-user messages as message-other", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({ type: "public", text: "hey", username: "alice", timestamp: Date.now() }),
|
||||
});
|
||||
|
||||
const messages = document.getElementById("messages")!;
|
||||
expect(messages.innerHTML).toContain("alice");
|
||||
expect(messages.innerHTML).toContain("hey");
|
||||
expect(messages.querySelector(".message-other")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+21
-34
@@ -18,7 +18,16 @@ let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
let chatVisible = true;
|
||||
const onlineUsers = new Set<string>();
|
||||
|
||||
function resolveWebSocketBaseUrl() {
|
||||
export function resetViewerStateForTests() {
|
||||
hls = null;
|
||||
ws = null;
|
||||
currentRoom = "main";
|
||||
username = "viewer";
|
||||
chatVisible = true;
|
||||
onlineUsers.clear();
|
||||
}
|
||||
|
||||
export function resolveWebSocketBaseUrl() {
|
||||
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
||||
|
||||
if (configuredUrl) {
|
||||
@@ -29,27 +38,19 @@ function resolveWebSocketBaseUrl() {
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
function resolveManifestUrl() {
|
||||
export function resolveManifestUrl() {
|
||||
const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
|
||||
const cacheBust = `stream=${Date.now()}`;
|
||||
const sameOriginManifestUrl = new URL(
|
||||
"/hls/playlist.m3u8",
|
||||
window.location.origin,
|
||||
);
|
||||
const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin);
|
||||
sameOriginManifestUrl.searchParams.set("stream", cacheBust);
|
||||
|
||||
const allowCrossOriginManifest =
|
||||
import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() ===
|
||||
"true";
|
||||
const allowCrossOriginManifest = import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === "true";
|
||||
|
||||
if (configuredUrl) {
|
||||
const manifestUrl = new URL(configuredUrl);
|
||||
manifestUrl.searchParams.set("stream", cacheBust);
|
||||
|
||||
if (
|
||||
!allowCrossOriginManifest &&
|
||||
manifestUrl.origin !== window.location.origin
|
||||
) {
|
||||
if (!allowCrossOriginManifest && manifestUrl.origin !== window.location.origin) {
|
||||
return sameOriginManifestUrl.toString();
|
||||
}
|
||||
|
||||
@@ -96,12 +97,7 @@ function connectWebSocket(room: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function addMessage(
|
||||
type: string,
|
||||
text: string,
|
||||
sender = "",
|
||||
timestamp = new Date(),
|
||||
) {
|
||||
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
|
||||
const messages = document.getElementById("messages")!;
|
||||
const div = document.createElement("div");
|
||||
div.className = "message-row";
|
||||
@@ -110,9 +106,7 @@ function addMessage(
|
||||
div.classList.add("message-system-row");
|
||||
div.innerHTML = `<div class="message-system">${text}</div>`;
|
||||
} else {
|
||||
div.classList.add(
|
||||
sender === username ? "message-self-row" : "message-other-row",
|
||||
);
|
||||
div.classList.add(sender === username ? "message-self-row" : "message-other-row");
|
||||
div.innerHTML = `
|
||||
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
|
||||
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
|
||||
@@ -131,10 +125,7 @@ function renderPresence() {
|
||||
if (!list) return;
|
||||
const sorted = [...onlineUsers].sort();
|
||||
list.innerHTML = sorted
|
||||
.map(
|
||||
(u) =>
|
||||
`<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`,
|
||||
)
|
||||
.map((u) => `<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
@@ -157,7 +148,7 @@ async function refreshSchedule() {
|
||||
}
|
||||
}
|
||||
|
||||
async function initPlayerAndChat() {
|
||||
export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: HlsClass }> = () => import("hls.js")) {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
const manifestUrl = resolveManifestUrl();
|
||||
|
||||
@@ -166,7 +157,7 @@ async function initPlayerAndChat() {
|
||||
video.addEventListener("loadedmetadata", () => video.play());
|
||||
} else {
|
||||
try {
|
||||
const { default: Hls } = await import("hls.js");
|
||||
const { default: Hls } = await loadHlsModule();
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(manifestUrl);
|
||||
@@ -185,9 +176,7 @@ async function initPlayerAndChat() {
|
||||
refreshSchedule();
|
||||
setInterval(refreshSchedule, 30000);
|
||||
|
||||
console.log(
|
||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||
);
|
||||
console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
|
||||
}
|
||||
|
||||
function toggleChatPanel() {
|
||||
@@ -220,9 +209,7 @@ function sendMessage() {
|
||||
function switchRoom(room: string) {
|
||||
currentRoom = room;
|
||||
document.getElementById("room-name")!.textContent =
|
||||
room === "main"
|
||||
? "Main Channel"
|
||||
: room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
||||
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
||||
addMessage("system", `Switched to ${room} room`);
|
||||
connectWebSocket(room);
|
||||
}
|
||||
|
||||
+7
-17
@@ -1,8 +1,7 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--font-body:
|
||||
"Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
--font-body: "Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
||||
|
||||
@@ -118,12 +117,7 @@ body {
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink-0);
|
||||
background: radial-gradient(
|
||||
circle at 20% 10%,
|
||||
var(--bg-grad-a) 0%,
|
||||
var(--bg-grad-b) 50%,
|
||||
var(--bg-grad-c) 100%
|
||||
);
|
||||
background: radial-gradient(circle at 20% 10%, var(--bg-grad-a) 0%, var(--bg-grad-b) 50%, var(--bg-grad-c) 100%);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@@ -262,7 +256,7 @@ select {
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-1);
|
||||
padding: 0.62rem 0.9rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -411,14 +405,14 @@ select {
|
||||
|
||||
.lv-body {
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-shell {
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
@@ -467,11 +461,7 @@ select {
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--brand-contrast);
|
||||
background: linear-gradient(
|
||||
140deg,
|
||||
var(--badge-start) 0%,
|
||||
var(--badge-end) 100%
|
||||
);
|
||||
background: linear-gradient(140deg, var(--badge-start) 0%, var(--badge-end) 100%);
|
||||
}
|
||||
|
||||
.brand-text,
|
||||
@@ -821,7 +811,7 @@ select {
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--panel);
|
||||
padding: var(--space-4);
|
||||
padding: var(--space-2);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -1,25 +1,25 @@
|
||||
# Cloudflare Cache Rules for Linear Void (per ADR-001)
|
||||
|
||||
## Recommended Cache Rules for media.example.com
|
||||
## Recommended Cache Rules for media.linearvoid.tv
|
||||
|
||||
1. **.ts segments (immutable)**
|
||||
- Expression: (http.host eq "media.example.com" and http.request.uri.path contains ".ts")
|
||||
1. **.m4s and .ts segments (immutable)**
|
||||
- Expression: (http.host eq "media.linearvoid.tv" and (http.request.uri.path contains ".ts" or http.request.uri.path contains ".m4s"))
|
||||
- Cache status: Eligible for cache
|
||||
- Edge Cache TTL: 1 month (or `Cache-Control: public, max-age=31536000, immutable` from origin)
|
||||
- Cache Everything: Yes
|
||||
|
||||
2. **.m3u8 manifests (live)**
|
||||
- Expression: (http.host eq "media.example.com" and http.request.uri.path contains ".m3u8")
|
||||
- Expression: (http.host eq "media.linearvoid.tv" and http.request.uri.path contains ".m3u8")
|
||||
- Cache status: Eligible for cache
|
||||
- Edge Cache TTL: 10 seconds
|
||||
- Cache-Control: public, max-age=10, must-revalidate
|
||||
- Edge Cache TTL: 2 seconds
|
||||
- Cache-Control: public, max-age=2, must-revalidate
|
||||
|
||||
3. **Static assets**
|
||||
- Expression: (http.host eq "example.com" and http.request.uri.path contains ".js" or http.request.uri.path contains ".css")
|
||||
- Expression: (http.host eq "linearvoid.tv" and http.request.uri.path contains ".js" or http.request.uri.path contains ".css")
|
||||
- Edge Cache TTL: 1 year (with hash in filename for immutable caching)
|
||||
|
||||
**Tiered Cache**: Enable Smart Tiered Cache for R2 custom domain to reduce origin requests.
|
||||
|
||||
**WAF**: Enable basic rate limiting and bot management for /ws and admin routes.
|
||||
|
||||
These rules ensure high cache hit ratio for .ts segments while keeping live manifests fresh. Monitor cache hit ratio in Cloudflare analytics.
|
||||
These rules ensure high cache hit ratio for .m4s and .ts segments while keeping live manifests fresh. Monitor cache hit ratio in Cloudflare analytics.
|
||||
@@ -6,8 +6,8 @@ addEventListener("fetch", (event) => {
|
||||
async function handleRequest(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Add immutable cache headers for .ts segments
|
||||
if (url.pathname.endsWith(".ts")) {
|
||||
// Add immutable cache headers for .m4s segments
|
||||
if (url.pathname.endsWith(".m4s")) {
|
||||
const response = await fetch(request);
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
|
||||
@@ -34,6 +34,33 @@
|
||||
"title": "Chat Scrape Health",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "up{job=\"chat\"}" }]
|
||||
},
|
||||
{
|
||||
"title": "Cache Hit Ratio",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "linearvoid_cache_hit_ratio"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Redis Memory Usage",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "linearvoid_chat_redis_memory_usage_ratio"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Chat Request Latency p95",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(linearvoid_chat_request_duration_seconds_bucket[5m])) by (le))"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,13 +38,13 @@ groups:
|
||||
description: "Playout has switched to emergency slate. Check playout logs."
|
||||
|
||||
- alert: HighChatLatency
|
||||
expr: rate(linearvoid_chat_messages_total[5m]) < 0.01
|
||||
expr: histogram_quantile(0.95, sum(rate(linearvoid_chat_request_duration_seconds_bucket[5m])) by (le)) > 1
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Unusually low chat message rate"
|
||||
description: "Message rate below 0.01/s for 5 minutes. Check chat service health."
|
||||
summary: "Chat request latency is high"
|
||||
description: "Chat request p95 has been above 1s for 5 minutes. Check chat handlers and dependencies."
|
||||
|
||||
- alert: PrometheusWALFailing
|
||||
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
|
||||
@@ -64,6 +64,24 @@ groups:
|
||||
summary: "High CPU usage"
|
||||
description: "CPU usage above 80% for 10 minutes on {{ $labels.instance }}."
|
||||
|
||||
- alert: LowCacheHitRatio
|
||||
expr: linearvoid_cache_hit_ratio < 0.9
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Cloudflare cache hit ratio is below target"
|
||||
description: "Cache hit ratio has been below 90% for 15 minutes on {{ $labels.hostname }}."
|
||||
|
||||
- alert: HighRedisMemoryUsage
|
||||
expr: linearvoid_chat_redis_memory_usage_ratio > 0.85
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Redis memory usage is above target"
|
||||
description: "Redis memory usage has been above 85% for 10 minutes on {{ $labels.instance }}."
|
||||
|
||||
- alert: LowDiskSpace
|
||||
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
|
||||
for: 5m
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
||||
const zoneTag = process.env.CLOUDFLARE_ZONE_TAG;
|
||||
const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv";
|
||||
const windowMinutes = Number.parseInt(process.env.CLOUDFLARE_CACHE_HIT_RATIO_WINDOW_MINUTES || "60", 10);
|
||||
const endpoint = process.env.CLOUDFLARE_GRAPHQL_ENDPOINT || "https://api.cloudflare.com/client/v4/graphql";
|
||||
|
||||
if (!apiToken || !zoneTag) {
|
||||
console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - windowMinutes * 60 * 1000);
|
||||
|
||||
const iso = (value) => value.toISOString();
|
||||
|
||||
const query = `
|
||||
query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) {
|
||||
viewer {
|
||||
zones(filter: { zoneTag: $zoneTag }) {
|
||||
requests: httpRequestsAdaptiveGroups(
|
||||
limit: 1000
|
||||
filter: {
|
||||
datetime_geq: $start
|
||||
datetime_lt: $end
|
||||
clientRequestHTTPHost: $hostname
|
||||
requestSource: "eyeball"
|
||||
}
|
||||
) {
|
||||
count
|
||||
dimensions {
|
||||
cacheStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: {
|
||||
zoneTag,
|
||||
hostname,
|
||||
start: iso(start),
|
||||
end: iso(end),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.errors) {
|
||||
const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const groups = payload?.data?.viewer?.zones?.[0]?.requests || [];
|
||||
let totalRequests = 0;
|
||||
let hitRequests = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
const count = Number(group?.count || 0);
|
||||
const status = String(group?.dimensions?.cacheStatus || "").toUpperCase();
|
||||
totalRequests += count;
|
||||
if (status === "HIT") {
|
||||
hitRequests += count;
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0;
|
||||
|
||||
console.log(
|
||||
`# HELP linearvoid_cache_hit_ratio Cloudflare cache hit ratio for the selected hostname and time window.`,
|
||||
);
|
||||
console.log(`# TYPE linearvoid_cache_hit_ratio gauge`);
|
||||
console.log(
|
||||
`linearvoid_cache_hit_ratio{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${ratio.toFixed(6)}`,
|
||||
);
|
||||
console.log(`# HELP linearvoid_cache_hit_requests_total Cloudflare cache HIT requests counted in the time window.`);
|
||||
console.log(`# TYPE linearvoid_cache_hit_requests_total counter`);
|
||||
console.log(
|
||||
`linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`,
|
||||
);
|
||||
console.log(
|
||||
`# HELP linearvoid_cache_total_requests_total Cloudflare total eyeball requests counted in the time window.`,
|
||||
);
|
||||
console.log(`# TYPE linearvoid_cache_total_requests_total counter`);
|
||||
console.log(
|
||||
`linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env pwsh
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js'
|
||||
|
||||
& node $nodeScript @args
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,90 @@
|
||||
# Load Test Scaffolding
|
||||
|
||||
These files back the scaling/performance playbook.
|
||||
|
||||
## WebSocket chat
|
||||
|
||||
Run with an explicit WebSocket URL, for example:
|
||||
|
||||
```bash
|
||||
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml
|
||||
```
|
||||
|
||||
Capture output to an artifact file for the worksheet:
|
||||
|
||||
```bash
|
||||
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
PowerShell equivalent:
|
||||
|
||||
```powershell
|
||||
$env:WS_URL = 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
Summarize key counters, rates, and available percentile stats from a saved Artillery JSON report:
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/summarize-artillery.ps1 -ReportFile artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
## Admin API
|
||||
|
||||
Run with a cookie or session header that can access admin endpoints:
|
||||
|
||||
```bash
|
||||
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run scripts/loadtest/admin-api.js
|
||||
```
|
||||
|
||||
Capture machine-readable metrics and a text summary:
|
||||
|
||||
```bash
|
||||
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js > artifacts/admin-api-output.txt
|
||||
```
|
||||
|
||||
PowerShell equivalent:
|
||||
|
||||
```powershell
|
||||
$env:ADMIN_BASE_URL = 'https://linearvoid.tv'
|
||||
$env:ADMIN_SESSION_COOKIE = 'session=...'
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | Tee-Object -FilePath artifacts/admin-api-output.txt
|
||||
```
|
||||
|
||||
The script emits global and per-endpoint thresholds (overview, analytics, chat-log, users) so p95 and error-rate regressions are visible per route.
|
||||
|
||||
Summarize endpoint p95 and failure rates from a saved k6 JSON summary:
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/summarize-k6.ps1 -SummaryFile artifacts/admin-api-summary.json
|
||||
```
|
||||
|
||||
## PowerShell helper
|
||||
|
||||
Use the helper to run one scenario or both and automatically write outputs into a dated artifact folder:
|
||||
|
||||
- The helper resolves scenario paths from its own script location, so it can be run from any current working directory.
|
||||
- WebSocket runs also emit `ws-chat-summary.txt` with connection/error counters and available summary percentiles.
|
||||
- Admin runs also emit `admin-api-endpoint-summary.txt` with per-endpoint p95/error values.
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario ws -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario admin -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario all -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main' -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Adjust target URLs for test lab or production.
|
||||
- Keep the same commit SHA or image tag when comparing runs.
|
||||
- Record results using the worksheet in `docs/operations/scaling-performance-playbook.md`.
|
||||
- Store raw outputs under an `artifacts/` folder per run date (for example `artifacts/2026-08-09/`).
|
||||
- The repository `.gitignore` excludes benchmark artifacts by default.
|
||||
@@ -0,0 +1,58 @@
|
||||
import http from "k6/http";
|
||||
import { check, sleep } from "k6";
|
||||
|
||||
const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv";
|
||||
const adminCookie = __ENV.ADMIN_SESSION_COOKIE || "";
|
||||
const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {};
|
||||
|
||||
const endpoints = [
|
||||
{ name: "overview", path: "/api/admin/overview" },
|
||||
{ name: "analytics", path: "/api/admin/analytics" },
|
||||
{ name: "chat_log", path: "/api/admin/chat-log" },
|
||||
{ name: "users", path: "/api/admin/users" },
|
||||
];
|
||||
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: "30s", target: 10 },
|
||||
{ duration: "60s", target: 50 },
|
||||
{ duration: "60s", target: 100 },
|
||||
{ duration: "30s", target: 0 },
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ["p(95)<1000"],
|
||||
http_req_failed: ["rate<0.01"],
|
||||
"http_req_duration{endpoint:overview}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:analytics}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:chat_log}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:users}": ["p(95)<1000"],
|
||||
"http_req_failed{endpoint:overview}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:analytics}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:chat_log}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:users}": ["rate<0.01"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const requests = endpoints.map((endpoint) => [
|
||||
"GET",
|
||||
`${baseUrl}${endpoint.path}`,
|
||||
null,
|
||||
{
|
||||
...requestParams,
|
||||
tags: {
|
||||
endpoint: endpoint.name,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const responses = http.batch(requests);
|
||||
|
||||
responses.forEach((response, index) => {
|
||||
const endpointPath = endpoints[index].path;
|
||||
check(response, {
|
||||
[`status is 200 for ${endpointPath}`]: (r) => r.status === 200,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(1);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[ValidateSet('ws', 'admin', 'all')]
|
||||
[string]$Scenario = 'all',
|
||||
[string]$ArtifactRoot = 'artifacts',
|
||||
[string]$WsUrl = $env:WS_URL,
|
||||
[string]$AdminBaseUrl = $env:ADMIN_BASE_URL,
|
||||
[string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..')
|
||||
$wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml'
|
||||
$wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1'
|
||||
$adminScriptPath = Join-Path $scriptDir 'admin-api.js'
|
||||
$adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1'
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) {
|
||||
$artifactBase = $ArtifactRoot
|
||||
} else {
|
||||
$artifactBase = Join-Path $repoRoot $ArtifactRoot
|
||||
}
|
||||
|
||||
if ($Scenario -in @('ws', 'all')) {
|
||||
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||
throw 'npx is required for ws scenario but was not found in PATH.'
|
||||
}
|
||||
}
|
||||
|
||||
if ($Scenario -in @('admin', 'all')) {
|
||||
if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) {
|
||||
throw 'k6 is required for admin scenario but was not found in PATH.'
|
||||
}
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
|
||||
$artifactDir = Join-Path $artifactBase $timestamp
|
||||
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
|
||||
|
||||
function Invoke-WebSocketLoadTest {
|
||||
param(
|
||||
[string]$TargetWsUrl,
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) {
|
||||
throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.'
|
||||
}
|
||||
|
||||
$env:WS_URL = $TargetWsUrl
|
||||
& npx artillery run $wsScenarioPath --output $OutputPath
|
||||
}
|
||||
|
||||
function Invoke-AdminApiLoadTest {
|
||||
param(
|
||||
[string]$BaseUrl,
|
||||
[string]$SessionCookie,
|
||||
[string]$SummaryPath,
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
||||
throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($SessionCookie)) {
|
||||
throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.'
|
||||
}
|
||||
|
||||
$env:ADMIN_BASE_URL = $BaseUrl
|
||||
$env:ADMIN_SESSION_COOKIE = $SessionCookie
|
||||
& k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath
|
||||
}
|
||||
|
||||
if ($Scenario -in @('ws', 'all')) {
|
||||
if (-not (Test-Path $wsSummaryScriptPath)) {
|
||||
throw "Missing summary helper: $wsSummaryScriptPath"
|
||||
}
|
||||
|
||||
$wsOutput = Join-Path $artifactDir 'ws-chat-report.json'
|
||||
$wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt'
|
||||
Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput
|
||||
& pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary
|
||||
}
|
||||
|
||||
if ($Scenario -in @('admin', 'all')) {
|
||||
if (-not (Test-Path $adminSummaryScriptPath)) {
|
||||
throw "Missing summary helper: $adminSummaryScriptPath"
|
||||
}
|
||||
|
||||
$adminSummary = Join-Path $artifactDir 'admin-api-summary.json'
|
||||
$adminOutput = Join-Path $artifactDir 'admin-api-output.txt'
|
||||
$adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt'
|
||||
Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput
|
||||
& pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary
|
||||
}
|
||||
|
||||
Write-Host "Artifacts written to $artifactDir"
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReportFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $ReportFile)) {
|
||||
throw "Report file not found: $ReportFile"
|
||||
}
|
||||
|
||||
$report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100
|
||||
$aggregate = $report.aggregate
|
||||
|
||||
if (-not $aggregate) {
|
||||
throw 'Artillery report is missing aggregate data.'
|
||||
}
|
||||
|
||||
function Get-PropertyValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object]$Object,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if (-not $Object) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$property = $Object.PSObject.Properties[$Name]
|
||||
if ($property) {
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Format-StatLine {
|
||||
param(
|
||||
[string]$Prefix,
|
||||
[object]$Stats
|
||||
)
|
||||
|
||||
if (-not $Stats) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$parts = @()
|
||||
foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) {
|
||||
$value = Get-PropertyValue -Object $Stats -Name $name
|
||||
if ($null -ne $value) {
|
||||
$parts += "$name=$value"
|
||||
}
|
||||
}
|
||||
|
||||
if ($parts.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return "$Prefix: $($parts -join ', ')"
|
||||
}
|
||||
|
||||
$counters = Get-PropertyValue -Object $aggregate -Name 'counters'
|
||||
$rates = Get-PropertyValue -Object $aggregate -Name 'rates'
|
||||
$summaries = Get-PropertyValue -Object $aggregate -Name 'summaries'
|
||||
|
||||
$created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0)
|
||||
$failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0)
|
||||
$completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0)
|
||||
$errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 }
|
||||
|
||||
Write-Output '=== Artillery WebSocket Summary ==='
|
||||
Write-Output "source: $ReportFile"
|
||||
|
||||
$timestamp = Get-PropertyValue -Object $report -Name 'timestamp'
|
||||
if ($timestamp) {
|
||||
Write-Output "timestamp: $timestamp"
|
||||
}
|
||||
|
||||
if ($null -ne $created) {
|
||||
Write-Output "vusers.created: $created"
|
||||
}
|
||||
if ($null -ne $completed) {
|
||||
Write-Output "vusers.completed: $completed"
|
||||
}
|
||||
if ($null -ne $failed) {
|
||||
Write-Output "vusers.failed: $failed"
|
||||
}
|
||||
Write-Output "vusers.failure_rate_percent: $errorRate"
|
||||
|
||||
if ($counters) {
|
||||
foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) {
|
||||
$value = Get-PropertyValue -Object $counters -Name $metric
|
||||
if ($null -ne $value) {
|
||||
Write-Output "$metric: $value"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($rates) {
|
||||
foreach ($property in $rates.PSObject.Properties) {
|
||||
Write-Output "rate.$($property.Name): $($property.Value)"
|
||||
}
|
||||
}
|
||||
|
||||
if ($summaries) {
|
||||
foreach ($property in $summaries.PSObject.Properties) {
|
||||
$line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value
|
||||
if ($line) {
|
||||
Write-Output $line
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SummaryFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $SummaryFile)) {
|
||||
throw "Summary file not found: $SummaryFile"
|
||||
}
|
||||
|
||||
$summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json
|
||||
$metrics = $summary.metrics
|
||||
|
||||
if (-not $metrics) {
|
||||
throw 'Invalid k6 summary file: missing metrics object.'
|
||||
}
|
||||
|
||||
$endpoints = @('overview', 'analytics', 'chat_log', 'users')
|
||||
|
||||
function Get-MetricValue {
|
||||
param(
|
||||
[object]$Metric,
|
||||
[string]$Key
|
||||
)
|
||||
|
||||
if (-not $Metric) {
|
||||
return $null
|
||||
}
|
||||
if (-not $Metric.values) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$value = $Metric.values.$Key
|
||||
if ($null -ne $value) {
|
||||
return [double]$value
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)'
|
||||
$globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate'
|
||||
|
||||
Write-Output "k6 Summary: $SummaryFile"
|
||||
if ($null -ne $globalDuration) {
|
||||
Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration)
|
||||
}
|
||||
if ($null -ne $globalFailRate) {
|
||||
Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate)
|
||||
}
|
||||
Write-Output ''
|
||||
|
||||
$rows = foreach ($endpoint in $endpoints) {
|
||||
$durationMetricName = "http_req_duration{endpoint:$endpoint}"
|
||||
$failMetricName = "http_req_failed{endpoint:$endpoint}"
|
||||
|
||||
$durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)'
|
||||
$failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate'
|
||||
|
||||
[PSCustomObject]@{
|
||||
Endpoint = $endpoint
|
||||
P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null }
|
||||
FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null }
|
||||
}
|
||||
}
|
||||
|
||||
$rows | Format-Table -AutoSize | Out-String | Write-Output
|
||||
@@ -0,0 +1,28 @@
|
||||
config:
|
||||
target: "{{ $processEnvironment.WS_URL }}"
|
||||
phases:
|
||||
- duration: 60
|
||||
arrivalRate: 10
|
||||
name: warmup-10
|
||||
- duration: 120
|
||||
arrivalRate: 100
|
||||
name: sustain-100
|
||||
- duration: 180
|
||||
arrivalRate: 500
|
||||
name: sustain-500
|
||||
- duration: 240
|
||||
arrivalRate: 1000
|
||||
name: sustain-1000
|
||||
engines:
|
||||
ws: {}
|
||||
scenarios:
|
||||
- engine: ws
|
||||
name: chat-presence-and-send
|
||||
flow:
|
||||
- think: 1
|
||||
- send:
|
||||
text: '{"type":"ping","text":"loadtest"}'
|
||||
- think: 1
|
||||
- send:
|
||||
text: '{"type":"ping","text":"loadtest-2"}'
|
||||
- think: 1
|
||||
Reference in new issue
Block a user