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 ./...
|
run: cd backend/chat && GOTOOLCHAIN=local go vet ./...
|
||||||
|
|
||||||
- name: Run Tests
|
- 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:
|
docker:
|
||||||
needs: test
|
needs: test
|
||||||
|
|||||||
+10
@@ -7,6 +7,10 @@
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage
|
||||||
|
coverage.out
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -15,6 +19,10 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Benchmark artifacts
|
||||||
|
artifacts/
|
||||||
|
scripts/loadtest/artifacts/
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
*.pid
|
*.pid
|
||||||
@@ -41,6 +49,7 @@ out/
|
|||||||
# FFmpeg temporary files
|
# FFmpeg temporary files
|
||||||
*.ts.tmp
|
*.ts.tmp
|
||||||
*.m3u8.tmp
|
*.m3u8.tmp
|
||||||
|
*.m4s.tmp
|
||||||
hls/
|
hls/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
@@ -81,6 +90,7 @@ Thumbs.db
|
|||||||
# Agent / Tool files
|
# Agent / Tool files
|
||||||
.github/agents/
|
.github/agents/
|
||||||
.github/instructions/
|
.github/instructions/
|
||||||
|
.github/MEMORY.md
|
||||||
|
|
||||||
# Temporary architecture artifacts
|
# Temporary architecture artifacts
|
||||||
**/*.tmp
|
**/*.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 -->
|
<!-- 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
|
## [1.0.9] - 2026-08-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -16,10 +52,15 @@
|
|||||||
- **CI pipeline**: Removed redundant `release` job (handled by `release.yml`).
|
- **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.
|
- **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.
|
- **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
|
### 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.
|
- **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
|
### Removed
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://git.allucanget.biz)
|
[](https://git.allucanget.biz)
|
||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses:
|
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)_.
|
- 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.
|
- Lightweight static frontend and WebSocket chat backend.
|
||||||
- Gitea self-hosted CI/CD (Actions) and Container Registry.
|
- 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.
|
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
||||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
- [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.
|
- [SETUP.md](docs/SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
||||||
- [API.md](API.md) - Implemented WebSocket and REST API reference.
|
- [API.md](docs/API.md) - Implemented WebSocket and REST API reference.
|
||||||
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
|
- [DEPLOYMENT.md](docs/DEPLOYMENT.md) - Production deployment and operations guide.
|
||||||
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
- [MONITORING.md](docs/MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
||||||
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
- [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.
|
- [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).
|
- [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)
|
[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
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/redis/go-redis/v9 v9.7.0
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
gorm.io/driver/postgres v1.5.11
|
||||||
gorm.io/driver/sqlite v1.5.7
|
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // 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/crypto v0.37.0 // indirect
|
||||||
golang.org/x/sync v0.13.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
|
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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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/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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
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-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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/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 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
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=
|
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=
|
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 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
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 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-2728
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
|
WORKDIR /app
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
COPY src/go.mod src/go.sum ./
|
||||||
|
|
||||||
# Initialize Go module if not present
|
# Download dependencies and build
|
||||||
RUN go mod init playout || true
|
RUN go mod download
|
||||||
RUN go mod tidy
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /playlist-manager ./src/playlist.go
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /playlist-manager ./src/playlist.go
|
||||||
|
|
||||||
FROM alpine:3.20
|
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 (
|
var (
|
||||||
r2Client *s3.Client
|
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")
|
bucket = os.Getenv("R2_BUCKET_NAME")
|
||||||
playoutMode = "starting" // "live", "slate", "degraded"
|
playoutMode = "starting" // "live", "slate", "degraded"
|
||||||
restartCount = 0
|
restartCount = 0
|
||||||
@@ -39,6 +42,9 @@ var (
|
|||||||
const (
|
const (
|
||||||
maxRestartsPerMinute = 5
|
maxRestartsPerMinute = 5
|
||||||
slateManifestName = "playlist.m3u8"
|
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
|
// initR2Client initializes the R2 (S3-compatible) client
|
||||||
@@ -203,7 +209,7 @@ func validateKeyframeInterval(videoPath string) error {
|
|||||||
"-v", "quiet",
|
"-v", "quiet",
|
||||||
"-select_streams", "v:0",
|
"-select_streams", "v:0",
|
||||||
"-show_frames",
|
"-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",
|
"-of", "csv=p=0",
|
||||||
videoPath)
|
videoPath)
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
@@ -223,8 +229,16 @@ func validateKeyframeInterval(videoPath string) error {
|
|||||||
if len(parts) < 2 || parts[0] != "1" {
|
if len(parts) < 2 || parts[0] != "1" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pts, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
var pts float64
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
keyframes = append(keyframes, kfEntry{pts})
|
keyframes = append(keyframes, kfEntry{pts})
|
||||||
@@ -284,7 +298,11 @@ func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
|
|||||||
return videos, nil
|
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 {
|
func SyncToR2(localPath, r2Key string) error {
|
||||||
if r2Client == nil {
|
if r2Client == nil {
|
||||||
if err := initR2Client(); err != nil {
|
if err := initR2Client(); err != nil {
|
||||||
@@ -298,11 +316,23 @@ func SyncToR2(localPath, r2Key string) error {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
_, err = r2Client.PutObject(context.TODO(), &s3.PutObjectInput{
|
input := &s3.PutObjectInput{
|
||||||
Bucket: aws.String(bucket),
|
Bucket: aws.String(bucket),
|
||||||
Key: aws.String(r2Key),
|
Key: aws.String(r2Key),
|
||||||
Body: file,
|
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
|
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)
|
- [Production Target (Hetzner)](#production-target-hetzner)
|
||||||
- [Planned Infrastructure](#planned-infrastructure)
|
- [Planned Infrastructure](#planned-infrastructure)
|
||||||
- [Cutover Plan](#cutover-plan)
|
- [Cutover Plan](#cutover-plan)
|
||||||
|
- [Production Preflight](#production-preflight)
|
||||||
- [Firewall / Security Group](#firewall--security-group)
|
- [Firewall / Security Group](#firewall--security-group)
|
||||||
- [Pipeline Readiness](#pipeline-readiness)
|
- [Pipeline Readiness](#pipeline-readiness)
|
||||||
- [Target Topology](#target-topology)
|
- [Target Topology](#target-topology)
|
||||||
@@ -64,6 +65,19 @@ Linear Void runs in two separate environments:
|
|||||||
6. Enable HTTPS, WAF, cache rules on production zone
|
6. Enable HTTPS, WAF, cache rules on production zone
|
||||||
7. Decommission test lab as public target
|
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
|
### Firewall / Security Group
|
||||||
|
|
||||||
| Port | Source | Purpose |
|
| Port | Source | Purpose |
|
||||||
@@ -135,7 +149,7 @@ See .env.example and SETUP.md for full variable reference.
|
|||||||
|
|
||||||
## Pull-and-Run Deployment
|
## 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.
|
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`.
|
- 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**:
|
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.
|
- 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.
|
- 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).
|
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. DNS Setup
|
||||||
|
|
||||||
1. Add apex record for `linearvoid.tv` pointing to Cloudflare Pages target.
|
1. Add apex record for `linearvoid.tv` pointing to Cloudflare Pages target.
|
||||||
@@ -262,7 +264,12 @@ Configure Cloudflare Cache Rules:
|
|||||||
- Cache: eligible
|
- Cache: eligible
|
||||||
- Edge TTL: `2s`
|
- Edge TTL: `2s`
|
||||||
- Revalidation: enabled
|
- 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
|
- `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
|
||||||
|
|
||||||
Reference: `infrastructure/cloudflare/cache-rules.md`.
|
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`.
|
- **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).
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Companion Documentation
|
||||||
|
|
||||||
- [API.md](../../API.md) - Implemented WebSocket and REST API reference.
|
- [API.md](../API.md) - Implemented WebSocket and REST API reference.
|
||||||
- [DEPLOYMENT.md](../../DEPLOYMENT.md) - Production deployment and operational runbook.
|
- [DEPLOYMENT.md](../DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||||
- [MONITORING.md](../../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
- [MONITORING.md](../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||||
- [SECURITY.md](../../SECURITY.md) - Security model, controls, and hardening baseline.
|
- [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
|
## Introduction and Goals
|
||||||
|
|
||||||
@@ -113,14 +115,14 @@ Full decision rationale, alternatives, and consequences in [`adr/ADR-001.md`](ad
|
|||||||
|
|
||||||
### Quality Goals
|
### Quality Goals
|
||||||
|
|
||||||
| Priority | Quality Attribute | Goal | Scenario |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 6 | **Security / Usability** | Secure transport, moderation tools, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player |
|
||||||
|
|
||||||
### Stakeholders
|
### Stakeholders
|
||||||
|
|
||||||
@@ -186,7 +188,7 @@ flowchart TD
|
|||||||
|
|
||||||
**External Interfaces**:
|
**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).
|
- **Hetzner VPS**: Compute for playout and chat (low-cost US-East with good peering).
|
||||||
- **Browsers**: Native HLS (`<video>` + hls.js) and WebSocket APIs.
|
- **Browsers**: Native HLS (`<video>` + hls.js) and WebSocket APIs.
|
||||||
- **Source**: Pre-normalized MP4 files uploaded to R2.
|
- **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
|
### Technical Context
|
||||||
|
|
||||||
- **Playout**: FFmpeg consuming normalized playlist → continuous HLS.
|
- **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.
|
- **Frontend**: Static SPA with hls.js.
|
||||||
- **Chat**: Lightweight WebSocket service (Node/Go) + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation).
|
- **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.
|
- **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).
|
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.
|
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.
|
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).
|
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.
|
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
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
subgraph Cloudflare
|
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]
|
R2[R2 - HLS + Source]
|
||||||
Pages[Pages - Static SPA]
|
Pages[Pages - Static SPA]
|
||||||
end
|
end
|
||||||
@@ -306,7 +308,7 @@ graph TB
|
|||||||
|
|
||||||
- **Role**: Continuous HLS encoding loop using FFmpeg concat demuxer
|
- **Role**: Continuous HLS encoding loop using FFmpeg concat demuxer
|
||||||
- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths
|
- **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
|
- **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081
|
||||||
- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval)
|
- **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).
|
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.
|
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.
|
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.
|
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`.
|
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**:
|
**Cloudflare Layer**:
|
||||||
|
|
||||||
- R2 bucket for media.
|
- 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.
|
- WAF, rate limiting, Tiered Cache.
|
||||||
- Pages for SPA hosting.
|
- Pages for SPA hosting.
|
||||||
|
|
||||||
@@ -479,7 +481,7 @@ Future evolution: Docker Compose, managed DB, secondary regions (detailed in sub
|
|||||||
|
|
||||||
### Caching & Cost Optimization
|
### 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
|
### Video Normalization & Playout
|
||||||
|
|
||||||
@@ -888,16 +890,16 @@ Document and rehearse this runbook quarterly; recovery that is not exercised is
|
|||||||
|
|
||||||
## Glossary
|
## Glossary
|
||||||
|
|
||||||
| Term | Definition |
|
| Term | Definition |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------ |
|
| ----------------- | -------------------------------------------------------------------------------------- |
|
||||||
| HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (`.ts` or fMP4). |
|
| HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (fMP4). |
|
||||||
| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
|
| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
|
||||||
| R2 | Cloudflare object storage ($0 egress, ideal origin for cached HLS). |
|
| 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. |
|
| 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. |
|
| Playout | Continuous generation of a scheduled linear program from a playlist. |
|
||||||
| Normalization | Pre-processing videos to consistent codec/fps/keyframe settings for seamless playback. |
|
| 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. |
|
| Cache Rule | Cloudflare configuration defining caching behavior, TTL, and headers per URL pattern. |
|
||||||
| SPOF | Single Point of Failure (current single VPS origin). |
|
| SPOF | Single Point of Failure (current single VPS origin). |
|
||||||
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
| 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.
|
**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**:
|
**Key distinctions**:
|
||||||
|
|
||||||
- **Playout**: Continuous scheduled program from playlist.
|
- **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).
|
- **Delivery**: Immutable segments served via Cloudflare edge caching + R2 (zero egress cost).
|
||||||
- **Interaction**: Separate lightweight WebSocket service for chat.
|
- **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
|
## 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).
|
2. Full independence and ownership (no video platform lock-in).
|
||||||
3. Strong US performance and low latency.
|
3. Strong US performance and low latency.
|
||||||
4. True/near-true synchronization via live-style HLS (not pure VOD).
|
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).
|
6. Browser-native playback (`hls.js` + native HLS).
|
||||||
7. Lightweight, maintainable stack with modest operational complexity.
|
7. Lightweight, maintainable stack with modest operational complexity.
|
||||||
8. Support for custom multi-room + private chat with persistence and moderation.
|
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)
|
### 2. Storage & Delivery (Cloudflare R2 + CDN)
|
||||||
|
|
||||||
- HLS segments (`.ts`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`).
|
- HLS segments (`.m4s`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`).
|
||||||
- Custom domain (`media.example.com`) fronted by Cloudflare with **Cache Rules**:
|
- Custom domain (`media.linearvoid.tv`) 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`).
|
- `*.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`).
|
- `*.m3u8`: Short TTL (1–10s) or `must-revalidate` (`Cache-Control: public, max-age=1..10, must-revalidate`).
|
||||||
- Master playlist: TTL ~30s.
|
- Master playlist: TTL ~30s.
|
||||||
- Tiered Cache enabled where possible.
|
- Tiered Cache enabled where possible.
|
||||||
@@ -90,7 +90,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
**Initial Targets**:
|
**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).
|
- Max rendition: 720p30 initially (upgrade to 1080p30 or ABR later).
|
||||||
- Latency: 15–35s (acceptable for linear programming/chat).
|
- 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).
|
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.
|
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).
|
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.
|
5. Single VPS origin initially; multi-region is a later evolution.
|
||||||
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ date: July 2025
|
|||||||
title: " Template"
|
title: " Template"
|
||||||
---
|
---
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
||||||
**About arc42**
|
**About arc42**
|
||||||
|
|
||||||
@@ -24,13 +24,13 @@ contributors. See <https://arc42.org>.
|
|||||||
## Stakeholders {#_stakeholders}
|
## Stakeholders {#_stakeholders}
|
||||||
|
|
||||||
+-------------+---------------------------+---------------------------+
|
+-------------+---------------------------+---------------------------+
|
||||||
| Role/Name | Contact | Expectations |
|
| Role/Name | Contact | Expectations |
|
||||||
+=============+===========================+===========================+
|
+=============+===========================+===========================+
|
||||||
| * | *\<Contact-1\>* | *\<Expectation-1\>* |
|
| * | _\<Contact-1\>_ | _\<Expectation-1\>_ |
|
||||||
| \<Role-1\>* | | |
|
| \<Role-1\>* | | |
|
||||||
+-------------+---------------------------+---------------------------+
|
+-------------+---------------------------+---------------------------+
|
||||||
| * | *\<Contact-2\>* | *\<Expectation-2\>* |
|
| * | _\<Contact-2\>_ | _\<Expectation-2\>_ |
|
||||||
| \<Role-2\>* | | |
|
| \<Role-2\>* | | |
|
||||||
+-------------+---------------------------+---------------------------+
|
+-------------+---------------------------+---------------------------+
|
||||||
|
|
||||||
# Architecture Constraints {#section-architecture-constraints}
|
# Architecture Constraints {#section-architecture-constraints}
|
||||||
@@ -57,41 +57,41 @@ contributors. See <https://arc42.org>.
|
|||||||
|
|
||||||
## Whitebox Overall System {#_whitebox_overall_system}
|
## Whitebox Overall System {#_whitebox_overall_system}
|
||||||
|
|
||||||
***\<Overview Diagram\>***
|
_**\<Overview Diagram\>**_
|
||||||
|
|
||||||
Motivation
|
Motivation
|
||||||
|
|
||||||
: *\<text explanation\>*
|
: _\<text explanation\>_
|
||||||
|
|
||||||
Contained Building Blocks
|
Contained Building Blocks
|
||||||
|
|
||||||
: *\<Description of contained building block (black boxes)\>*
|
: _\<Description of contained building block (black boxes)\>_
|
||||||
|
|
||||||
Important Interfaces
|
Important Interfaces
|
||||||
|
|
||||||
: *\<Description of important interfaces\>*
|
: _\<Description of important interfaces\>_
|
||||||
|
|
||||||
### \<Name black box 1\> {#_name_black_box_1}
|
### \<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}
|
### \<Name black box 2\> {#_name_black_box_2}
|
||||||
|
|
||||||
*\<black box template\>*
|
_\<black box template\>_
|
||||||
|
|
||||||
### \<Name black box n\> {#_name_black_box_n}
|
### \<Name black box n\> {#_name_black_box_n}
|
||||||
|
|
||||||
*\<black box template\>*
|
_\<black box template\>_
|
||||||
|
|
||||||
### \<Name interface 1\> {#_name_interface_1}
|
### \<Name interface 1\> {#_name_interface_1}
|
||||||
|
|
||||||
@@ -101,42 +101,42 @@ Important Interfaces
|
|||||||
|
|
||||||
## Level 2 {#_level_2}
|
## 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}
|
## Level 3 {#_level_3}
|
||||||
|
|
||||||
### White Box \<\_building block x.1\_\> {#_white_box_building_block_x_1}
|
### 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 \<\_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 \<\_building block y.1\_\> {#_white_box_building_block_y_1}
|
||||||
|
|
||||||
*\<white box template\>*
|
_\<white box template\>_
|
||||||
|
|
||||||
# Runtime View {#section-runtime-view}
|
# Runtime View {#section-runtime-view}
|
||||||
|
|
||||||
## \<Runtime Scenario 1\> {#_runtime_scenario_1}
|
## \<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
|
- _\<insert description of the notable aspects of the interactions
|
||||||
between the building block instances depicted in this diagram.\>*
|
between the building block instances depicted in this diagram.\>_
|
||||||
|
|
||||||
## \<Runtime Scenario 2\> {#_runtime_scenario_2}
|
## \<Runtime Scenario 2\> {#_runtime_scenario_2}
|
||||||
|
|
||||||
@@ -148,51 +148,51 @@ Important Interfaces
|
|||||||
|
|
||||||
## Infrastructure Level 1 {#_infrastructure_level_1}
|
## Infrastructure Level 1 {#_infrastructure_level_1}
|
||||||
|
|
||||||
***\<Overview Diagram\>***
|
_**\<Overview Diagram\>**_
|
||||||
|
|
||||||
Motivation
|
Motivation
|
||||||
|
|
||||||
: *\<explanation in text form\>*
|
: _\<explanation in text form\>_
|
||||||
|
|
||||||
Quality and/or Performance Features
|
Quality and/or Performance Features
|
||||||
|
|
||||||
: *\<explanation in text form\>*
|
: _\<explanation in text form\>_
|
||||||
|
|
||||||
Mapping of Building Blocks to Infrastructure
|
Mapping of Building Blocks to Infrastructure
|
||||||
|
|
||||||
: *\<description of the mapping\>*
|
: _\<description of the mapping\>_
|
||||||
|
|
||||||
## Infrastructure Level 2 {#_infrastructure_level_2}
|
## 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}
|
# 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}
|
# Architecture Decisions {#section-design-decisions}
|
||||||
|
|
||||||
@@ -207,9 +207,9 @@ Mapping of Building Blocks to Infrastructure
|
|||||||
# Glossary {#section-glossary}
|
# 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
|
3. Add prevention to docs if actionable
|
||||||
4. Update this runbook if new failure mode discovered
|
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.
|
||||||
+116
-28
@@ -1,42 +1,130 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linear Void Admin</title>
|
<title>Linear Void Admin</title>
|
||||||
<link rel="stylesheet" href="../src/style.css" />
|
<link rel="stylesheet" href="../src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="admin-body">
|
<body class="admin-body">
|
||||||
<div class="admin-shell">
|
<div class="admin-shell">
|
||||||
<aside class="admin-sidebar" aria-label="Primary">
|
<aside class="admin-sidebar" aria-label="Primary">
|
||||||
<div class="brand-block">
|
<div class="brand-block">
|
||||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
<div class="brand-text">Admin</div>
|
<div class="brand-text">Admin</div>
|
||||||
</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">
|
<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
|
||||||
<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>
|
type="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>
|
id="tab-trigger-dashboard"
|
||||||
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">Schedule</button>
|
role="tab"
|
||||||
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">Moderation</button>
|
class="tab-trigger is-active"
|
||||||
<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>
|
data-tab="dashboard"
|
||||||
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">Users</button>
|
aria-controls="tab-dashboard"
|
||||||
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">Analytics</button>
|
aria-selected="true"
|
||||||
<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>
|
Dashboard
|
||||||
</aside>
|
</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">
|
<main class="admin-main" id="admin-main" tabindex="-1">
|
||||||
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
<div id="tab-content-container" aria-live="polite">Loading...</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/src/admin.ts"></script>
|
<script type="module" src="/src/admin.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
</html>
|
||||||
</html>
|
|
||||||
@@ -1,67 +1,65 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Linear Void Admin Login</title>
|
<title>Linear Void Admin Login</title>
|
||||||
<link rel="stylesheet" href="../../src/style.css" />
|
<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">
|
<main class="auth-shell" aria-label="Admin authentication">
|
||||||
<section class="auth-card" aria-labelledby="auth-title">
|
<section class="auth-card" aria-labelledby="auth-title">
|
||||||
<p class="auth-kicker">Linear Void</p>
|
<p class="auth-kicker">Linear Void</p>
|
||||||
<h1 id="auth-title">Admin Login</h1>
|
<h1 id="auth-title">Admin Login</h1>
|
||||||
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
<p class="auth-subtitle">Authenticate to access protected admin controls.</p>
|
||||||
|
|
||||||
<form id="login-form" class="form-stack" novalidate>
|
<form id="login-form" class="form-stack" novalidate>
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
<input id="username" type="text" class="text-input" autocomplete="username" required />
|
||||||
|
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input id="password" type="password" class="text-input" autocomplete="current-password" required />
|
<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>
|
<button id="submit" type="submit" class="btn btn-primary btn-block">Sign in</button>
|
||||||
<p id="status" class="status-line" aria-live="polite"></p>
|
<p id="status" class="status-line" aria-live="polite"></p>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const form = document.getElementById('login-form');
|
const form = document.getElementById("login-form");
|
||||||
const username = document.getElementById('username');
|
const username = document.getElementById("username");
|
||||||
const password = document.getElementById('password');
|
const password = document.getElementById("password");
|
||||||
const submit = document.getElementById('submit');
|
const submit = document.getElementById("submit");
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById("status");
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
form.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
status.textContent = 'Signing in...';
|
status.textContent = "Signing in...";
|
||||||
status.className = 'status-line';
|
status.className = "status-line";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/login', {
|
const response = await fetch("/api/admin/login", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username: username.value, password: password.value })
|
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = '/admin/';
|
window.location.href = "/admin/";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to sign in', error);
|
console.error("Failed to sign in", error);
|
||||||
status.textContent = 'Login failed. Check your credentials.';
|
status.textContent = "Login failed. Check your credentials.";
|
||||||
status.className = 'status-line status-error';
|
status.className = "status-line status-error";
|
||||||
} finally {
|
} finally {
|
||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
</html>
|
||||||
</html>
|
|
||||||
+67
-56
@@ -1,65 +1,76 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<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>
|
<body class="lv-body">
|
||||||
<meta charset="UTF-8" />
|
<main class="viewer-shell" aria-label="Live player and chat">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<header class="viewer-header">
|
||||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
<div class="viewer-brand-group">
|
||||||
<link rel="stylesheet" href="/src/style.css" />
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
</head>
|
<div>
|
||||||
|
<h1 class="viewer-title">Linear Void</h1>
|
||||||
<body class="lv-body">
|
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
</div>
|
||||||
</header>
|
<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 class="viewer-content" aria-label="Live stream and chat layout">
|
||||||
|
<section class="player-stage" aria-label="Video stage">
|
||||||
<section id="presence-panel" class="presence-panel" aria-label="Online users">
|
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||||
<div id="presence-list" class="presence-list"></div>
|
<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>
|
</section>
|
||||||
|
|
||||||
<div class="chat-input-area">
|
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||||
<div class="chat-input-row">
|
<header class="chat-header">
|
||||||
<label for="message-input" class="sr-only">Message input</label>
|
<div class="chat-room-title">
|
||||||
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if(event.key === 'Enter') sendMessage()" />
|
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
<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>
|
</div>
|
||||||
<p class="chat-note">
|
</aside>
|
||||||
Synced with video • Moderation active
|
</section>
|
||||||
</p>
|
</main>
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
</html>
|
||||||
</html>
|
|
||||||
Generated
+1005
-3
File diff suppressed because it is too large.
Load diff
@@ -1,17 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.9",
|
"version": "1.0.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitest/ui": "^4.1.10",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.2.0"
|
"vite": "^8.2.0",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hls.js": "^1.6.16"
|
"hls.js": "^1.6.16"
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
<div class="panel-header-row">
|
<div class="panel-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1>Analytics</h1>
|
<h1>Analytics</h1>
|
||||||
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<select id="analytics-range" class="text-input" style="width:auto">
|
<select id="analytics-range" class="text-input" style="width: auto">
|
||||||
<option value="1h">Last hour</option>
|
<option value="1h">Last hour</option>
|
||||||
<option value="6h">Last 6 hours</option>
|
<option value="6h">Last 6 hours</option>
|
||||||
<option value="24h" selected>Last 24 hours</option>
|
<option value="24h" selected>Last 24 hours</option>
|
||||||
<option value="7d">Last 7 days</option>
|
<option value="7d">Last 7 days</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
<button id="reload-analytics-btn" type="button" class="btn btn-secondary">Reload analytics</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<h2>Total Messages</h2>
|
<h2>Total Messages</h2>
|
||||||
<p id="analytics-total-messages" class="stat-value">0</p>
|
<p id="analytics-total-messages" class="stat-value">0</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<h2>Last 24 Hours</h2>
|
<h2>Last 24 Hours</h2>
|
||||||
<p id="analytics-messages-24h" class="stat-value">0</p>
|
<p id="analytics-messages-24h" class="stat-value">0</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<h2>Distinct Rooms</h2>
|
<h2>Distinct Rooms</h2>
|
||||||
<p id="analytics-distinct-rooms" class="stat-value">0</p>
|
<p id="analytics-distinct-rooms" class="stat-value">0</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<h2>Moderation Actions</h2>
|
<h2>Moderation Actions</h2>
|
||||||
<p id="analytics-moderation-actions" class="stat-value">0</p>
|
<p id="analytics-moderation-actions" class="stat-value">0</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<div class="panel-header-row">
|
<div class="panel-header-row">
|
||||||
<h2>Top Rooms</h2>
|
<h2>Top Rooms</h2>
|
||||||
<p id="analytics-generated-at" class="small-muted">loading...</p>
|
<p id="analytics-generated-at" class="small-muted">loading...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Room</th>
|
<th>Room</th>
|
||||||
<th>Messages</th>
|
<th>Messages</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="analytics-table-body"></tbody>
|
<tbody id="analytics-table-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
|
<p id="analytics-status" class="status-line" aria-live="polite">Loading analytics...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,46 +1,46 @@
|
|||||||
<div class="panel-header-row">
|
<div class="panel-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1>Chat Log</h1>
|
<h1>Chat Log</h1>
|
||||||
<p>Browse and manage stored chat messages.</p>
|
<p>Browse and manage stored chat messages.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button id="reload-chat-log-btn" type="button" class="btn btn-secondary">Reload</button>
|
<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>
|
<button id="bulk-delete-chat-log-btn" type="button" class="btn btn-danger" disabled>Delete Selected</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<div class="chat-log-filters">
|
<div class="chat-log-filters">
|
||||||
<input id="chat-log-filter-room" class="text-input" type="text" placeholder="Room" />
|
<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-username" class="text-input" type="text" placeholder="Username" />
|
||||||
<input id="chat-log-filter-text" class="text-input" type="text" placeholder="Message text" />
|
<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-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>
|
<button id="chat-log-clear-filter-btn" type="button" class="btn btn-secondary">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
<th><input type="checkbox" id="chat-log-select-all" title="Select all" /></th>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Room</th>
|
<th>Room</th>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Message</th>
|
<th>Message</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="chat-log-table-body"></tbody>
|
<tbody id="chat-log-table-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-log-pagination">
|
<div class="chat-log-pagination">
|
||||||
<button id="chat-log-prev-btn" type="button" class="btn btn-secondary" disabled>Previous</button>
|
<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>
|
<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>
|
<button id="chat-log-next-btn" type="button" class="btn btn-secondary" disabled>Next</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="chat-log-status" class="status-line" aria-live="polite">Loading chat log...</p>
|
<p id="chat-log-status" class="status-line" aria-live="polite">Loading chat log...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<h2>Concurrent Viewers</h2>
|
<h2>Concurrent Viewers</h2>
|
||||||
<p id="stat-viewers" class="stat-value">0</p>
|
<p id="stat-viewers" class="stat-value">0</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<h2>Stored Messages</h2>
|
<h2>Stored Messages</h2>
|
||||||
<p id="stat-stored-messages" class="stat-value">0</p>
|
<p id="stat-stored-messages" class="stat-value">0</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<h2>Moderation Actions</h2>
|
<h2>Moderation Actions</h2>
|
||||||
<p id="stat-moderation-actions" class="stat-value">0</p>
|
<p id="stat-moderation-actions" class="stat-value">0</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<h2>Media Runtime</h2>
|
<h2>Media Runtime</h2>
|
||||||
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
|
<p id="stat-media-runtime" class="stat-value">00:00:00</p>
|
||||||
<p id="stat-media-count" class="stat-caption">0 files</p>
|
<p id="stat-media-count" class="stat-caption">0 files</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<p id="overview-status" class="status-line" aria-live="polite">Loading live admin overview...</p>
|
<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 class="panel-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1>Moderation</h1>
|
<h1>Moderation</h1>
|
||||||
<p>Create moderation actions and review the recent immutable audit log.</p>
|
<p>Create moderation actions and review the recent immutable audit log.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button id="reload-moderation-btn" type="button" class="btn btn-secondary">Reload logs</button>
|
<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>
|
<button id="export-chat-log-btn" type="button" class="btn btn-secondary">Export CSV</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="moderation-grid">
|
<div class="moderation-grid">
|
||||||
<section class="panel-card">
|
<section class="panel-card">
|
||||||
<h2>New Action</h2>
|
<h2>New Action</h2>
|
||||||
<form id="moderation-form" class="form-stack">
|
<form id="moderation-form" class="form-stack">
|
||||||
<label>Action
|
<label
|
||||||
<select id="mod-action" class="text-input">
|
>Action
|
||||||
<option value="mute">mute</option>
|
<select id="mod-action" class="text-input">
|
||||||
<option value="ban">ban</option>
|
<option value="mute">mute</option>
|
||||||
<option value="delete">delete</option>
|
<option value="ban">ban</option>
|
||||||
<option value="timeout">timeout</option>
|
<option value="delete">delete</option>
|
||||||
</select>
|
<option value="timeout">timeout</option>
|
||||||
</label>
|
</select>
|
||||||
<label>Target
|
</label>
|
||||||
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
|
<label
|
||||||
</label>
|
>Target
|
||||||
<label>Moderator
|
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
|
||||||
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
|
</label>
|
||||||
</label>
|
<label
|
||||||
<label>Room
|
>Moderator
|
||||||
<input id="mod-room" class="text-input" type="text" placeholder="main" />
|
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
|
||||||
</label>
|
</label>
|
||||||
<label>Reason
|
<label
|
||||||
<textarea id="mod-reason" class="text-input text-area" placeholder="Explain why this action is needed" required></textarea>
|
>Room
|
||||||
</label>
|
<input id="mod-room" class="text-input" type="text" placeholder="main" />
|
||||||
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
|
</label>
|
||||||
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
|
<label
|
||||||
</form>
|
>Reason
|
||||||
</section>
|
<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">
|
<section class="panel-card">
|
||||||
<div class="panel-header-row">
|
<div class="panel-header-row">
|
||||||
<h2>Recent Audit Log</h2>
|
<h2>Recent Audit Log</h2>
|
||||||
<p class="small-muted">Latest entries</p>
|
<p class="small-muted">Latest entries</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>When</th>
|
<th>When</th>
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
<th>Target</th>
|
<th>Target</th>
|
||||||
<th>Moderator</th>
|
<th>Moderator</th>
|
||||||
<th>Reason</th>
|
<th>Reason</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="moderation-table-body"></tbody>
|
<tbody id="moderation-table-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
<div class="panel-header-row">
|
<div class="panel-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1>Playlist</h1>
|
<h1>Playlist</h1>
|
||||||
<p>Edit the active concat playlist. Drag entries to reorder.</p>
|
<p>Edit the active concat playlist. Drag entries to reorder.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button id="reload-playlist-btn" type="button" class="btn btn-secondary">Reload</button>
|
<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>
|
<button id="save-playlist-btn" type="button" class="btn btn-primary">Save playlist</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<div id="playlist-warnings" class="status-warnings" hidden></div>
|
<div id="playlist-warnings" class="status-warnings" hidden></div>
|
||||||
<ul id="playlist-sortable" class="sortable-list" aria-label="Playlist entries"></ul>
|
<ul id="playlist-sortable" class="sortable-list" aria-label="Playlist entries"></ul>
|
||||||
<label for="playlist-content" class="sr-only">Playlist content (raw)</label>
|
<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>
|
<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>
|
<p id="playlist-status" class="status-line" aria-live="polite">Loading playlist...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,53 +1,56 @@
|
|||||||
<div class="panel-header-row">
|
<div class="panel-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1>Schedule</h1>
|
<h1>Schedule</h1>
|
||||||
<p>Read-only view of the currently configured playout order.</p>
|
<p>Read-only view of the currently configured playout order.</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
|
<button id="reload-schedule-btn" type="button" class="btn btn-secondary">Reload schedule</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<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>
|
<p class="small-muted">
|
||||||
<div class="table-wrap">
|
<span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime
|
||||||
<table class="data-table">
|
<span id="schedule-total-runtime">00:00:00</span>
|
||||||
<thead>
|
</p>
|
||||||
<tr>
|
<div class="table-wrap">
|
||||||
<th>#</th>
|
<table class="data-table">
|
||||||
<th>Label</th>
|
<thead>
|
||||||
<th>Runtime</th>
|
<tr>
|
||||||
<th>Status</th>
|
<th>#</th>
|
||||||
<th>Path</th>
|
<th>Label</th>
|
||||||
</tr>
|
<th>Runtime</th>
|
||||||
</thead>
|
<th>Status</th>
|
||||||
<tbody id="schedule-table-body"></tbody>
|
<th>Path</th>
|
||||||
</table>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
<tbody id="schedule-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="schedule-status" class="status-line" aria-live="polite">Loading schedule...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<h2>Schedule Overrides</h2>
|
<h2>Schedule Overrides</h2>
|
||||||
<p>Insert scheduled overrides to play specific videos at specific times.</p>
|
<p>Insert scheduled overrides to play specific videos at specific times.</p>
|
||||||
<form id="override-form" class="form-stack" style="max-width:500px">
|
<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>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>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>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>
|
<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>
|
<button type="submit" class="btn btn-primary">Add Override</button>
|
||||||
</form>
|
</form>
|
||||||
<p id="override-status" class="status-line" aria-live="polite"></p>
|
<p id="override-status" class="status-line" aria-live="polite"></p>
|
||||||
<div class="table-wrap" style="margin-top:1rem">
|
<div class="table-wrap" style="margin-top: 1rem">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Media ID</th>
|
<th>Media ID</th>
|
||||||
<th>Start</th>
|
<th>Start</th>
|
||||||
<th>End</th>
|
<th>End</th>
|
||||||
<th>Reason</th>
|
<th>Reason</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="override-table-body"></tbody>
|
<tbody id="override-table-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,75 +1,75 @@
|
|||||||
<h1>System Configuration</h1>
|
<h1>System Configuration</h1>
|
||||||
<div class="panel-card split-grid">
|
<div class="panel-card split-grid">
|
||||||
<section>
|
<section>
|
||||||
<h2>Redis Stats</h2>
|
<h2>Redis Stats</h2>
|
||||||
<dl class="kv-list">
|
<dl class="kv-list">
|
||||||
<div>
|
<div>
|
||||||
<dt>Memory Used</dt>
|
<dt>Memory Used</dt>
|
||||||
<dd id="system-redis-memory">loading...</dd>
|
<dd id="system-redis-memory">loading...</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Connected Clients</dt>
|
<dt>Connected Clients</dt>
|
||||||
<dd id="system-redis-clients">0</dd>
|
<dd id="system-redis-clients">0</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Redis Enabled</dt>
|
<dt>Redis Enabled</dt>
|
||||||
<dd id="system-redis-enabled">no</dd>
|
<dd id="system-redis-enabled">no</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2>Storage</h2>
|
<h2>Storage</h2>
|
||||||
<dl class="kv-list">
|
<dl class="kv-list">
|
||||||
<div>
|
<div>
|
||||||
<dt>Database Driver</dt>
|
<dt>Database Driver</dt>
|
||||||
<dd id="system-db-driver">loading...</dd>
|
<dd id="system-db-driver">loading...</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Stored Messages</dt>
|
<dt>Stored Messages</dt>
|
||||||
<dd id="system-stored-messages">0</dd>
|
<dd id="system-stored-messages">0</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Last Refresh</dt>
|
<dt>Last Refresh</dt>
|
||||||
<dd id="system-generated-at">loading...</dd>
|
<dd id="system-generated-at">loading...</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Playlist Path</dt>
|
<dt>Playlist Path</dt>
|
||||||
<dd id="system-playlist-path">loading...</dd>
|
<dd id="system-playlist-path">loading...</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Playlist Present</dt>
|
<dt>Playlist Present</dt>
|
||||||
<dd id="system-playlist-present">no</dd>
|
<dd id="system-playlist-present">no</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<div class="panel-header-row">
|
<div class="panel-header-row">
|
||||||
<h2>Service Health</h2>
|
<h2>Service Health</h2>
|
||||||
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
|
<button id="reload-system-btn" type="button" class="btn btn-secondary">Reload health</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="system-services" class="service-list"></div>
|
<div id="system-services" class="service-list"></div>
|
||||||
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
<p id="system-status" class="status-line" aria-live="polite">Loading system summary...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<h2>Runtime Configuration</h2>
|
<h2>Runtime Configuration</h2>
|
||||||
<form id="config-form" class="form-stack" style="max-width:400px">
|
<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 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>
|
<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>
|
<button type="submit" class="btn btn-primary">Save Config</button>
|
||||||
</form>
|
</form>
|
||||||
<p id="config-status" class="status-line" aria-live="polite"></p>
|
<p id="config-status" class="status-line" aria-live="polite"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<h2>Service Control</h2>
|
<h2>Service Control</h2>
|
||||||
<p class="small-muted">Restart individual services (manual — container restart commands returned by API).</p>
|
<p class="small-muted">Restart individual services (manual — container restart commands returned by API).</p>
|
||||||
<div class="button-row">
|
<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="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="chat">Restart Chat</button>
|
||||||
<button class="btn btn-secondary restart-btn" data-service="nginx">Restart Nginx</button>
|
<button class="btn btn-secondary restart-btn" data-service="nginx">Restart Nginx</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="restart-status" class="status-line" aria-live="polite"></p>
|
<p id="restart-status" class="status-line" aria-live="polite"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,41 +1,43 @@
|
|||||||
<div class="panel-header-row">
|
<div class="panel-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1>Upload Video</h1>
|
<h1>Upload Video</h1>
|
||||||
<p>Upload media files into the local media library mounted for playout.</p>
|
<p>Upload media files into the local media library mounted for playout.</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
|
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="upload-dropzone" class="upload-dropzone">
|
<div id="upload-dropzone" class="upload-dropzone">
|
||||||
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
|
<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>
|
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">
|
||||||
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
|
Drag and Drop or Click to Upload
|
||||||
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
|
</button>
|
||||||
<progress id="media-upload-progress" value="0" max="100"></progress>
|
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
|
||||||
<p id="media-upload-progress-text" class="small-muted">Uploading (0%)</p>
|
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
|
||||||
</div>
|
<progress id="media-upload-progress" value="0" max="100"></progress>
|
||||||
<p id="media-status" class="status-line" aria-live="polite">Loading media library...</p>
|
<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>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<div class="panel-header-row">
|
<div class="panel-header-row">
|
||||||
<h2>Media Library</h2>
|
<h2>Media Library</h2>
|
||||||
<p id="media-generated-at" class="small-muted">loading...</p>
|
<p id="media-generated-at" class="small-muted">loading...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>File</th>
|
<th>File</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Runtime</th>
|
<th>Runtime</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Size</th>
|
<th>Size</th>
|
||||||
<th>Modified</th>
|
<th>Modified</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="media-table-body"></tbody>
|
<tbody id="media-table-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
<div class="panel-header-row">
|
<div class="panel-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
<p>Current room presence derived from Redis presence keys.</p>
|
<p>Current room presence derived from Redis presence keys.</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="reload-users-btn" type="button" class="btn btn-secondary">Reload users</button>
|
<button id="reload-users-btn" type="button" class="btn btn-secondary">Reload users</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-card">
|
<div class="panel-card">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Room</th>
|
<th>Room</th>
|
||||||
<th>Online</th>
|
<th>Online</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="users-table-body"></tbody>
|
<tbody id="users-table-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p id="users-status" class="status-line" aria-live="polite">Loading active users...</p>
|
<p id="users-status" class="status-line" aria-live="polite">Loading active users...</p>
|
||||||
</div>
|
</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 bodyDataset = document.body.dataset;
|
||||||
const moderationLogLimit = Number.parseInt(
|
const moderationLogLimit = Number.parseInt(bodyDataset.adminModerationLogLimit || "", 10);
|
||||||
bodyDataset.adminModerationLogLimit || "",
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
const adminConfig = {
|
const adminConfig = {
|
||||||
defaultModerator: bodyDataset.adminDefaultModerator || "admin",
|
defaultModerator: bodyDataset.adminDefaultModerator || "admin",
|
||||||
defaultRoom: bodyDataset.adminDefaultRoom || "main",
|
defaultRoom: bodyDataset.adminDefaultRoom || "main",
|
||||||
moderationLogLimit:
|
moderationLogLimit: Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 ? moderationLogLimit : 50,
|
||||||
Number.isFinite(moderationLogLimit) && moderationLogLimit > 0
|
|
||||||
? moderationLogLimit
|
|
||||||
: 50,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
@@ -43,16 +37,11 @@ const state = {
|
|||||||
playlistSaving: false,
|
playlistSaving: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadProgressWrap = () =>
|
const uploadProgressWrap = () => document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
||||||
document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
|
||||||
|
|
||||||
const uploadProgressBar = () =>
|
const uploadProgressBar = () => document.getElementById("media-upload-progress") as HTMLProgressElement | null;
|
||||||
document.getElementById(
|
|
||||||
"media-upload-progress",
|
|
||||||
) as HTMLProgressElement | null;
|
|
||||||
|
|
||||||
const uploadProgressText = () =>
|
const uploadProgressText = () => document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
||||||
document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
|
||||||
|
|
||||||
const setUploadProgress = (value: number, total: number, label: string) => {
|
const setUploadProgress = (value: number, total: number, label: string) => {
|
||||||
const wrap = uploadProgressWrap();
|
const wrap = uploadProgressWrap();
|
||||||
@@ -73,11 +62,9 @@ const hideUploadProgress = () => {
|
|||||||
if (wrap) wrap.hidden = true;
|
if (wrap) wrap.hidden = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFileFingerprint = (file: File) =>
|
const getFileFingerprint = (file: File) => `${file.name}:${file.size}:${file.lastModified}`;
|
||||||
`${file.name}:${file.size}:${file.lastModified}`;
|
|
||||||
|
|
||||||
const getResumeStorageKey = (fingerprint: string) =>
|
const getResumeStorageKey = (fingerprint: string) => `${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
|
||||||
`${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
|
|
||||||
|
|
||||||
const getStoredUploadId = (fingerprint: string) => {
|
const getStoredUploadId = (fingerprint: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -185,7 +172,7 @@ async function switchTab(tabName: string) {
|
|||||||
if (tabName === "system") refreshSystemSummary();
|
if (tabName === "system") refreshSystemSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshOverview() {
|
export async function refreshOverview() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/overview");
|
const response = await fetch("/api/admin/overview");
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
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...");
|
setStatus("analytics-status", "Loading analytics...");
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/analytics");
|
const response = await fetch("/api/admin/analytics");
|
||||||
@@ -221,10 +208,7 @@ async function refreshAnalytics() {
|
|||||||
setText("analytics-total-messages", String(payload.totalMessages ?? 0));
|
setText("analytics-total-messages", String(payload.totalMessages ?? 0));
|
||||||
setText("analytics-messages-24h", String(payload.messagesLast24Hours ?? 0));
|
setText("analytics-messages-24h", String(payload.messagesLast24Hours ?? 0));
|
||||||
setText("analytics-distinct-rooms", String(payload.distinctRooms ?? 0));
|
setText("analytics-distinct-rooms", String(payload.distinctRooms ?? 0));
|
||||||
setText(
|
setText("analytics-moderation-actions", String(payload.moderationActions ?? 0));
|
||||||
"analytics-moderation-actions",
|
|
||||||
String(payload.moderationActions ?? 0),
|
|
||||||
);
|
|
||||||
setText("analytics-generated-at", payload.generatedAt || "unavailable");
|
setText("analytics-generated-at", payload.generatedAt || "unavailable");
|
||||||
|
|
||||||
const tbody = document.getElementById("analytics-table-body");
|
const tbody = document.getElementById("analytics-table-body");
|
||||||
@@ -260,9 +244,7 @@ async function refreshPlaylist() {
|
|||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
const content: string = payload.content || "";
|
const content: string = payload.content || "";
|
||||||
const textarea = document.getElementById(
|
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
||||||
"playlist-content",
|
|
||||||
) as HTMLTextAreaElement | null;
|
|
||||||
if (textarea) textarea.value = content;
|
if (textarea) textarea.value = content;
|
||||||
renderPlaylistSortable(content);
|
renderPlaylistSortable(content);
|
||||||
await showPlaylistWarnings(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");
|
const list = document.getElementById("playlist-sortable");
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||||
list.innerHTML = lines
|
list.innerHTML = lines
|
||||||
.map((line, i) => {
|
.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}">
|
return `<li class="sortable-item" draggable="true" data-index="${i}">
|
||||||
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
||||||
<span class="sortable-index">${i + 1}</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 mediaPaths = new Set((media.files || []).map((f: { path: string }) => f.path));
|
||||||
const nonNorm = (media.files || []).filter((f: { status: string }) => f.status === "needs_normalization");
|
const nonNorm = (media.files || []).filter((f: { status: string }) => f.status === "needs_normalization");
|
||||||
for (const line of lines) {
|
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 (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.`);
|
if (nonNorm.length > 0) msgs.push(`${nonNorm.length} file(s) need normalization. Run scripts/normalize.sh.`);
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
if (msgs.length > 0) {
|
if (msgs.length > 0) {
|
||||||
warnings.innerHTML = msgs.map((m) => `<div class="warning-msg">⚠ ${m}</div>`).join("");
|
warnings.innerHTML = msgs.map((m) => `<div class="warning-msg">⚠ ${m}</div>`).join("");
|
||||||
warnings.hidden = false;
|
warnings.hidden = false;
|
||||||
@@ -362,15 +352,13 @@ async function showPlaylistWarnings(content: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePlaylist() {
|
export async function savePlaylist() {
|
||||||
if (state.playlistSaving) return;
|
if (state.playlistSaving) return;
|
||||||
state.playlistSaving = true;
|
state.playlistSaving = true;
|
||||||
setStatus("playlist-status", "Saving playlist...");
|
setStatus("playlist-status", "Saving playlist...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const textarea = document.getElementById(
|
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
||||||
"playlist-content",
|
|
||||||
) as HTMLTextAreaElement | null;
|
|
||||||
const content = textarea?.value || "";
|
const content = textarea?.value || "";
|
||||||
|
|
||||||
const response = await fetch("/api/admin/playlist", {
|
const response = await fetch("/api/admin/playlist", {
|
||||||
@@ -415,10 +403,7 @@ async function refreshSchedule() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(
|
setStatus("schedule-status", `Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`);
|
||||||
"schedule-status",
|
|
||||||
`Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load schedule", error);
|
console.error("Failed to load schedule", error);
|
||||||
setStatus("schedule-status", "Failed to load schedule.", true);
|
setStatus("schedule-status", "Failed to load schedule.", true);
|
||||||
@@ -445,7 +430,9 @@ async function refreshOverrides() {
|
|||||||
tbody.querySelectorAll("[data-delete-override]").forEach((btn) => {
|
tbody.querySelectorAll("[data-delete-override]").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => deleteOverride(Number((btn as HTMLElement).dataset.deleteOverride)));
|
btn.addEventListener("click", () => deleteOverride(Number((btn as HTMLElement).dataset.deleteOverride)));
|
||||||
});
|
});
|
||||||
} catch { /* non-critical */ }
|
} catch {
|
||||||
|
/* non-critical */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitOverride(e: Event) {
|
async function submitOverride(e: Event) {
|
||||||
@@ -460,18 +447,37 @@ async function submitOverride(e: Event) {
|
|||||||
const resp = await fetch("/api/admin/schedule/overrides", {
|
const resp = await fetch("/api/admin/schedule/overrides", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 (!resp.ok) {
|
||||||
if (status) { status.textContent = "Override added."; status.className = "status-line"; }
|
const text = await resp.text();
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
status.textContent = "Override added.";
|
||||||
|
status.className = "status-line";
|
||||||
|
}
|
||||||
refreshOverrides();
|
refreshOverrides();
|
||||||
} catch (err) {
|
} 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) {
|
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() {
|
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>
|
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () =>
|
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => editMedia(file));
|
||||||
editMedia(file),
|
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
|
||||||
);
|
|
||||||
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () =>
|
|
||||||
deleteMedia(file),
|
|
||||||
);
|
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -612,18 +614,11 @@ async function uploadVideo(file: File) {
|
|||||||
|
|
||||||
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
|
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
|
||||||
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
|
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
|
||||||
const resumeFrom = Math.min(
|
const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks);
|
||||||
Math.max(alreadyUploaded, 0),
|
|
||||||
effectiveTotalChunks,
|
|
||||||
);
|
|
||||||
|
|
||||||
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
|
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
|
||||||
|
|
||||||
for (
|
for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) {
|
||||||
let chunkIndex = resumeFrom;
|
|
||||||
chunkIndex < effectiveTotalChunks;
|
|
||||||
chunkIndex += 1
|
|
||||||
) {
|
|
||||||
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
|
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
|
||||||
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
|
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
|
||||||
const chunk = file.slice(start, end);
|
const chunk = file.slice(start, end);
|
||||||
@@ -641,23 +636,13 @@ async function uploadVideo(file: File) {
|
|||||||
throw new Error(await readErrorMessage(chunkResponse));
|
throw new Error(await readErrorMessage(chunkResponse));
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploadProgress(
|
setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks");
|
||||||
chunkIndex + 1,
|
setStatus("media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`);
|
||||||
effectiveTotalChunks,
|
|
||||||
"Uploading chunks",
|
|
||||||
);
|
|
||||||
setStatus(
|
|
||||||
"media-status",
|
|
||||||
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const completeResponse = await fetch(
|
const completeResponse = await fetch(`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`, {
|
||||||
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
|
method: "POST",
|
||||||
{
|
});
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!completeResponse.ok) {
|
if (!completeResponse.ok) {
|
||||||
throw new Error(await readErrorMessage(completeResponse));
|
throw new Error(await readErrorMessage(completeResponse));
|
||||||
@@ -674,13 +659,8 @@ async function uploadVideo(file: File) {
|
|||||||
await refreshSchedule();
|
await refreshSchedule();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to upload media", error);
|
console.error("Failed to upload media", error);
|
||||||
const details =
|
const details = error instanceof Error ? error.message : "Unknown upload error.";
|
||||||
error instanceof Error ? error.message : "Unknown upload error.";
|
setStatus("media-status", `Upload interrupted. Re-select the same file to resume. (${details})`, true);
|
||||||
setStatus(
|
|
||||||
"media-status",
|
|
||||||
`Upload interrupted. Re-select the same file to resume. (${details})`,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
state.mediaUploading = false;
|
state.mediaUploading = false;
|
||||||
setTimeout(() => hideUploadProgress(), 1200);
|
setTimeout(() => hideUploadProgress(), 1200);
|
||||||
@@ -749,9 +729,7 @@ async function deleteMedia(file: MediaEntry) {
|
|||||||
async function refreshModerationLogs() {
|
async function refreshModerationLogs() {
|
||||||
setStatus("moderation-status", "Loading moderation logs...");
|
setStatus("moderation-status", "Loading moderation logs...");
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
|
||||||
`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`,
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -787,10 +765,8 @@ async function submitModerationAction(event: Event) {
|
|||||||
const payload = {
|
const payload = {
|
||||||
action: (document.getElementById("mod-action") as HTMLSelectElement).value,
|
action: (document.getElementById("mod-action") as HTMLSelectElement).value,
|
||||||
target: (document.getElementById("mod-target") as HTMLInputElement).value,
|
target: (document.getElementById("mod-target") as HTMLInputElement).value,
|
||||||
moderator: (document.getElementById("mod-moderator") as HTMLInputElement)
|
moderator: (document.getElementById("mod-moderator") as HTMLInputElement).value,
|
||||||
.value,
|
reason: (document.getElementById("mod-reason") as HTMLTextAreaElement).value,
|
||||||
reason: (document.getElementById("mod-reason") as HTMLTextAreaElement)
|
|
||||||
.value,
|
|
||||||
room: (document.getElementById("mod-room") as HTMLInputElement).value,
|
room: (document.getElementById("mod-room") as HTMLInputElement).value,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -865,10 +841,7 @@ async function refreshChatLog() {
|
|||||||
renderChatLogTable();
|
renderChatLogTable();
|
||||||
updateChatLogPagination();
|
updateChatLogPagination();
|
||||||
updateBulkDeleteButton();
|
updateBulkDeleteButton();
|
||||||
setStatus(
|
setStatus("chat-log-status", `Loaded ${chatLogState.entries.length} entries.`);
|
||||||
"chat-log-status",
|
|
||||||
`Loaded ${chatLogState.entries.length} entries.`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load chat log", error);
|
console.error("Failed to load chat log", error);
|
||||||
setStatus("chat-log-status", "Failed to load chat log.", true);
|
setStatus("chat-log-status", "Failed to load chat log.", true);
|
||||||
@@ -908,10 +881,7 @@ function renderChatLogTable() {
|
|||||||
tr.querySelector('[data-action="chat-ban"]')?.addEventListener("click", () => {
|
tr.querySelector('[data-action="chat-ban"]')?.addEventListener("click", () => {
|
||||||
if (confirm(`Ban ${entry.username}?`)) userAction("ban", entry.username);
|
if (confirm(`Ban ${entry.username}?`)) userAction("ban", entry.username);
|
||||||
});
|
});
|
||||||
tr.querySelector('[data-action="delete-chat"]')?.addEventListener(
|
tr.querySelector('[data-action="delete-chat"]')?.addEventListener("click", () => deleteChatEntry(entry.id));
|
||||||
"click",
|
|
||||||
() => deleteChatEntry(entry.id),
|
|
||||||
);
|
|
||||||
tr.querySelector(".chat-log-checkbox")?.addEventListener("change", (e) => {
|
tr.querySelector(".chat-log-checkbox")?.addEventListener("change", (e) => {
|
||||||
const cb = e.target as HTMLInputElement;
|
const cb = e.target as HTMLInputElement;
|
||||||
if (cb.checked) {
|
if (cb.checked) {
|
||||||
@@ -929,38 +899,27 @@ function renderChatLogTable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSelectAllCheckbox() {
|
function updateSelectAllCheckbox() {
|
||||||
const selectAll = document.getElementById(
|
const selectAll = document.getElementById("chat-log-select-all") as HTMLInputElement | null;
|
||||||
"chat-log-select-all",
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
if (!selectAll) return;
|
if (!selectAll) return;
|
||||||
const allSelected =
|
const allSelected =
|
||||||
chatLogState.entries.length > 0 &&
|
chatLogState.entries.length > 0 && chatLogState.entries.every((e) => chatLogState.selected.has(e.id));
|
||||||
chatLogState.entries.every((e) => chatLogState.selected.has(e.id));
|
|
||||||
selectAll.checked = allSelected;
|
selectAll.checked = allSelected;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBulkDeleteButton() {
|
function updateBulkDeleteButton() {
|
||||||
const btn = document.getElementById(
|
const btn = document.getElementById("bulk-delete-chat-log-btn") as HTMLButtonElement | null;
|
||||||
"bulk-delete-chat-log-btn",
|
|
||||||
) as HTMLButtonElement | null;
|
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.disabled = chatLogState.selected.size === 0;
|
btn.disabled = chatLogState.selected.size === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChatLogPagination() {
|
function updateChatLogPagination() {
|
||||||
const prevBtn = document.getElementById(
|
const prevBtn = document.getElementById("chat-log-prev-btn") as HTMLButtonElement | null;
|
||||||
"chat-log-prev-btn",
|
const nextBtn = document.getElementById("chat-log-next-btn") as HTMLButtonElement | null;
|
||||||
) as HTMLButtonElement | null;
|
|
||||||
const nextBtn = document.getElementById(
|
|
||||||
"chat-log-next-btn",
|
|
||||||
) as HTMLButtonElement | null;
|
|
||||||
const info = document.getElementById("chat-log-page-info");
|
const info = document.getElementById("chat-log-page-info");
|
||||||
|
|
||||||
if (prevBtn) prevBtn.disabled = !chatLogState.before;
|
if (prevBtn) prevBtn.disabled = !chatLogState.before;
|
||||||
if (nextBtn)
|
if (nextBtn) nextBtn.disabled = chatLogState.entries.length < chatLogState.limit;
|
||||||
nextBtn.disabled = chatLogState.entries.length < chatLogState.limit;
|
if (info) info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`;
|
||||||
if (info)
|
|
||||||
info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatLogNextPage() {
|
function chatLogNextPage() {
|
||||||
@@ -975,29 +934,17 @@ function chatLogPrevPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function chatLogApplyFilter() {
|
function chatLogApplyFilter() {
|
||||||
chatLogState.room =
|
chatLogState.room = (document.getElementById("chat-log-filter-room") as HTMLInputElement)?.value || "";
|
||||||
(document.getElementById("chat-log-filter-room") as HTMLInputElement)
|
chatLogState.username = (document.getElementById("chat-log-filter-username") as HTMLInputElement)?.value || "";
|
||||||
?.value || "";
|
chatLogState.text = (document.getElementById("chat-log-filter-text") 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 = "";
|
chatLogState.before = "";
|
||||||
refreshChatLog();
|
refreshChatLog();
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatLogClearFilter() {
|
function chatLogClearFilter() {
|
||||||
const roomEl = document.getElementById(
|
const roomEl = document.getElementById("chat-log-filter-room") as HTMLInputElement | null;
|
||||||
"chat-log-filter-room",
|
const userEl = document.getElementById("chat-log-filter-username") as HTMLInputElement | null;
|
||||||
) as HTMLInputElement | null;
|
const textEl = document.getElementById("chat-log-filter-text") 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 (roomEl) roomEl.value = "";
|
||||||
if (userEl) userEl.value = "";
|
if (userEl) userEl.value = "";
|
||||||
if (textEl) textEl.value = "";
|
if (textEl) textEl.value = "";
|
||||||
@@ -1009,8 +956,7 @@ function chatLogClearFilter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteChatEntry(id: number) {
|
async function deleteChatEntry(id: number) {
|
||||||
if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`))
|
if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`)) return;
|
||||||
return;
|
|
||||||
|
|
||||||
setStatus("chat-log-status", `Deleting message #${id}...`);
|
setStatus("chat-log-status", `Deleting message #${id}...`);
|
||||||
try {
|
try {
|
||||||
@@ -1030,17 +976,9 @@ async function deleteChatEntry(id: number) {
|
|||||||
async function bulkDeleteChatLog() {
|
async function bulkDeleteChatLog() {
|
||||||
if (chatLogState.selected.size === 0) return;
|
if (chatLogState.selected.size === 0) return;
|
||||||
|
|
||||||
if (
|
if (!window.confirm(`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`)) return;
|
||||||
!window.confirm(
|
|
||||||
`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
setStatus(
|
setStatus("chat-log-status", `Deleting ${chatLogState.selected.size} messages...`);
|
||||||
"chat-log-status",
|
|
||||||
`Deleting ${chatLogState.selected.size} messages...`,
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const ids = Array.from(chatLogState.selected);
|
const ids = Array.from(chatLogState.selected);
|
||||||
const response = await fetch("/api/admin/chat-log/bulk", {
|
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}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
setStatus(
|
setStatus("chat-log-status", `${payload.deleted || ids.length} messages deleted.`);
|
||||||
"chat-log-status",
|
|
||||||
`${payload.deleted || ids.length} messages deleted.`,
|
|
||||||
);
|
|
||||||
await refreshChatLog();
|
await refreshChatLog();
|
||||||
await refreshOverview();
|
await refreshOverview();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1088,17 +1023,23 @@ async function refreshUsers() {
|
|||||||
</td>`;
|
</td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
tbody.querySelectorAll(".user-mute").forEach(b => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
|
tbody
|
||||||
tbody.querySelectorAll(".user-ban").forEach(b => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!)));
|
.querySelectorAll(".user-mute")
|
||||||
tbody.querySelectorAll(".user-unmute").forEach(b => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!)));
|
.forEach((b) => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
|
||||||
tbody.querySelectorAll(".user-unban").forEach(b => b.addEventListener("click", () => userAction("unban", (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(
|
setStatus(
|
||||||
"users-status",
|
"users-status",
|
||||||
payload.redisEnabled
|
payload.redisEnabled ? "Active users loaded." : "Redis disabled; no live presence data available.",
|
||||||
? "Active users loaded."
|
|
||||||
: "Redis disabled; no live presence data available.",
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load users", error);
|
console.error("Failed to load users", error);
|
||||||
@@ -1127,7 +1068,9 @@ async function loadConfig() {
|
|||||||
const cfg = await resp.json();
|
const cfg = await resp.json();
|
||||||
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
||||||
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
||||||
} catch { /* non-critical */ }
|
} catch {
|
||||||
|
/* non-critical */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig(e: Event) {
|
async function saveConfig(e: Event) {
|
||||||
@@ -1144,9 +1087,15 @@ async function saveConfig(e: Event) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
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) {
|
} 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() {
|
function bindEvents() {
|
||||||
document
|
document.querySelectorAll<HTMLButtonElement>(".tab-trigger").forEach((button) => {
|
||||||
.querySelectorAll<HTMLButtonElement>(".tab-trigger")
|
button.addEventListener("click", () => {
|
||||||
.forEach((button) => {
|
const tab = button.dataset.tab;
|
||||||
button.addEventListener("click", () => {
|
if (tab) switchTab(tab);
|
||||||
const tab = button.dataset.tab;
|
|
||||||
if (tab) switchTab(tab);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1186,17 +1133,11 @@ function bindEvents() {
|
|||||||
function bindPageEvents(tabName: string) {
|
function bindPageEvents(tabName: string) {
|
||||||
switch (tabName) {
|
switch (tabName) {
|
||||||
case "upload":
|
case "upload":
|
||||||
document
|
document.getElementById("reload-media-btn")?.addEventListener("click", refreshMediaLibrary);
|
||||||
.getElementById("reload-media-btn")
|
|
||||||
?.addEventListener("click", refreshMediaLibrary);
|
|
||||||
|
|
||||||
const uploadInput = document.getElementById(
|
const uploadInput = document.getElementById("video-upload") as HTMLInputElement | null;
|
||||||
"video-upload",
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
const uploadDropzone = document.getElementById("upload-dropzone");
|
const uploadDropzone = document.getElementById("upload-dropzone");
|
||||||
document
|
document.getElementById("upload-trigger-btn")?.addEventListener("click", () => uploadInput?.click());
|
||||||
.getElementById("upload-trigger-btn")
|
|
||||||
?.addEventListener("click", () => uploadInput?.click());
|
|
||||||
|
|
||||||
uploadDropzone?.addEventListener("dragover", (event) => {
|
uploadDropzone?.addEventListener("dragover", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1221,99 +1162,61 @@ function bindPageEvents(tabName: string) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "playlist":
|
case "playlist":
|
||||||
document
|
document.getElementById("reload-playlist-btn")?.addEventListener("click", refreshPlaylist);
|
||||||
.getElementById("reload-playlist-btn")
|
document.getElementById("save-playlist-btn")?.addEventListener("click", savePlaylist);
|
||||||
?.addEventListener("click", refreshPlaylist);
|
|
||||||
document
|
|
||||||
.getElementById("save-playlist-btn")
|
|
||||||
?.addEventListener("click", savePlaylist);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "schedule":
|
case "schedule":
|
||||||
document
|
document.getElementById("reload-schedule-btn")?.addEventListener("click", refreshSchedule);
|
||||||
.getElementById("reload-schedule-btn")
|
document.getElementById("override-form")?.addEventListener("submit", submitOverride);
|
||||||
?.addEventListener("click", refreshSchedule);
|
|
||||||
document
|
|
||||||
.getElementById("override-form")
|
|
||||||
?.addEventListener("submit", submitOverride);
|
|
||||||
refreshOverrides();
|
refreshOverrides();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "moderation":
|
case "moderation":
|
||||||
document
|
document.getElementById("reload-moderation-btn")?.addEventListener("click", refreshModerationLogs);
|
||||||
.getElementById("reload-moderation-btn")
|
document.getElementById("export-chat-log-btn")?.addEventListener("click", () => {
|
||||||
?.addEventListener("click", refreshModerationLogs);
|
window.open("/api/admin/chat-log/export", "_blank");
|
||||||
document
|
});
|
||||||
.getElementById("export-chat-log-btn")
|
document.getElementById("moderation-form")?.addEventListener("submit", submitModerationAction);
|
||||||
?.addEventListener("click", () => {
|
(document.getElementById("mod-moderator") as HTMLInputElement).value = adminConfig.defaultModerator;
|
||||||
window.open("/api/admin/chat-log/export", "_blank");
|
(document.getElementById("mod-room") as HTMLInputElement).value = adminConfig.defaultRoom;
|
||||||
});
|
|
||||||
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;
|
break;
|
||||||
|
|
||||||
case "chat-log":
|
case "chat-log":
|
||||||
document
|
document.getElementById("reload-chat-log-btn")?.addEventListener("click", refreshChatLog);
|
||||||
.getElementById("reload-chat-log-btn")
|
document.getElementById("bulk-delete-chat-log-btn")?.addEventListener("click", bulkDeleteChatLog);
|
||||||
?.addEventListener("click", refreshChatLog);
|
document.getElementById("chat-log-filter-btn")?.addEventListener("click", chatLogApplyFilter);
|
||||||
document
|
document.getElementById("chat-log-clear-filter-btn")?.addEventListener("click", chatLogClearFilter);
|
||||||
.getElementById("bulk-delete-chat-log-btn")
|
document.getElementById("chat-log-prev-btn")?.addEventListener("click", chatLogPrevPage);
|
||||||
?.addEventListener("click", bulkDeleteChatLog);
|
document.getElementById("chat-log-next-btn")?.addEventListener("click", chatLogNextPage);
|
||||||
document
|
document.getElementById("chat-log-select-all")?.addEventListener("change", (e) => {
|
||||||
.getElementById("chat-log-filter-btn")
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
?.addEventListener("click", chatLogApplyFilter);
|
chatLogState.entries.forEach((entry) => {
|
||||||
document
|
if (checked) {
|
||||||
.getElementById("chat-log-clear-filter-btn")
|
chatLogState.selected.add(entry.id);
|
||||||
?.addEventListener("click", chatLogClearFilter);
|
} else {
|
||||||
document
|
chatLogState.selected.delete(entry.id);
|
||||||
.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();
|
|
||||||
});
|
});
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
renderChatLogTable();
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "analytics":
|
case "analytics":
|
||||||
document
|
document.getElementById("reload-analytics-btn")?.addEventListener("click", refreshAnalytics);
|
||||||
.getElementById("reload-analytics-btn")
|
|
||||||
?.addEventListener("click", refreshAnalytics);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "users":
|
case "users":
|
||||||
document
|
document.getElementById("reload-users-btn")?.addEventListener("click", refreshUsers);
|
||||||
.getElementById("reload-users-btn")
|
|
||||||
?.addEventListener("click", refreshUsers);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "system":
|
case "system":
|
||||||
|
document.getElementById("reload-system-btn")?.addEventListener("click", refreshSystemSummary);
|
||||||
|
document.getElementById("config-form")?.addEventListener("submit", saveConfig);
|
||||||
document
|
document
|
||||||
.getElementById("reload-system-btn")
|
.querySelectorAll(".restart-btn")
|
||||||
?.addEventListener("click", refreshSystemSummary);
|
.forEach((btn) => btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!)));
|
||||||
document
|
|
||||||
.getElementById("config-form")
|
|
||||||
?.addEventListener("submit", saveConfig);
|
|
||||||
document.querySelectorAll(".restart-btn").forEach((btn) =>
|
|
||||||
btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!))
|
|
||||||
);
|
|
||||||
loadConfig();
|
loadConfig();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export function setupCounter(element: HTMLButtonElement) {
|
export function setupCounter(element: HTMLButtonElement) {
|
||||||
let counter = 0
|
let counter = 0;
|
||||||
const setCounter = (count: number) => {
|
const setCounter = (count: number) => {
|
||||||
counter = count
|
counter = count;
|
||||||
element.innerHTML = `Count is ${counter}`
|
element.innerHTML = `Count is ${counter}`;
|
||||||
}
|
};
|
||||||
element.addEventListener('click', () => setCounter(counter + 1))
|
element.addEventListener("click", () => setCounter(counter + 1));
|
||||||
setCounter(0)
|
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;
|
let chatVisible = true;
|
||||||
const onlineUsers = new Set<string>();
|
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();
|
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
||||||
|
|
||||||
if (configuredUrl) {
|
if (configuredUrl) {
|
||||||
@@ -29,27 +38,19 @@ function resolveWebSocketBaseUrl() {
|
|||||||
return `${protocol}//${window.location.host}/ws`;
|
return `${protocol}//${window.location.host}/ws`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveManifestUrl() {
|
export function resolveManifestUrl() {
|
||||||
const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
|
const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
|
||||||
const cacheBust = `stream=${Date.now()}`;
|
const cacheBust = `stream=${Date.now()}`;
|
||||||
const sameOriginManifestUrl = new URL(
|
const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin);
|
||||||
"/hls/playlist.m3u8",
|
|
||||||
window.location.origin,
|
|
||||||
);
|
|
||||||
sameOriginManifestUrl.searchParams.set("stream", cacheBust);
|
sameOriginManifestUrl.searchParams.set("stream", cacheBust);
|
||||||
|
|
||||||
const allowCrossOriginManifest =
|
const allowCrossOriginManifest = import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === "true";
|
||||||
import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() ===
|
|
||||||
"true";
|
|
||||||
|
|
||||||
if (configuredUrl) {
|
if (configuredUrl) {
|
||||||
const manifestUrl = new URL(configuredUrl);
|
const manifestUrl = new URL(configuredUrl);
|
||||||
manifestUrl.searchParams.set("stream", cacheBust);
|
manifestUrl.searchParams.set("stream", cacheBust);
|
||||||
|
|
||||||
if (
|
if (!allowCrossOriginManifest && manifestUrl.origin !== window.location.origin) {
|
||||||
!allowCrossOriginManifest &&
|
|
||||||
manifestUrl.origin !== window.location.origin
|
|
||||||
) {
|
|
||||||
return sameOriginManifestUrl.toString();
|
return sameOriginManifestUrl.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +97,7 @@ function connectWebSocket(room: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMessage(
|
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
|
||||||
type: string,
|
|
||||||
text: string,
|
|
||||||
sender = "",
|
|
||||||
timestamp = new Date(),
|
|
||||||
) {
|
|
||||||
const messages = document.getElementById("messages")!;
|
const messages = document.getElementById("messages")!;
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "message-row";
|
div.className = "message-row";
|
||||||
@@ -110,9 +106,7 @@ function addMessage(
|
|||||||
div.classList.add("message-system-row");
|
div.classList.add("message-system-row");
|
||||||
div.innerHTML = `<div class="message-system">${text}</div>`;
|
div.innerHTML = `<div class="message-system">${text}</div>`;
|
||||||
} else {
|
} else {
|
||||||
div.classList.add(
|
div.classList.add(sender === username ? "message-self-row" : "message-other-row");
|
||||||
sender === username ? "message-self-row" : "message-other-row",
|
|
||||||
);
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
|
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
|
||||||
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
|
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
|
||||||
@@ -131,10 +125,7 @@ function renderPresence() {
|
|||||||
if (!list) return;
|
if (!list) return;
|
||||||
const sorted = [...onlineUsers].sort();
|
const sorted = [...onlineUsers].sort();
|
||||||
list.innerHTML = sorted
|
list.innerHTML = sorted
|
||||||
.map(
|
.map((u) => `<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`)
|
||||||
(u) =>
|
|
||||||
`<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`,
|
|
||||||
)
|
|
||||||
.join("");
|
.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 video = document.getElementById("player") as HTMLVideoElement;
|
||||||
const manifestUrl = resolveManifestUrl();
|
const manifestUrl = resolveManifestUrl();
|
||||||
|
|
||||||
@@ -166,7 +157,7 @@ async function initPlayerAndChat() {
|
|||||||
video.addEventListener("loadedmetadata", () => video.play());
|
video.addEventListener("loadedmetadata", () => video.play());
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const { default: Hls } = await import("hls.js");
|
const { default: Hls } = await loadHlsModule();
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
hls = new Hls();
|
hls = new Hls();
|
||||||
hls.loadSource(manifestUrl);
|
hls.loadSource(manifestUrl);
|
||||||
@@ -185,9 +176,7 @@ async function initPlayerAndChat() {
|
|||||||
refreshSchedule();
|
refreshSchedule();
|
||||||
setInterval(refreshSchedule, 30000);
|
setInterval(refreshSchedule, 30000);
|
||||||
|
|
||||||
console.log(
|
console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
|
||||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleChatPanel() {
|
function toggleChatPanel() {
|
||||||
@@ -220,9 +209,7 @@ function sendMessage() {
|
|||||||
function switchRoom(room: string) {
|
function switchRoom(room: string) {
|
||||||
currentRoom = room;
|
currentRoom = room;
|
||||||
document.getElementById("room-name")!.textContent =
|
document.getElementById("room-name")!.textContent =
|
||||||
room === "main"
|
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
||||||
? "Main Channel"
|
|
||||||
: room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
|
||||||
addMessage("system", `Switched to ${room} room`);
|
addMessage("system", `Switched to ${room} room`);
|
||||||
connectWebSocket(room);
|
connectWebSocket(room);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-17
@@ -1,8 +1,7 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
--font-body:
|
--font-body: "Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
"Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
|
|
||||||
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
||||||
|
|
||||||
@@ -118,12 +117,7 @@ body {
|
|||||||
body {
|
body {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
color: var(--ink-0);
|
color: var(--ink-0);
|
||||||
background: radial-gradient(
|
background: radial-gradient(circle at 20% 10%, var(--bg-grad-a) 0%, var(--bg-grad-b) 50%, var(--bg-grad-c) 100%);
|
||||||
circle at 20% 10%,
|
|
||||||
var(--bg-grad-a) 0%,
|
|
||||||
var(--bg-grad-b) 50%,
|
|
||||||
var(--bg-grad-c) 100%
|
|
||||||
);
|
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +256,7 @@ select {
|
|||||||
.btn {
|
.btn {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-1);
|
border-radius: var(--radius-1);
|
||||||
padding: 0.62rem 0.9rem;
|
padding: 0.2rem 0.5rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
@@ -411,14 +405,14 @@ select {
|
|||||||
|
|
||||||
.lv-body {
|
.lv-body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-shell {
|
.viewer-shell {
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -467,11 +461,7 @@ select {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--brand-contrast);
|
color: var(--brand-contrast);
|
||||||
background: linear-gradient(
|
background: linear-gradient(140deg, var(--badge-start) 0%, var(--badge-end) 100%);
|
||||||
140deg,
|
|
||||||
var(--badge-start) 0%,
|
|
||||||
var(--badge-end) 100%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-text,
|
.brand-text,
|
||||||
@@ -821,7 +811,7 @@ select {
|
|||||||
border: 1px solid var(--line-soft);
|
border: 1px solid var(--line-soft);
|
||||||
border-radius: var(--radius-2);
|
border-radius: var(--radius-2);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
padding: var(--space-4);
|
padding: var(--space-2);
|
||||||
box-shadow: var(--shadow-1);
|
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)
|
# 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)**
|
1. **.m4s and .ts segments (immutable)**
|
||||||
- Expression: (http.host eq "media.example.com" and http.request.uri.path contains ".ts")
|
- 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
|
- Cache status: Eligible for cache
|
||||||
- Edge Cache TTL: 1 month (or `Cache-Control: public, max-age=31536000, immutable` from origin)
|
- Edge Cache TTL: 1 month (or `Cache-Control: public, max-age=31536000, immutable` from origin)
|
||||||
- Cache Everything: Yes
|
- Cache Everything: Yes
|
||||||
|
|
||||||
2. **.m3u8 manifests (live)**
|
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
|
- Cache status: Eligible for cache
|
||||||
- Edge Cache TTL: 10 seconds
|
- Edge Cache TTL: 2 seconds
|
||||||
- Cache-Control: public, max-age=10, must-revalidate
|
- Cache-Control: public, max-age=2, must-revalidate
|
||||||
|
|
||||||
3. **Static assets**
|
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)
|
- 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.
|
**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.
|
**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) {
|
async function handleRequest(request) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
// Add immutable cache headers for .ts segments
|
// Add immutable cache headers for .m4s segments
|
||||||
if (url.pathname.endsWith(".ts")) {
|
if (url.pathname.endsWith(".m4s")) {
|
||||||
const response = await fetch(request);
|
const response = await fetch(request);
|
||||||
const headers = new Headers(response.headers);
|
const headers = new Headers(response.headers);
|
||||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
|||||||
@@ -34,6 +34,33 @@
|
|||||||
"title": "Chat Scrape Health",
|
"title": "Chat Scrape Health",
|
||||||
"type": "stat",
|
"type": "stat",
|
||||||
"targets": [{ "expr": "up{job=\"chat\"}" }]
|
"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."
|
description: "Playout has switched to emergency slate. Check playout logs."
|
||||||
|
|
||||||
- alert: HighChatLatency
|
- 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
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Unusually low chat message rate"
|
summary: "Chat request latency is high"
|
||||||
description: "Message rate below 0.01/s for 5 minutes. Check chat service health."
|
description: "Chat request p95 has been above 1s for 5 minutes. Check chat handlers and dependencies."
|
||||||
|
|
||||||
- alert: PrometheusWALFailing
|
- alert: PrometheusWALFailing
|
||||||
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
|
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
|
||||||
@@ -64,6 +64,24 @@ groups:
|
|||||||
summary: "High CPU usage"
|
summary: "High CPU usage"
|
||||||
description: "CPU usage above 80% for 10 minutes on {{ $labels.instance }}."
|
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
|
- alert: LowDiskSpace
|
||||||
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
|
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
|
||||||
for: 5m
|
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