Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f73928103a | ||
|
|
b427353672 | ||
|
|
f9aba11b90 | ||
|
|
926a663c23 | ||
|
|
6b9830de05 | ||
|
|
914a518f20 | ||
|
|
8064e459f2 | ||
|
|
587561f9fc | ||
|
|
887023a491 | ||
|
|
994c433fc8 | ||
|
|
6632df0bc7 | ||
|
|
e4ffa0b356 | ||
|
|
e8a43b8a79 | ||
|
|
b3a10ba3ef | ||
|
|
46e878bef6 | ||
|
|
39cca5d235 | ||
|
|
2ed89760d6 | ||
|
|
a8df164cc2 | ||
|
|
0a8221cf83 |
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
|
||||
+75
-1
@@ -48,7 +48,7 @@ jobs:
|
||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v prometheus-config:/etc/prometheus \
|
||||
-v linearvoid_prometheus-config:/etc/prometheus \
|
||||
alpine:latest sh -c '
|
||||
cat > /etc/prometheus/prometheus.yml << "PROMEOF"
|
||||
global:
|
||||
@@ -113,6 +113,20 @@ jobs:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Playout in slate fallback"
|
||||
- alert: HighChatLatency
|
||||
expr: rate(linearvoid_chat_messages_total[5m]) < 0.01
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Unusually low chat message rate"
|
||||
- alert: PrometheusWALFailing
|
||||
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Prometheus WAL writes failing"
|
||||
- alert: HighCPU
|
||||
expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
|
||||
for: 10m
|
||||
@@ -130,6 +144,66 @@ jobs:
|
||||
ALERTSEOF
|
||||
'
|
||||
|
||||
- name: Seed Grafana provisioning on remote host
|
||||
env:
|
||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v linearvoid_grafana-provisioning:/etc/grafana/provisioning \
|
||||
alpine:latest sh -c '
|
||||
mkdir -p /etc/grafana/provisioning/datasources /etc/grafana/provisioning/dashboards
|
||||
cat > /etc/grafana/provisioning/datasources/datasource.yml << "DSEOF"
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: true
|
||||
DSEOF
|
||||
cat > /etc/grafana/provisioning/dashboards/dashboard.yml << "PROVEOF"
|
||||
apiVersion: 1
|
||||
providers:
|
||||
- name: "default"
|
||||
orgId: 1
|
||||
folder: ""
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 10
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards
|
||||
PROVEOF
|
||||
cat > /etc/grafana/provisioning/dashboards/linearvoid-app.json << "DBEOF"
|
||||
{
|
||||
"title": "Linear Void Monitoring",
|
||||
"panels": [
|
||||
{
|
||||
"title": "Concurrent Viewers",
|
||||
"type": "timeseries",
|
||||
"targets": [{ "expr": "linearvoid_chat_connected_clients" }]
|
||||
},
|
||||
{
|
||||
"title": "Stored Messages",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "linearvoid_chat_messages_total" }]
|
||||
},
|
||||
{
|
||||
"title": "Redis Reachable",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "linearvoid_chat_redis_up" }]
|
||||
},
|
||||
{
|
||||
"title": "Playout Mode",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "linearvoid_playout_mode" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
DBEOF
|
||||
'
|
||||
|
||||
- name: Deploy with docker-compose
|
||||
env:
|
||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||
|
||||
+11
-1
@@ -43,7 +43,17 @@ jobs:
|
||||
run: cd backend/chat && GOTOOLCHAIN=local go vet ./...
|
||||
|
||||
- name: Run Tests
|
||||
run: echo "Tests will be added in future CI steps"
|
||||
run: cd backend/chat && GOTOOLCHAIN=local go test ./... -v -cover -coverprofile=coverage.out -race
|
||||
|
||||
- name: Check Coverage
|
||||
run: |
|
||||
cd backend/chat
|
||||
COVERAGE=$(GOTOOLCHAIN=local go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
|
||||
echo "Coverage: $COVERAGE%"
|
||||
if [ "$(echo "$COVERAGE < 50" | bc)" -eq 1 ]; then
|
||||
echo "Coverage $COVERAGE% below 50% threshold"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker:
|
||||
needs: test
|
||||
|
||||
+12
-4
@@ -7,6 +7,10 @@
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Test coverage
|
||||
coverage
|
||||
coverage.out
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -15,6 +19,10 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Benchmark artifacts
|
||||
artifacts/
|
||||
scripts/loadtest/artifacts/
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
@@ -41,6 +49,7 @@ out/
|
||||
# FFmpeg temporary files
|
||||
*.ts.tmp
|
||||
*.m3u8.tmp
|
||||
*.m4s.tmp
|
||||
hls/
|
||||
temp/
|
||||
|
||||
@@ -79,10 +88,9 @@ Thumbs.db
|
||||
.terraform/
|
||||
|
||||
# Agent / Tool files
|
||||
.github/instructions/TODO.md
|
||||
.github/instructions/DONE.md
|
||||
.github/instructions/BACKLOG.md
|
||||
# (keep AGENTS.md, MEMORY.md, and other instruction files)
|
||||
.github/agents/
|
||||
.github/instructions/
|
||||
.github/MEMORY.md
|
||||
|
||||
# Temporary architecture artifacts
|
||||
**/*.tmp
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# =============================================
|
||||
# Linear Void .prettierignore
|
||||
# =============================================
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
*.min.js
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
coverage.out
|
||||
|
||||
# Go source (handled by gofmt, not prettier)
|
||||
*.go
|
||||
go.mod
|
||||
go.sum
|
||||
|
||||
# Binary / compiled
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Media files
|
||||
*.m4s
|
||||
*.m3u8
|
||||
*.ts.tmp # FFmpeg temp, not TypeScript
|
||||
*.m3u8.tmp
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.svg
|
||||
*.ico
|
||||
*.webp
|
||||
*.mp4
|
||||
*.webm
|
||||
*.mp3
|
||||
*.ogg
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Databases
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Lockfile (auto-generated, don't reformat)
|
||||
package-lock.json
|
||||
|
||||
# Shell scripts (prettier doesn't handle sh)
|
||||
*.sh
|
||||
|
||||
# Environment & secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Editor & IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
|
||||
# Terraform / IaC
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
.terraform/
|
||||
|
||||
# Temp & backup files
|
||||
**/*.tmp
|
||||
**/*.bak
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
@@ -2,6 +2,42 @@
|
||||
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
## [1.0.11] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Phase 1 test fixtures**: Media and playlist test fixtures in `backend/playout/testdata/` with ffmpeg generation commands documented in test headers.
|
||||
- **Phase 4 R2 integration**: `SyncToR2` now sets `CacheControl` + `ContentType` based on file extension (immutable segments, short-lived manifests). Three integration tests added.
|
||||
- **Phase 2 viewer tests**: Presence updates (join/leave/sorted), private DM flow, message rendering (system/self/other) in `main.test.ts` (27 total tests).
|
||||
- **Phase 3 admin tests**: Playlist management (save via PUT, error handling, sortable rendering) in `admin.test.ts` (31 total tests passing).
|
||||
|
||||
### Changed
|
||||
|
||||
- Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts` for testing.
|
||||
|
||||
## [1.0.10] - 2026-08-09
|
||||
|
||||
### Added
|
||||
|
||||
- **WebSocket summary helper**: Added `scripts/loadtest/summarize-artillery.ps1` to extract counters, rates, and available percentile stats from Artillery JSON output.
|
||||
- **Playbook worksheet mapping**: Added artifact-to-worksheet mapping section in `docs/operations/scaling-performance-playbook.md` for WebSocket and Admin API benchmark artifacts.
|
||||
- **Test fixture generation docs**: Added ffmpeg commands for generating valid and invalid media fixtures as comments in `backend/playout/src/playlist_test.go`. Phase 1 test fixtures now complete.
|
||||
- **R2 cache header support**: `SyncToR2` now sets `CacheControl` and `ContentType` based on file extension:
|
||||
- `.m4s`/`.ts`/`.mp4`/`.m4a` → immutable (max-age=31536000)
|
||||
- `.m3u8` → short-lived (max-age=2s)
|
||||
- unknown → no cache headers
|
||||
- **R2 integration tests**: Added `TestSyncToR2_ManifestCacheHeaders` and `TestSyncToR2_NoCacheHeadersForUnknownExt` verifying cache header behavior for segment, manifest, and unknown file types.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Loadtest runner**: `scripts/loadtest/run-loadtests.ps1` now generates `ws-chat-summary.txt` automatically for `ws` and `all` scenarios.
|
||||
- **Loadtest docs**: `scripts/loadtest/README.md` now documents `summarize-artillery.ps1` usage and the new `ws-chat-summary.txt` artifact.
|
||||
- **Planning status wording**: Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while benchmark execution remains pending.
|
||||
- **Phase 1 complete**: Test fixture creation and ffmpeg documentation marked done.
|
||||
- **Phase 4 partially complete**: R2 upload + cache header validation done; edge cache hit/miss validation remains manual.
|
||||
- **Phase 2 frontend tests**: Added presence updates (join/leave rendering), private DM flow, and message rendering tests in `main.test.ts` (27 total tests passing).
|
||||
- **Phase 3 admin frontend tests**: Added playlist management tests (save via PUT, error handling, sortable rendering) in `admin.test.ts` (31 total tests passing).
|
||||
|
||||
## [1.0.9] - 2026-08-06
|
||||
|
||||
### Added
|
||||
@@ -16,10 +52,15 @@
|
||||
- **CI pipeline**: Removed redundant `release` job (handled by `release.yml`).
|
||||
- **Tag format**: Standardized on `*.*.*` (e.g. `1.0.9`), removed `v` prefix from existing tags.
|
||||
- **DEPLOYMENT.md**: Documented parallel CI/CD pipeline flow with workflow table.
|
||||
- **Runtime checks**: Executed normalization edge-case tests (MOV, MKV, VFR, 5.1 input) and concat continuity verification in running playout environment.
|
||||
- **Incident runbook**: Added quarterly disaster recovery drill procedure with prerequisites, outage simulation, restore validation, and evidence template.
|
||||
- **Manual ops checklist**: Added `docs/operations/cloudflare-uptimerobot-checklist.md` to execute and record Cloudflare + UptimeRobot dashboard tasks with evidence capture.
|
||||
- **Scaling playbook**: Added `docs/operations/scaling-performance-playbook.md` with benchmark methods, trigger thresholds, and Phase 2/3 scaling decision flow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Prometheus config mount**: Switched prod from bind mounts to named volume `prometheus-config`, populated via `docker run` on remote host. Fixes `not a directory` error when bind source path doesn't exist on deployment host.
|
||||
- **Viewer mobile overflow**: Replaced `100vw` with `100%` on full-screen viewer containers to remove horizontal overflow on mobile viewports.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||
[](LICENSE)
|
||||
[](https://git.allucanget.biz)
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||
[](CHANGELOG.md)
|
||||
|
||||
## Architecture
|
||||
@@ -15,7 +15,7 @@
|
||||
The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses:
|
||||
|
||||
- Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat _(planned production; currently test lab at 192.168.88.40)_.
|
||||
- Cloudflare R2 + CDN with aggressive immutable caching of HLS `.ts` segments ($0 egress).
|
||||
- Cloudflare R2 + CDN with aggressive immutable caching of `.m4s` segments ($0 egress).
|
||||
- Lightweight static frontend and WebSocket chat backend.
|
||||
- Gitea self-hosted CI/CD (Actions) and Container Registry.
|
||||
|
||||
@@ -206,11 +206,12 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
|
||||
|
||||
- [CHANGELOG.md](CHANGELOG.md) - Version history and completed features.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Guidelines for contributing to the project.
|
||||
- [SETUP.md](SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
||||
- [API.md](API.md) - Implemented WebSocket and REST API reference.
|
||||
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
|
||||
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
||||
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
||||
- [SETUP.md](docs/SETUP.md) - Complete setup guide with prerequisites, environment configuration, running the application, and troubleshooting.
|
||||
- [API.md](docs/API.md) - Implemented WebSocket and REST API reference.
|
||||
- [DEPLOYMENT.md](docs/DEPLOYMENT.md) - Production deployment and operations guide.
|
||||
- [MONITORING.md](docs/MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
||||
- [SECURITY.md](docs/SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
||||
- [Scaling Playbook](docs/operations/scaling-performance-playbook.md) - Performance test method, thresholds, and scaling decision flow.
|
||||
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
||||
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
||||
|
||||
@@ -227,8 +228,8 @@ Contributions are welcome! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelin
|
||||
3. Open PRs on [Gitea](https://git.allucanget.biz/zwitschi/linearvoid).
|
||||
4. CI validates builds and tests automatically.
|
||||
|
||||
Since this a linear project, we also rely on a linear development and release model: Only main branch is used for releases, and all features are merged into main after review and CI validation. Releases are tagged with semantic versioning (1.0.3, 1.0.4, etc.) and automatically built/pushed to the container registry. Version numbers are declared in `frontend/package.json`, `CHANGELOG.md`, and the version badge in this README. For patches, we use even numbers for stable versions (1.0.2, 1.0.4) and odd numbers for pre-release/unstable versions (1.0.3, 1.0.5).
|
||||
Since this is a linear project, we also rely on a linear development and release model: only `main` branch is used for releases, and all features are merged into `main` after review and CI validation. Releases use semantic version tags in `*.*.*` format (for example `1.0.9`) and are automatically built/pushed to the container registry. Version numbers are declared in `frontend/package.json`, `CHANGELOG.md`, and the version badge in this README.
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
The project is ready for use. See [`SETUP.md`](SETUP.md) for detailed installation instructions.
|
||||
The project is ready for use. See [`SETUP.md`](docs/SETUP.md) for detailed installation instructions.
|
||||
+11
-2
@@ -3,24 +3,33 @@ module chat
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
+26
-4
@@ -9,6 +9,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -23,12 +33,15 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -38,6 +51,9 @@ golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -46,7 +62,13 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionStore manages admin authentication sessions.
|
||||
type SessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]time.Time
|
||||
}
|
||||
|
||||
// NewSessionStore creates a new session store.
|
||||
func NewSessionStore() *SessionStore {
|
||||
return &SessionStore{
|
||||
sessions: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken creates a cryptographically random 64-char hex session token.
|
||||
func GenerateToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// Validate checks if a session token is valid and not expired.
|
||||
// Returns true if valid, false otherwise. Expired sessions are cleaned up.
|
||||
func (s *SessionStore) Validate(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
expiresAt, ok := s.sessions[token]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(expiresAt) {
|
||||
delete(s.sessions, token)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Add stores a session token with expiry.
|
||||
func (s *SessionStore) Add(token string, expiresAt time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[token] = expiresAt
|
||||
}
|
||||
|
||||
// Remove deletes a session token.
|
||||
func (s *SessionStore) Remove(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
|
||||
// CountActive returns the number of unexpired sessions.
|
||||
func (s *SessionStore) CountActive() int {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for token, expiresAt := range s.sessions {
|
||||
if now.After(expiresAt) {
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
}
|
||||
return len(s.sessions)
|
||||
}
|
||||
|
||||
// CookieSecure determines if the session cookie should have Secure flag.
|
||||
func CookieSecure(r *http.Request) bool {
|
||||
if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
|
||||
return strings.EqualFold(forwardedProto, "https")
|
||||
}
|
||||
return r.TLS != nil
|
||||
}
|
||||
|
||||
// SetCookie writes the admin session cookie.
|
||||
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: CookieSecure(r),
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// ClearCookie removes the admin session cookie.
|
||||
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: CookieSecure(r),
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTokenFromCookie extracts the session token from the request cookie.
|
||||
func GetTokenFromCookie(r *http.Request, cookieName string) (string, bool) {
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return "", false
|
||||
}
|
||||
return cookie.Value, true
|
||||
}
|
||||
|
||||
// RequireAdmin returns middleware that checks for a valid admin session.
|
||||
func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc) http.HandlerFunc {
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := GetTokenFromCookie(r, cookieName)
|
||||
if !ok || !store.Validate(token) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateToken(t *testing.T) {
|
||||
token, err := GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() error = %v", err)
|
||||
}
|
||||
if len(token) != 64 {
|
||||
t.Errorf("GenerateToken() length = %d, want 64", len(token))
|
||||
}
|
||||
|
||||
// Uniqueness check
|
||||
tokens := make(map[string]bool)
|
||||
for i := 0; i < 1000; i++ {
|
||||
tok, err := GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() iteration %d error = %v", i, err)
|
||||
}
|
||||
if tokens[tok] {
|
||||
t.Errorf("GenerateToken() produced duplicate token at iteration %d", i)
|
||||
}
|
||||
tokens[tok] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Validate(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
|
||||
// Empty token
|
||||
if store.Validate("") {
|
||||
t.Error("Validate(\"\") = true, want false")
|
||||
}
|
||||
|
||||
// Missing token
|
||||
if store.Validate("nonexistent") {
|
||||
t.Error("Validate(\"nonexistent\") = true, want false")
|
||||
}
|
||||
|
||||
// Valid token
|
||||
token, _ := GenerateToken()
|
||||
store.Add(token, time.Now().Add(1*time.Hour))
|
||||
if !store.Validate(token) {
|
||||
t.Error("Validate(valid) = false, want true")
|
||||
}
|
||||
|
||||
// Expired token
|
||||
expiredToken, _ := GenerateToken()
|
||||
store.Add(expiredToken, time.Now().Add(-1*time.Hour))
|
||||
if store.Validate(expiredToken) {
|
||||
t.Error("Validate(expired) = true, want false")
|
||||
}
|
||||
|
||||
// Expired token should be cleaned up
|
||||
if store.Validate(expiredToken) {
|
||||
t.Error("Validate(expired) second call = true, want false (should be cleaned)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_AddRemove(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
token, _ := GenerateToken()
|
||||
|
||||
store.Add(token, time.Now().Add(1*time.Hour))
|
||||
if !store.Validate(token) {
|
||||
t.Error("Validate after Add = false, want true")
|
||||
}
|
||||
|
||||
store.Remove(token)
|
||||
if store.Validate(token) {
|
||||
t.Error("Validate after Remove = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_CountActive(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
|
||||
if count := store.CountActive(); count != 0 {
|
||||
t.Errorf("CountActive() = %d, want 0", count)
|
||||
}
|
||||
|
||||
t1, _ := GenerateToken()
|
||||
t2, _ := GenerateToken()
|
||||
t3, _ := GenerateToken()
|
||||
|
||||
store.Add(t1, time.Now().Add(1*time.Hour))
|
||||
store.Add(t2, time.Now().Add(1*time.Hour))
|
||||
store.Add(t3, time.Now().Add(-1*time.Hour)) // expired
|
||||
|
||||
if count := store.CountActive(); count != 2 {
|
||||
t.Errorf("CountActive() = %d, want 2 (expired should be cleaned)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCookieSecure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
forwardedProto string
|
||||
tls bool
|
||||
expected bool
|
||||
}{
|
||||
{"https forwarded", "https", false, true},
|
||||
{"http forwarded", "http", false, false},
|
||||
{"empty forwarded, no TLS", "", false, false},
|
||||
{"empty forwarded, TLS", "", true, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
if tt.forwardedProto != "" {
|
||||
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
|
||||
}
|
||||
if tt.tls {
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
}
|
||||
if got := CookieSecure(req); got != tt.expected {
|
||||
t.Errorf("CookieSecure() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCookie(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
SetCookie(w, req, "test_cookie", "abc123", expiresAt)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != "test_cookie" {
|
||||
t.Errorf("cookie name = %q, want test_cookie", c.Name)
|
||||
}
|
||||
if c.Value != "abc123" {
|
||||
t.Errorf("cookie value = %q, want abc123", c.Value)
|
||||
}
|
||||
if !c.HttpOnly {
|
||||
t.Error("cookie HttpOnly = false, want true")
|
||||
}
|
||||
if c.SameSite != http.SameSiteStrictMode {
|
||||
t.Errorf("cookie SameSite = %v, want Strict", c.SameSite)
|
||||
}
|
||||
if !c.Secure {
|
||||
t.Error("cookie Secure = false, want true (X-Forwarded-Proto: https)")
|
||||
}
|
||||
if c.Path != "/" {
|
||||
t.Errorf("cookie Path = %q, want /", c.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCookie(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ClearCookie(w, req, "test_cookie")
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != "test_cookie" {
|
||||
t.Errorf("cookie name = %q, want test_cookie", c.Name)
|
||||
}
|
||||
if c.Value != "" {
|
||||
t.Errorf("cookie value = %q, want empty", c.Value)
|
||||
}
|
||||
if c.MaxAge != -1 {
|
||||
t.Errorf("cookie MaxAge = %d, want -1", c.MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenFromCookie(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
|
||||
|
||||
token, ok := GetTokenFromCookie(req, "session")
|
||||
if !ok {
|
||||
t.Error("GetTokenFromCookie() ok = false, want true")
|
||||
}
|
||||
if token != "token123" {
|
||||
t.Errorf("GetTokenFromCookie() = %q, want token123", token)
|
||||
}
|
||||
|
||||
// Missing cookie
|
||||
_, ok = GetTokenFromCookie(req, "nonexistent")
|
||||
if ok {
|
||||
t.Error("GetTokenFromCookie() for missing cookie ok = true, want false")
|
||||
}
|
||||
|
||||
// Empty value
|
||||
req2 := httptest.NewRequest("GET", "/", nil)
|
||||
req2.AddCookie(&http.Cookie{Name: "session", Value: ""})
|
||||
_, ok = GetTokenFromCookie(req2, "session")
|
||||
if ok {
|
||||
t.Error("GetTokenFromCookie() for empty value ok = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdmin(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
token, _ := GenerateToken()
|
||||
store.Add(token, time.Now().Add(1*time.Hour))
|
||||
|
||||
handler := RequireAdmin(store, "session")(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// Valid session
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: token})
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// No cookie
|
||||
req2 := httptest.NewRequest("GET", "/", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler(w2, req2)
|
||||
if w2.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w2.Code)
|
||||
}
|
||||
|
||||
// Invalid token
|
||||
req3 := httptest.NewRequest("GET", "/", nil)
|
||||
req3.AddCookie(&http.Cookie{Name: "session", Value: "invalid"})
|
||||
w3 := httptest.NewRecorder()
|
||||
handler(w3, req3)
|
||||
if w3.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w3.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defaults and constants
|
||||
const (
|
||||
AdminSessionCookieName = "linearvoid_admin_session"
|
||||
MaxAdminUploadBytes int64 = 2 * 1024 * 1024 * 1024
|
||||
DefaultAdminChunkSize int64 = 8 * 1024 * 1024
|
||||
MaxAdminChunkSize int64 = 32 * 1024 * 1024
|
||||
DefaultRateLimitMax = 10
|
||||
DefaultRateLimitWindow = 10 * time.Second
|
||||
DefaultAdminSessionTTL = 12 * time.Hour
|
||||
DefaultChatPort = "3000"
|
||||
)
|
||||
|
||||
// PlaylistPath returns the playlist file path from env or default.
|
||||
func PlaylistPath() string {
|
||||
p := strings.TrimSpace(os.Getenv("PLAYLIST_PATH"))
|
||||
if p == "" {
|
||||
return "/app/playlist/playlist.txt"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// MediaDir returns the media directory from env or default.
|
||||
func MediaDir() string {
|
||||
d := strings.TrimSpace(os.Getenv("MEDIA_DIR"))
|
||||
if d == "" {
|
||||
return "/app/media"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// AdminUsername returns the admin username from env or default.
|
||||
func AdminUsername() string {
|
||||
u := strings.TrimSpace(os.Getenv("ADMIN_USERNAME"))
|
||||
if u == "" {
|
||||
return "admin"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// AdminPassword returns the admin password from env or default.
|
||||
func AdminPassword() string {
|
||||
p := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD"))
|
||||
if p == "" {
|
||||
return "admin"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// AdminSessionTTL returns the admin session TTL from env or default.
|
||||
func AdminSessionTTL() time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS"))
|
||||
if raw == "" {
|
||||
return DefaultAdminSessionTTL
|
||||
}
|
||||
hours, err := strconv.Atoi(raw)
|
||||
if err != nil || hours <= 0 {
|
||||
return DefaultAdminSessionTTL
|
||||
}
|
||||
return time.Duration(hours) * time.Hour
|
||||
}
|
||||
|
||||
// RateLimitMax returns the chat rate limit max messages per window.
|
||||
func RateLimitMax() int {
|
||||
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
|
||||
if raw == "" {
|
||||
return DefaultRateLimitMax
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return DefaultRateLimitMax
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// RateLimitWindow returns the chat rate limit window duration.
|
||||
func RateLimitWindow() time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_WINDOW_S"))
|
||||
if raw == "" {
|
||||
return DefaultRateLimitWindow
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return DefaultRateLimitWindow
|
||||
}
|
||||
return time.Duration(v) * time.Second
|
||||
}
|
||||
|
||||
// ChatPort returns the chat service port from env or default.
|
||||
func ChatPort() string {
|
||||
p := strings.TrimSpace(os.Getenv("CHAT_PORT"))
|
||||
if p == "" {
|
||||
return DefaultChatPort
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// RedisAddress returns the Redis address from env or default.
|
||||
func RedisAddress() string {
|
||||
a := strings.TrimSpace(os.Getenv("REDIS_URL"))
|
||||
if a == "" {
|
||||
return "redis:6379"
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// DatabaseURL returns the database URL from env (empty = SQLite default).
|
||||
func DatabaseURL() string {
|
||||
return strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlaylistPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "/app/playlist/playlist.txt"},
|
||||
{"custom", "/custom/playlist.txt", "/custom/playlist.txt"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("PLAYLIST_PATH", tt.env)
|
||||
defer os.Unsetenv("PLAYLIST_PATH")
|
||||
} else {
|
||||
os.Unsetenv("PLAYLIST_PATH")
|
||||
}
|
||||
if got := PlaylistPath(); got != tt.expected {
|
||||
t.Errorf("PlaylistPath() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "/app/media"},
|
||||
{"custom", "/data/media", "/data/media"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("MEDIA_DIR", tt.env)
|
||||
defer os.Unsetenv("MEDIA_DIR")
|
||||
} else {
|
||||
os.Unsetenv("MEDIA_DIR")
|
||||
}
|
||||
if got := MediaDir(); got != tt.expected {
|
||||
t.Errorf("MediaDir() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUsername(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "admin"},
|
||||
{"custom", "superadmin", "superadmin"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("ADMIN_USERNAME", tt.env)
|
||||
defer os.Unsetenv("ADMIN_USERNAME")
|
||||
} else {
|
||||
os.Unsetenv("ADMIN_USERNAME")
|
||||
}
|
||||
if got := AdminUsername(); got != tt.expected {
|
||||
t.Errorf("AdminUsername() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "admin"},
|
||||
{"custom", "secret123", "secret123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("ADMIN_PASSWORD", tt.env)
|
||||
defer os.Unsetenv("ADMIN_PASSWORD")
|
||||
} else {
|
||||
os.Unsetenv("ADMIN_PASSWORD")
|
||||
}
|
||||
if got := AdminPassword(); got != tt.expected {
|
||||
t.Errorf("AdminPassword() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSessionTTL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected time.Duration
|
||||
}{
|
||||
{"default", "", 12 * time.Hour},
|
||||
{"custom", "24", 24 * time.Hour},
|
||||
{"zero", "0", 12 * time.Hour},
|
||||
{"negative", "-5", 12 * time.Hour},
|
||||
{"invalid", "abc", 12 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("ADMIN_SESSION_TTL_HOURS", tt.env)
|
||||
defer os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
|
||||
} else {
|
||||
os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
|
||||
}
|
||||
if got := AdminSessionTTL(); got != tt.expected {
|
||||
t.Errorf("AdminSessionTTL() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMax(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected int
|
||||
}{
|
||||
{"default", "", 10},
|
||||
{"custom", "20", 20},
|
||||
{"zero", "0", 10},
|
||||
{"negative", "-5", 10},
|
||||
{"invalid", "abc", 10},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("CHAT_RATE_LIMIT_MAX", tt.env)
|
||||
defer os.Unsetenv("CHAT_RATE_LIMIT_MAX")
|
||||
} else {
|
||||
os.Unsetenv("CHAT_RATE_LIMIT_MAX")
|
||||
}
|
||||
if got := RateLimitMax(); got != tt.expected {
|
||||
t.Errorf("RateLimitMax() = %d, want %d", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitWindow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected time.Duration
|
||||
}{
|
||||
{"default", "", 10 * time.Second},
|
||||
{"custom", "30", 30 * time.Second},
|
||||
{"zero", "0", 10 * time.Second},
|
||||
{"negative", "-5", 10 * time.Second},
|
||||
{"invalid", "abc", 10 * time.Second},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("CHAT_RATE_LIMIT_WINDOW_S", tt.env)
|
||||
defer os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
|
||||
} else {
|
||||
os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
|
||||
}
|
||||
if got := RateLimitWindow(); got != tt.expected {
|
||||
t.Errorf("RateLimitWindow() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "3000"},
|
||||
{"custom", "8080", "8080"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("CHAT_PORT", tt.env)
|
||||
defer os.Unsetenv("CHAT_PORT")
|
||||
} else {
|
||||
os.Unsetenv("CHAT_PORT")
|
||||
}
|
||||
if got := ChatPort(); got != tt.expected {
|
||||
t.Errorf("ChatPort() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", "redis:6379"},
|
||||
{"custom", "redis.example.com:6380", "redis.example.com:6380"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("REDIS_URL", tt.env)
|
||||
defer os.Unsetenv("REDIS_URL")
|
||||
} else {
|
||||
os.Unsetenv("REDIS_URL")
|
||||
}
|
||||
if got := RedisAddress(); got != tt.expected {
|
||||
t.Errorf("RedisAddress() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
expected string
|
||||
}{
|
||||
{"default", "", ""},
|
||||
{"sqlite", "sqlite://test.db", "sqlite://test.db"},
|
||||
{"postgres", "postgres://user:pass@localhost/db", "postgres://user:pass@localhost/db"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != "" {
|
||||
os.Setenv("DATABASE_URL", tt.env)
|
||||
defer os.Unsetenv("DATABASE_URL")
|
||||
} else {
|
||||
os.Unsetenv("DATABASE_URL")
|
||||
}
|
||||
if got := DatabaseURL(); got != tt.expected {
|
||||
t.Errorf("DatabaseURL() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DBClient wraps gorm.DB for testability.
|
||||
type DBClient struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
// InitDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006).
|
||||
// Returns the client and the driver name.
|
||||
func InitDB(databaseURL string) (*DBClient, string, error) {
|
||||
var (
|
||||
gormDB *gorm.DB
|
||||
err error
|
||||
driver = "sqlite"
|
||||
)
|
||||
|
||||
switch {
|
||||
case databaseURL == "":
|
||||
gormDB, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
|
||||
case strings.HasPrefix(databaseURL, "sqlite://"):
|
||||
sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://")
|
||||
if sqlitePath == "" {
|
||||
sqlitePath = "chat.db"
|
||||
}
|
||||
gormDB, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
||||
case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"):
|
||||
driver = "postgres"
|
||||
gormDB, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
default:
|
||||
log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, driver, err
|
||||
}
|
||||
|
||||
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
||||
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
||||
|
||||
return &DBClient{DB: gormDB}, driver, nil
|
||||
}
|
||||
|
||||
// RedisClient wraps the Redis client for testability.
|
||||
// Interface defined for mocking in tests.
|
||||
type RedisClient interface {
|
||||
Ping(ctx context.Context) error
|
||||
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
Del(ctx context.Context, keys ...string) error
|
||||
Incr(ctx context.Context, key string) (int64, error)
|
||||
Decr(ctx context.Context, key string) (int64, error)
|
||||
Expire(ctx context.Context, key string, expiration time.Duration) error
|
||||
Exists(ctx context.Context, keys ...string) (int64, error)
|
||||
Publish(ctx context.Context, channel string, message interface{}) error
|
||||
PSubscribe(ctx context.Context, channels ...string) PubSub
|
||||
Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error)
|
||||
Info(ctx context.Context, sections ...string) (string, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// PubSub wraps Redis pub/sub subscription.
|
||||
type PubSub interface {
|
||||
ReceiveMessage(ctx context.Context) (*PubSubMessage, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// PubSubMessage represents a received pub/sub message.
|
||||
type PubSubMessage struct {
|
||||
Channel string
|
||||
Payload string
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInitDB_SQLite(t *testing.T) {
|
||||
client, driver, err := InitDB("sqlite://:memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB() error = %v", err)
|
||||
}
|
||||
if driver != "sqlite" {
|
||||
t.Errorf("InitDB() driver = %q, want sqlite", driver)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("InitDB() client = nil")
|
||||
}
|
||||
|
||||
// Verify tables exist by running AutoMigrate (idempotent)
|
||||
err = client.DB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
||||
if err != nil {
|
||||
t.Errorf("AutoMigrate error = %v", err)
|
||||
}
|
||||
|
||||
// Verify CRUD on Message
|
||||
msg := Message{
|
||||
Room: "test",
|
||||
Username: "user1",
|
||||
Text: "hello",
|
||||
Timestamp: time.Now(),
|
||||
Type: "public",
|
||||
}
|
||||
if err := client.DB.Create(&msg).Error; err != nil {
|
||||
t.Fatalf("Create message error = %v", err)
|
||||
}
|
||||
if msg.ID == 0 {
|
||||
t.Error("Create message did not set ID")
|
||||
}
|
||||
|
||||
var count int64
|
||||
client.DB.Model(&Message{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("Message count = %d, want 1", count)
|
||||
}
|
||||
|
||||
// Verify read
|
||||
var read Message
|
||||
if err := client.DB.First(&read, msg.ID).Error; err != nil {
|
||||
t.Fatalf("First message error = %v", err)
|
||||
}
|
||||
if read.Text != "hello" {
|
||||
t.Errorf("read.Text = %q, want hello", read.Text)
|
||||
}
|
||||
|
||||
// Verify delete
|
||||
client.DB.Delete(&Message{}, msg.ID)
|
||||
client.DB.Model(&Message{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("Message count after delete = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDB_ModerationLog(t *testing.T) {
|
||||
client, _, err := InitDB("sqlite://:memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB() error = %v", err)
|
||||
}
|
||||
|
||||
log := ModerationLog{
|
||||
Action: "mute",
|
||||
Target: "user1",
|
||||
Moderator: "admin",
|
||||
Reason: "spam",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err := client.DB.Create(&log).Error; err != nil {
|
||||
t.Fatalf("Create moderation log error = %v", err)
|
||||
}
|
||||
if log.ID == 0 {
|
||||
t.Error("Create moderation log did not set ID")
|
||||
}
|
||||
|
||||
var count int64
|
||||
client.DB.Model(&ModerationLog{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("ModerationLog count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDB_Media(t *testing.T) {
|
||||
client, _, err := InitDB("sqlite://:memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB() error = %v", err)
|
||||
}
|
||||
|
||||
media := Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: "/media/test.mp4",
|
||||
SizeBytes: 1024,
|
||||
DurationSeconds: 60,
|
||||
Status: "ready",
|
||||
Title: "Test Video",
|
||||
Tags: "tag1,tag2",
|
||||
}
|
||||
if err := client.DB.Create(&media).Error; err != nil {
|
||||
t.Fatalf("Create media error = %v", err)
|
||||
}
|
||||
if media.ID == 0 {
|
||||
t.Error("Create media did not set ID")
|
||||
}
|
||||
|
||||
// Verify update
|
||||
client.DB.Model(&media).Update("status", "needs_normalization")
|
||||
var updated Media
|
||||
client.DB.First(&updated, media.ID)
|
||||
if updated.Status != "needs_normalization" {
|
||||
t.Errorf("updated.Status = %q, want needs_normalization", updated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDB_ScheduleOverride(t *testing.T) {
|
||||
client, _, err := InitDB("sqlite://:memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB() error = %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
ov := ScheduleOverride{
|
||||
MediaID: 1,
|
||||
StartTime: now,
|
||||
EndTime: now.Add(1 * time.Hour),
|
||||
Reason: "special event",
|
||||
}
|
||||
if err := client.DB.Create(&ov).Error; err != nil {
|
||||
t.Fatalf("Create schedule override error = %v", err)
|
||||
}
|
||||
if ov.ID == 0 {
|
||||
t.Error("Create schedule override did not set ID")
|
||||
}
|
||||
|
||||
// Conflict detection
|
||||
var conflict int64
|
||||
client.DB.Model(&ScheduleOverride{}).
|
||||
Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime).
|
||||
Count(&conflict)
|
||||
if conflict != 1 {
|
||||
t.Errorf("conflict count = %d, want 1", conflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDB_EmptyURL(t *testing.T) {
|
||||
// Empty URL should default to SQLite chat.db
|
||||
// This creates a file, clean up after
|
||||
client, driver, err := InitDB("")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB(\"\") error = %v", err)
|
||||
}
|
||||
if driver != "sqlite" {
|
||||
t.Errorf("InitDB(\"\") driver = %q, want sqlite", driver)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("InitDB(\"\") client = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitDB_SQLitePath(t *testing.T) {
|
||||
client, driver, err := InitDB("sqlite://:memory:?cache=shared")
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB() error = %v", err)
|
||||
}
|
||||
if driver != "sqlite" {
|
||||
t.Errorf("InitDB() driver = %q, want sqlite", driver)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("InitDB() client = nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message represents a chat message (persisted in SQL DB per ADR-006)
|
||||
type Message struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Room string `json:"room"`
|
||||
Username string `json:"username"`
|
||||
Text string `json:"text"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Type string `json:"type"` // "public", "private", "system", "moderation"
|
||||
}
|
||||
|
||||
// ModerationLog for audit trail (immutable per ADR-005)
|
||||
type ModerationLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Action string `json:"action"` // "mute", "ban", "delete", "timeout"
|
||||
Target string `json:"target"`
|
||||
Moderator string `json:"moderator"`
|
||||
Reason string `json:"reason"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Media stores admin media library metadata (filesystem + DB model per ADR-006)
|
||||
type Media struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Filename string `gorm:"index" json:"filename"`
|
||||
OriginalName string `json:"originalName"`
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Status string `gorm:"index" json:"status"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
||||
type ScheduleOverride struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
MediaID uint `json:"mediaId"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Presence tracks online users per room (using Redis per ADR-007)
|
||||
type Presence struct {
|
||||
Username string `json:"username"`
|
||||
Room string `json:"room"`
|
||||
Online bool `json:"online"`
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisWrapper wraps go-redis client to implement RedisClient interface.
|
||||
type RedisWrapper struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewRedisWrapper creates a RedisWrapper from a go-redis client.
|
||||
func NewRedisWrapper(client *redis.Client) *RedisWrapper {
|
||||
return &RedisWrapper{client: client}
|
||||
}
|
||||
|
||||
// InitRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007).
|
||||
// Returns nil if Redis is unavailable (non-fatal).
|
||||
func InitRedis(redisAddress string) *RedisWrapper {
|
||||
var options *redis.Options
|
||||
var err error
|
||||
|
||||
if strings.Contains(redisAddress, "://") {
|
||||
options, err = redis.ParseURL(redisAddress)
|
||||
if err != nil {
|
||||
log.Printf("Redis configuration warning: %v (rate limiting and presence will be disabled)", err)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
options = &redis.Options{Addr: redisAddress}
|
||||
}
|
||||
|
||||
client := redis.NewClient(options)
|
||||
if err := client.Ping(context.Background()).Err(); err != nil {
|
||||
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
|
||||
client.Close()
|
||||
// Start auto-reconnect goroutine
|
||||
go redisReconnectLoop(redisAddress, options)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
|
||||
return &RedisWrapper{client: client}
|
||||
}
|
||||
|
||||
// redisReconnectLoop attempts to reconnect to Redis with exponential backoff.
|
||||
func redisReconnectLoop(addr string, baseOptions *redis.Options) {
|
||||
backoff := 2 * time.Second
|
||||
maxBackoff := 60 * time.Second
|
||||
for {
|
||||
time.Sleep(backoff)
|
||||
testClient := redis.NewClient(baseOptions)
|
||||
if err := testClient.Ping(context.Background()).Err(); err != nil {
|
||||
log.Printf("Redis reconnect failed: %v (retry in %v)", err, backoff)
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
testClient.Close()
|
||||
continue
|
||||
}
|
||||
// Reconnected — caller must replace their reference.
|
||||
// This is a best-effort background reconnect; the wrapper
|
||||
// returned by InitRedis remains nil until a new InitRedis call.
|
||||
log.Println("Redis reconnected successfully (background)")
|
||||
testClient.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ping checks Redis connectivity.
|
||||
func (r *RedisWrapper) Ping(ctx context.Context) error {
|
||||
if r == nil || r.client == nil {
|
||||
return redis.ErrClosed
|
||||
}
|
||||
return r.client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// Set stores a key with TTL.
|
||||
func (r *RedisWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
return r.client.Set(ctx, key, value, expiration).Err()
|
||||
}
|
||||
|
||||
// Get retrieves a key.
|
||||
func (r *RedisWrapper) Get(ctx context.Context, key string) (string, error) {
|
||||
return r.client.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Del removes keys.
|
||||
func (r *RedisWrapper) Del(ctx context.Context, keys ...string) error {
|
||||
return r.client.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
// Incr increments a key.
|
||||
func (r *RedisWrapper) Incr(ctx context.Context, key string) (int64, error) {
|
||||
return r.client.Incr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Decr decrements a key.
|
||||
func (r *RedisWrapper) Decr(ctx context.Context, key string) (int64, error) {
|
||||
return r.client.Decr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Expire sets TTL on a key.
|
||||
func (r *RedisWrapper) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return r.client.Expire(ctx, key, expiration).Err()
|
||||
}
|
||||
|
||||
// Exists checks if keys exist.
|
||||
func (r *RedisWrapper) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||
return r.client.Exists(ctx, keys...).Result()
|
||||
}
|
||||
|
||||
// Publish publishes a message to a channel.
|
||||
func (r *RedisWrapper) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||
return r.client.Publish(ctx, channel, message).Err()
|
||||
}
|
||||
|
||||
// PSubscribe subscribes to channels by pattern.
|
||||
func (r *RedisWrapper) PSubscribe(ctx context.Context, channels ...string) PubSub {
|
||||
ps := r.client.PSubscribe(ctx, channels...)
|
||||
return &redisPubSubWrapper{ps: ps}
|
||||
}
|
||||
|
||||
// Scan scans keys matching a pattern.
|
||||
func (r *RedisWrapper) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||
return r.client.Scan(ctx, cursor, match, count).Result()
|
||||
}
|
||||
|
||||
// Info returns Redis server info.
|
||||
func (r *RedisWrapper) Info(ctx context.Context, sections ...string) (string, error) {
|
||||
return r.client.Info(ctx, sections...).Result()
|
||||
}
|
||||
|
||||
// Close closes the Redis connection.
|
||||
func (r *RedisWrapper) Close() error {
|
||||
if r == nil || r.client == nil {
|
||||
return nil
|
||||
}
|
||||
return r.client.Close()
|
||||
}
|
||||
|
||||
// redisPubSubWrapper wraps go-redis PubSub.
|
||||
type redisPubSubWrapper struct {
|
||||
ps *redis.PubSub
|
||||
}
|
||||
|
||||
func (w *redisPubSubWrapper) ReceiveMessage(ctx context.Context) (*PubSubMessage, error) {
|
||||
msg, err := w.ps.ReceiveMessage(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PubSubMessage{Channel: msg.Channel, Payload: msg.Payload}, nil
|
||||
}
|
||||
|
||||
func (w *redisPubSubWrapper) Close() error {
|
||||
return w.ps.Close()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,947 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/auth"
|
||||
"chat/src/config"
|
||||
"chat/src/db"
|
||||
"chat/src/ws"
|
||||
)
|
||||
|
||||
// mockRedis implements db.RedisClient for handler tests.
|
||||
type mockRedis struct {
|
||||
mu sync.Mutex
|
||||
store map[string]string
|
||||
}
|
||||
|
||||
func newMockRedis() *mockRedis {
|
||||
return &mockRedis{store: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (m *mockRedis) Ping(ctx context.Context) error { return nil }
|
||||
func (m *mockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.store[key] = value.(string)
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Get(ctx context.Context, key string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.store[key], nil
|
||||
}
|
||||
func (m *mockRedis) Del(ctx context.Context, keys ...string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, k := range keys {
|
||||
delete(m.store, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.store[key] = "1"
|
||||
return 1, nil
|
||||
}
|
||||
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, k := range keys {
|
||||
if _, ok := m.store[k]; ok {
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range m.store {
|
||||
if strings.Contains(k, strings.TrimSuffix(match, "*")) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
return keys, 0, nil
|
||||
}
|
||||
func (m *mockRedis) Info(ctx context.Context, sections ...string) (string, error) {
|
||||
return "connected_clients:1\nused_memory_human:1M\n", nil
|
||||
}
|
||||
func (m *mockRedis) Close() error { return nil }
|
||||
|
||||
func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
|
||||
t.Helper()
|
||||
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test DB: %v", err)
|
||||
}
|
||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{})
|
||||
mr := newMockRedis()
|
||||
sessionStore := auth.NewSessionStore()
|
||||
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
|
||||
|
||||
deps := &Dependencies{
|
||||
DB: gormDB,
|
||||
Redis: mr,
|
||||
Hub: hub,
|
||||
SessionStore: sessionStore,
|
||||
DBDriver: "sqlite",
|
||||
}
|
||||
return deps, gormDB, sessionStore
|
||||
}
|
||||
|
||||
// adminCookie creates a valid admin session and returns the cookie header.
|
||||
func adminCookie(t *testing.T, store *auth.SessionStore) string {
|
||||
t.Helper()
|
||||
token, _ := auth.GenerateToken()
|
||||
store.Add(token, time.Now().Add(1*time.Hour))
|
||||
return config.AdminSessionCookieName + "=" + token
|
||||
}
|
||||
|
||||
func TestHandleAdminLogin_Success(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
handler := HandleAdminLogin(deps)
|
||||
|
||||
// Ensure env vars are set for credential check
|
||||
os.Setenv("ADMIN_USERNAME", "admin")
|
||||
os.Setenv("ADMIN_PASSWORD", "admin")
|
||||
defer os.Unsetenv("ADMIN_USERNAME")
|
||||
defer os.Unsetenv("ADMIN_PASSWORD")
|
||||
|
||||
body := `{"username":"admin","password":"admin"}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp AdminSessionResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if !resp.Authenticated {
|
||||
t.Error("Authenticated = false, want true")
|
||||
}
|
||||
|
||||
// Check cookie set
|
||||
cookies := w.Result().Cookies()
|
||||
found := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == config.AdminSessionCookieName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("session cookie not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminLogin_InvalidCreds(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
handler := HandleAdminLogin(deps)
|
||||
|
||||
body := `{"username":"admin","password":"wrong"}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminLogin_InvalidJSON(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
handler := HandleAdminLogin(deps)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/admin/login", strings.NewReader("not json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminLogin_WrongMethod(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
handler := HandleAdminLogin(deps)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/admin/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminLogout(t *testing.T) {
|
||||
deps, _, store := setupTestDeps(t)
|
||||
handler := HandleAdminLogout(deps)
|
||||
|
||||
cookie := adminCookie(t, store)
|
||||
req := httptest.NewRequest("POST", "/api/admin/logout", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// Cookie should be cleared
|
||||
cookies := w.Result().Cookies()
|
||||
found := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == config.AdminSessionCookieName && c.MaxAge == -1 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("session cookie not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSession_Authenticated(t *testing.T) {
|
||||
deps, _, store := setupTestDeps(t)
|
||||
handler := HandleAdminSession(deps)
|
||||
|
||||
cookie := adminCookie(t, store)
|
||||
req := httptest.NewRequest("GET", "/api/admin/session", nil)
|
||||
req.Header.Set("Cookie", cookie)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp AdminSessionResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if !resp.Authenticated {
|
||||
t.Error("Authenticated = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSession_Unauthenticated(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
handler := HandleAdminSession(deps)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/admin/session", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminOverview(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
// Add some test data
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
|
||||
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
|
||||
|
||||
handler := HandleAdminOverview(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/overview", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var overview AdminOverview
|
||||
json.NewDecoder(w.Body).Decode(&overview)
|
||||
if overview.StoredMessages != 1 {
|
||||
t.Errorf("StoredMessages = %d, want 1", overview.StoredMessages)
|
||||
}
|
||||
if overview.ModerationActions != 1 {
|
||||
t.Errorf("ModerationActions = %d, want 1", overview.ModerationActions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminPlaylist_Get(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
// Create temp playlist
|
||||
tmpDir := t.TempDir()
|
||||
playlistPath := filepath.Join(tmpDir, "playlist.txt")
|
||||
os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
|
||||
os.Setenv("PLAYLIST_PATH", playlistPath)
|
||||
defer os.Unsetenv("PLAYLIST_PATH")
|
||||
|
||||
handler := HandleAdminPlaylist(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/playlist", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var payload PlaylistPayload
|
||||
json.NewDecoder(w.Body).Decode(&payload)
|
||||
if !strings.Contains(payload.Content, "test.mp4") {
|
||||
t.Errorf("Content = %q, want test.mp4", payload.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminPlaylist_Put(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
playlistPath := filepath.Join(tmpDir, "playlist.txt")
|
||||
os.Setenv("PLAYLIST_PATH", playlistPath)
|
||||
defer os.Unsetenv("PLAYLIST_PATH")
|
||||
|
||||
handler := HandleAdminPlaylist(deps)
|
||||
body := `{"content":"file /videos/new.mp4"}`
|
||||
req := httptest.NewRequest("PUT", "/api/admin/playlist", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// Verify file written
|
||||
data, _ := os.ReadFile(playlistPath)
|
||||
if !strings.Contains(string(data), "new.mp4") {
|
||||
t.Errorf("file content = %q, want new.mp4", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminModerationLogs(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u1", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
|
||||
|
||||
handler := HandleAdminModerationLogs(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/moderation/logs", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp ModerationLogsResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp.Logs) != 1 {
|
||||
t.Errorf("len(Logs) = %d, want 1", len(resp.Logs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminModerationAction(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminModerationAction(deps)
|
||||
body := `{"action":"mute","target":"baduser","moderator":"admin","reason":"spam","room":"main"}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
gormDB.Model(&db.ModerationLog{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("moderation log count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminModerationAction_MissingFields(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminModerationAction(deps)
|
||||
body := `{"action":"mute"}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/moderation/actions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminAnalytics(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"})
|
||||
|
||||
handler := HandleAdminAnalytics(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/analytics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var analytics AdminAnalytics
|
||||
json.NewDecoder(w.Body).Decode(&analytics)
|
||||
if analytics.TotalMessages != 2 {
|
||||
t.Errorf("TotalMessages = %d, want 2", analytics.TotalMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSystemSummary(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminSystemSummary(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/system", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var summary AdminSystemSummary
|
||||
json.NewDecoder(w.Body).Decode(&summary)
|
||||
if summary.DatabaseDriver != "sqlite" {
|
||||
t.Errorf("DatabaseDriver = %q, want sqlite", summary.DatabaseDriver)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminConfig_Get(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminConfig(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminConfig_Put(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminConfig(deps)
|
||||
body := `{"rateLimitMax":20}`
|
||||
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminRestart(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminRestart(deps)
|
||||
req := httptest.NewRequest("POST", "/api/admin/restart?service=chat", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminUsers_Get(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminUsers(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/users", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminUsers_Post(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminUsers(deps)
|
||||
body := `{"action":"mute","username":"baduser","duration":10}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/users", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSchedule(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
playlistPath := filepath.Join(tmpDir, "playlist.txt")
|
||||
os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
|
||||
os.Setenv("PLAYLIST_PATH", playlistPath)
|
||||
defer os.Unsetenv("PLAYLIST_PATH")
|
||||
|
||||
gormDB.Create(&db.Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: "/videos/test.mp4",
|
||||
SizeBytes: 1024,
|
||||
DurationSeconds: 60,
|
||||
Status: "ready",
|
||||
Title: "Test Video",
|
||||
})
|
||||
|
||||
handler := HandleAdminSchedule(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/schedule", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp AdminScheduleResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.MatchedEntries != 1 {
|
||||
t.Errorf("MatchedEntries = %d, want 1", resp.MatchedEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminScheduleOverrides_CRUD(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
// Create
|
||||
handler := HandleAdminScheduleOverrides(deps)
|
||||
now := time.Now()
|
||||
body, _ := json.Marshal(db.ScheduleOverride{
|
||||
MediaID: 1,
|
||||
StartTime: now,
|
||||
EndTime: now.Add(1 * time.Hour),
|
||||
Reason: "test",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/admin/schedule/overrides", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("POST status = %d, want 201", w.Code)
|
||||
}
|
||||
|
||||
// List
|
||||
req2 := httptest.NewRequest("GET", "/api/admin/schedule/overrides", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Errorf("GET status = %d, want 200", w2.Code)
|
||||
}
|
||||
|
||||
// Delete
|
||||
var overrides []db.ScheduleOverride
|
||||
gormDB.Find(&overrides)
|
||||
if len(overrides) > 0 {
|
||||
req3 := httptest.NewRequest("DELETE", "/api/admin/schedule/overrides?id=1", nil)
|
||||
w3 := httptest.NewRecorder()
|
||||
handler(w3, req3)
|
||||
if w3.Code != http.StatusNoContent {
|
||||
t.Errorf("DELETE status = %d, want 204", w3.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLog(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
|
||||
|
||||
handler := HandleAdminChatLog(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/chat-log", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp ChatLogListResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Total != 1 {
|
||||
t.Errorf("Total = %d, want 1", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLog_FilterByRoom(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
|
||||
gormDB.Create(&db.Message{Room: "lobby", Username: "u2", Text: "hey", Timestamp: time.Now(), Type: "public"})
|
||||
|
||||
handler := HandleAdminChatLog(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/chat-log?room=main", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp ChatLogListResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Total != 1 {
|
||||
t.Errorf("Total = %d, want 1", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLogByID_Get(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}
|
||||
gormDB.Create(&msg)
|
||||
|
||||
handler := HandleAdminChatLogByID(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/chat-log/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLogByID_Delete(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
msg := db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"}
|
||||
gormDB.Create(&msg)
|
||||
|
||||
handler := HandleAdminChatLogByID(deps)
|
||||
req := httptest.NewRequest("DELETE", "/api/admin/chat-log/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want 204", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
gormDB.Model(&db.Message{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("message count after delete = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLogBulk(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "m1", Timestamp: time.Now(), Type: "public"})
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u2", Text: "m2", Timestamp: time.Now(), Type: "public"})
|
||||
|
||||
handler := HandleAdminChatLogBulk(deps)
|
||||
body := `{"ids":[1,2]}`
|
||||
req := httptest.NewRequest("DELETE", "/api/admin/chat-log/bulk", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
gormDB.Model(&db.Message{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("message count after bulk delete = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminChatLogExport(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hello", Timestamp: time.Now(), Type: "public"})
|
||||
|
||||
handler := HandleAdminChatLogExport(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/chat-log/export", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/csv") {
|
||||
t.Errorf("Content-Type = %q, want text/csv", contentType)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "hello") {
|
||||
t.Errorf("CSV body missing 'hello': %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePublicSchedule(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
playlistPath := filepath.Join(tmpDir, "playlist.txt")
|
||||
os.WriteFile(playlistPath, []byte("file /videos/test.mp4\n"), 0644)
|
||||
os.Setenv("PLAYLIST_PATH", playlistPath)
|
||||
defer os.Unsetenv("PLAYLIST_PATH")
|
||||
|
||||
handler := HandlePublicSchedule(deps)
|
||||
req := httptest.NewRequest("GET", "/api/schedule", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMedia_Get(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: "/media/test.mp4",
|
||||
SizeBytes: 1024,
|
||||
DurationSeconds: 60,
|
||||
Status: "ready",
|
||||
Title: "Test",
|
||||
})
|
||||
|
||||
handler := HandleAdminMedia(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/media", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var resp AdminMediaResponse
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp.Files) != 1 {
|
||||
t.Errorf("len(Files) = %d, want 1", len(resp.Files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaByID_Get(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: "/media/test.mp4",
|
||||
SizeBytes: 1024,
|
||||
DurationSeconds: 60,
|
||||
Status: "ready",
|
||||
Title: "Test",
|
||||
})
|
||||
|
||||
handler := HandleAdminMediaByID(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/media/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaByID_NotFound(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminMediaByID(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/media/999", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaByID_Put(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
gormDB.Create(&db.Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: "/media/test.mp4",
|
||||
SizeBytes: 1024,
|
||||
DurationSeconds: 60,
|
||||
Status: "ready",
|
||||
Title: "Old Title",
|
||||
})
|
||||
|
||||
handler := HandleAdminMediaByID(deps)
|
||||
body := `{"title":"New Title","status":"needs_normalization","tags":"tag1"}`
|
||||
req := httptest.NewRequest("PUT", "/api/admin/media/1", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var updated db.Media
|
||||
gormDB.First(&updated, 1)
|
||||
if updated.Title != "New Title" {
|
||||
t.Errorf("Title = %q, want New Title", updated.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaByID_Delete(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
// Create a real temp file for the media record
|
||||
tmpDir := t.TempDir()
|
||||
os.Setenv("MEDIA_DIR", tmpDir)
|
||||
defer os.Unsetenv("MEDIA_DIR")
|
||||
|
||||
mediaPath := filepath.Join(tmpDir, "test.mp4")
|
||||
os.WriteFile(mediaPath, []byte("fake video"), 0644)
|
||||
|
||||
gormDB.Create(&db.Media{
|
||||
Filename: "test.mp4",
|
||||
OriginalName: "test.mp4",
|
||||
Path: mediaPath,
|
||||
SizeBytes: 10,
|
||||
DurationSeconds: 1,
|
||||
Status: "ready",
|
||||
Title: "Test",
|
||||
})
|
||||
|
||||
handler := HandleAdminMediaByID(deps)
|
||||
req := httptest.NewRequest("DELETE", "/api/admin/media/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
gormDB.Model(&db.Media{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("media count after delete = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaUploads_Init(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminMediaUploads(deps)
|
||||
body := `{"fileName":"video.mp4","fileSize":1024,"mimeType":"video/mp4","chunkSize":524288,"totalChunks":1}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var status AdminMediaUploadStatusResponse
|
||||
json.NewDecoder(w.Body).Decode(&status)
|
||||
if status.FileName != "video.mp4" {
|
||||
t.Errorf("FileName = %q, want video.mp4", status.FileName)
|
||||
}
|
||||
if status.UploadID == "" {
|
||||
t.Error("UploadID is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminMediaUploads_InvalidFile(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleAdminMediaUploads(deps)
|
||||
body := `{"fileName":"bad.exe","fileSize":1024,"mimeType":"application/x-msdownload","chunkSize":524288,"totalChunks":1}`
|
||||
req := httptest.NewRequest("POST", "/api/admin/media/uploads", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
+70
-2727
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,385 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FormatDuration converts seconds to HH:MM:SS string.
|
||||
func FormatDuration(seconds int) string {
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
hours := seconds / 3600
|
||||
minutes := (seconds % 3600) / 60
|
||||
remainingSeconds := seconds % 60
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds)
|
||||
}
|
||||
|
||||
// NormalizationResult holds ffprobe validation output for admin uploads.
|
||||
type NormalizationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// ValidateNormalization runs ffprobe against a media file and checks
|
||||
// conformance with the Linear Void normalization profile (ADR-001).
|
||||
func ValidateNormalization(mediaPath string) NormalizationResult {
|
||||
result := NormalizationResult{Valid: true, Errors: []string{}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx,
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", mediaPath,
|
||||
).CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return NormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}}
|
||||
}
|
||||
if err != nil {
|
||||
return NormalizationResult{Valid: false, Errors: []string{fmt.Sprintf("ffprobe failed: %v (%s)", err, strings.TrimSpace(string(out)))}}
|
||||
}
|
||||
|
||||
var probe struct {
|
||||
Streams []struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &probe); err != nil {
|
||||
return NormalizationResult{Valid: false, Errors: []string{"ffprobe JSON parse failed: " + err.Error()}}
|
||||
}
|
||||
|
||||
var hasVideo, hasAudio bool
|
||||
for _, s := range probe.Streams {
|
||||
switch s.CodecType {
|
||||
case "video":
|
||||
hasVideo = true
|
||||
if s.CodecName != "h264" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
|
||||
}
|
||||
if s.RFrameRate != "30/1" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("framerate must be 30 fps constant, got %s", s.RFrameRate))
|
||||
}
|
||||
if s.Width != 1280 && s.Width != 1920 {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
|
||||
}
|
||||
if s.Height != 720 && s.Height != 1080 {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
|
||||
}
|
||||
if s.PixFmt != "yuv420p" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
|
||||
}
|
||||
case "audio":
|
||||
hasAudio = true
|
||||
if s.CodecName != "aac" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
|
||||
}
|
||||
if s.SampleRate != "48000" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("sample rate must be 48000 Hz, got %s", s.SampleRate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasVideo {
|
||||
result.Errors = append(result.Errors, "no video stream found")
|
||||
}
|
||||
if !hasAudio {
|
||||
result.Errors = append(result.Errors, "no audio stream found")
|
||||
}
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
result.Valid = false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ProbeDurationSeconds returns the duration of a media file in seconds.
|
||||
func ProbeDurationSeconds(mediaPath string) (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(
|
||||
ctx,
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
mediaPath,
|
||||
).CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return 0, fmt.Errorf("ffprobe timed out")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
rawDuration := strings.TrimSpace(string(out))
|
||||
if rawDuration == "" {
|
||||
return 0, fmt.Errorf("ffprobe returned empty duration")
|
||||
}
|
||||
|
||||
secondsFloat, err := strconv.ParseFloat(rawDuration, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err)
|
||||
}
|
||||
if secondsFloat < 0 {
|
||||
secondsFloat = 0
|
||||
}
|
||||
|
||||
seconds := int(secondsFloat + 0.5)
|
||||
if secondsFloat > 0 && seconds == 0 {
|
||||
seconds = 1
|
||||
}
|
||||
|
||||
return seconds, nil
|
||||
}
|
||||
|
||||
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
|
||||
func DetectMime(filePath string) (string, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
head := make([]byte, 512)
|
||||
n, err := f.Read(head)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0], nil
|
||||
}
|
||||
|
||||
// IsAllowedExtension checks if the file extension is a supported video format.
|
||||
func IsAllowedExtension(fileName string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
switch ext {
|
||||
case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsAllowedMime checks if the MIME type is a supported video type.
|
||||
func IsAllowedMime(mime string) bool {
|
||||
mime = strings.TrimSpace(strings.ToLower(mime))
|
||||
if mime == "" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(mime, "video/") {
|
||||
return true
|
||||
}
|
||||
return mime == "application/octet-stream"
|
||||
}
|
||||
|
||||
// UniqueTargetPath returns a unique file path in mediaDir, appending -N if needed.
|
||||
func UniqueTargetPath(mediaDir, fileName string) string {
|
||||
baseName := filepath.Base(fileName)
|
||||
ext := filepath.Ext(baseName)
|
||||
nameOnly := strings.TrimSuffix(baseName, ext)
|
||||
candidate := filepath.Join(mediaDir, baseName)
|
||||
|
||||
for i := 1; ; i++ {
|
||||
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
||||
return candidate
|
||||
}
|
||||
candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext))
|
||||
}
|
||||
}
|
||||
|
||||
// FileEntry represents a media file for API responses.
|
||||
type FileEntry struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"originalName"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
UploadedAt string `json:"uploadedAt"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
// MediaRecord is the interface for media DB records.
|
||||
type MediaRecord interface {
|
||||
GetID() uint
|
||||
GetFilename() string
|
||||
GetOriginalName() string
|
||||
GetPath() string
|
||||
GetSizeBytes() int64
|
||||
GetDurationSeconds() int
|
||||
GetStatus() string
|
||||
GetTitle() string
|
||||
GetTags() string
|
||||
GetUploadedAt() time.Time
|
||||
GetUpdatedAt() time.Time
|
||||
}
|
||||
|
||||
// ListFiles returns all media files sorted by upload date descending.
|
||||
func ListFiles(db *gorm.DB) ([]FileEntry, error) {
|
||||
type record struct {
|
||||
ID uint
|
||||
Filename string
|
||||
OriginalName string
|
||||
Path string
|
||||
SizeBytes int64
|
||||
DurationSeconds int
|
||||
Status string
|
||||
Title string
|
||||
Tags string
|
||||
UploadedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
var records []record
|
||||
if err := db.Table("media").Order("uploaded_at desc").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files := make([]FileEntry, 0, len(records))
|
||||
for _, r := range records {
|
||||
files = append(files, FileEntry{
|
||||
ID: r.ID,
|
||||
Name: r.Filename,
|
||||
OriginalName: r.OriginalName,
|
||||
Path: r.Path,
|
||||
Size: r.SizeBytes,
|
||||
DurationSeconds: r.DurationSeconds,
|
||||
Runtime: FormatDuration(r.DurationSeconds),
|
||||
Status: r.Status,
|
||||
Title: r.Title,
|
||||
Tags: r.Tags,
|
||||
UploadedAt: r.UploadedAt.UTC().Format(time.RFC3339),
|
||||
ModifiedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
if files[i].UploadedAt == files[j].UploadedAt {
|
||||
return files[i].Name < files[j].Name
|
||||
}
|
||||
return files[i].UploadedAt > files[j].UploadedAt
|
||||
})
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// ParsePlaylistEntries parses playlist content into schedule entries.
|
||||
type ScheduleEntry struct {
|
||||
Index int `json:"index"`
|
||||
Path string `json:"path"`
|
||||
Label string `json:"label"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// ParsePlaylist parses playlist text content into entries.
|
||||
func ParsePlaylist(content string) []ScheduleEntry {
|
||||
entries := []ScheduleEntry{}
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, rawLine := range lines {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "file ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
|
||||
line = strings.Trim(line, "'\"")
|
||||
}
|
||||
|
||||
entries = append(entries, ScheduleEntry{
|
||||
Index: len(entries) + 1,
|
||||
Path: line,
|
||||
Label: filepath.Base(line),
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// UploadSessionDir returns the directory for an upload session's chunks.
|
||||
func UploadSessionDir(mediaDir, uploadID string) string {
|
||||
return filepath.Join(mediaDir, ".uploads", uploadID)
|
||||
}
|
||||
|
||||
// UploadSessionMetaPath returns the path to the session metadata file.
|
||||
func UploadSessionMetaPath(mediaDir, uploadID string) string {
|
||||
return filepath.Join(UploadSessionDir(mediaDir, uploadID), "session.json")
|
||||
}
|
||||
|
||||
// UploadChunkPath returns the path for a specific chunk file.
|
||||
func UploadChunkPath(mediaDir, uploadID string, chunkIndex int) string {
|
||||
return filepath.Join(UploadSessionDir(mediaDir, uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex))
|
||||
}
|
||||
|
||||
// CountUploadedChunks counts how many chunks exist for a session.
|
||||
func CountUploadedChunks(mediaDir, uploadID string, totalChunks int) int {
|
||||
uploaded := 0
|
||||
for i := 0; i < totalChunks; i++ {
|
||||
if _, err := os.Stat(UploadChunkPath(mediaDir, uploadID, i)); err == nil {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
}
|
||||
|
||||
// ValidateUploadID checks that an upload ID is a valid hex string of appropriate length.
|
||||
func ValidateUploadID(uploadID string) bool {
|
||||
if len(uploadID) < 16 || len(uploadID) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range uploadID {
|
||||
if (r < 'a' || r > 'f') && (r < '0' || r > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetMediaIDFromPath extracts a media ID from a URL path like /api/admin/media/123.
|
||||
func GetMediaIDFromPath(path, prefix string) (uint, error) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return 0, fmt.Errorf("invalid media path")
|
||||
}
|
||||
idPart := strings.TrimPrefix(path, prefix)
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart == "" || strings.Contains(idPart, "/") {
|
||||
return 0, fmt.Errorf("invalid media id")
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(idPart, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, fmt.Errorf("invalid media id")
|
||||
}
|
||||
|
||||
return uint(id), nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
seconds int
|
||||
expected string
|
||||
}{
|
||||
{"zero", 0, "00:00:00"},
|
||||
{"one second", 1, "00:00:01"},
|
||||
{"one minute", 60, "00:01:00"},
|
||||
{"one hour", 3600, "01:00:00"},
|
||||
{"mixed", 3661, "01:01:01"},
|
||||
{"large", 86399, "23:59:59"},
|
||||
{"negative", -5, "00:00:00"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := FormatDuration(tt.seconds); got != tt.expected {
|
||||
t.Errorf("FormatDuration(%d) = %q, want %q", tt.seconds, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedExtension(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected bool
|
||||
}{
|
||||
{"video.mp4", true},
|
||||
{"video.mov", true},
|
||||
{"video.mkv", true},
|
||||
{"video.webm", true},
|
||||
{"video.m4v", true},
|
||||
{"video.ts", true},
|
||||
{"video.MP4", true},
|
||||
{"video.avi", false},
|
||||
{"video.flv", false},
|
||||
{"video.wmv", false},
|
||||
{"video", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsAllowedExtension(tt.name); got != tt.expected {
|
||||
t.Errorf("IsAllowedExtension(%q) = %v, want %v", tt.name, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedMime(t *testing.T) {
|
||||
tests := []struct {
|
||||
mime string
|
||||
expected bool
|
||||
}{
|
||||
{"video/mp4", true},
|
||||
{"video/quicktime", true},
|
||||
{"video/x-matroska", true},
|
||||
{"video/webm", true},
|
||||
{"application/octet-stream", true},
|
||||
{"", true},
|
||||
{"image/jpeg", false},
|
||||
{"audio/mpeg", false},
|
||||
{"text/plain", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mime, func(t *testing.T) {
|
||||
if got := IsAllowedMime(tt.mime); got != tt.expected {
|
||||
t.Errorf("IsAllowedMime(%q) = %v, want %v", tt.mime, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUploadID(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
expected bool
|
||||
}{
|
||||
{"abcdef0123456789", true}, // 16 hex chars
|
||||
{"abcdef0123456789abcdef0123456789", true}, // 32 hex chars
|
||||
{"abc", false}, // too short
|
||||
{"abcdef012345678", false}, // 15 chars (too short)
|
||||
{"abcdef0123456789g", false}, // non-hex char
|
||||
{"ABCDEF0123456789", false}, // uppercase (not allowed)
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.id, func(t *testing.T) {
|
||||
if got := ValidateUploadID(tt.id); got != tt.expected {
|
||||
t.Errorf("ValidateUploadID(%q) = %v, want %v", tt.id, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlaylist(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expected int
|
||||
first string
|
||||
}{
|
||||
{
|
||||
name: "simple",
|
||||
content: "/videos/video1.mp4\n/videos/video2.mp4\n",
|
||||
expected: 2,
|
||||
first: "/videos/video1.mp4",
|
||||
},
|
||||
{
|
||||
name: "with file prefix",
|
||||
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n",
|
||||
expected: 2,
|
||||
first: "/videos/video1.mp4",
|
||||
},
|
||||
{
|
||||
name: "with comments and blanks",
|
||||
content: "# comment\n\n/videos/video1.mp4\n\n# another comment\n/videos/video2.mp4\n",
|
||||
expected: 2,
|
||||
first: "/videos/video1.mp4",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
content: "",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "only comments",
|
||||
content: "# comment\n# another\n",
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
entries := ParsePlaylist(tt.content)
|
||||
if len(entries) != tt.expected {
|
||||
t.Errorf("ParsePlaylist() len = %d, want %d", len(entries), tt.expected)
|
||||
}
|
||||
if tt.expected > 0 && entries[0].Path != tt.first {
|
||||
t.Errorf("ParsePlaylist()[0].Path = %q, want %q", entries[0].Path, tt.first)
|
||||
}
|
||||
// Check indices
|
||||
for i, e := range entries {
|
||||
if e.Index != i+1 {
|
||||
t.Errorf("ParsePlaylist()[%d].Index = %d, want %d", i, e.Index, i+1)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMediaIDFromPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
prefix string
|
||||
expected uint
|
||||
expectErr bool
|
||||
}{
|
||||
{"/api/admin/media/123", "/api/admin/media/", 123, false},
|
||||
{"/api/admin/media/1", "/api/admin/media/", 1, false},
|
||||
{"/api/admin/media/", "/api/admin/media/", 0, true},
|
||||
{"/api/admin/media/abc", "/api/admin/media/", 0, true},
|
||||
{"/api/admin/media/0", "/api/admin/media/", 0, true},
|
||||
{"/other/path", "/api/admin/media/", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
id, err := GetMediaIDFromPath(tt.path, tt.prefix)
|
||||
if tt.expectErr && err == nil {
|
||||
t.Errorf("GetMediaIDFromPath(%q) expected error, got nil", tt.path)
|
||||
}
|
||||
if !tt.expectErr && err != nil {
|
||||
t.Errorf("GetMediaIDFromPath(%q) error = %v", tt.path, err)
|
||||
}
|
||||
if id != tt.expected {
|
||||
t.Errorf("GetMediaIDFromPath(%q) = %d, want %d", tt.path, id, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueTargetPath(t *testing.T) {
|
||||
// Test that the function returns a path with the mediaDir prefix
|
||||
// and the correct filename. We can't easily test collision behavior
|
||||
// without filesystem setup, but we can verify the basic path.
|
||||
expected := filepath.Join("/tmp/media", "video.mp4")
|
||||
path := UniqueTargetPath("/tmp/media", "video.mp4")
|
||||
if path != expected {
|
||||
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/auth"
|
||||
"chat/src/db"
|
||||
"chat/src/ws"
|
||||
)
|
||||
|
||||
var requestDurationBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5}
|
||||
|
||||
var chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||
|
||||
type requestDurationHistogram struct {
|
||||
mu sync.Mutex
|
||||
buckets []float64
|
||||
counts []uint64
|
||||
sum float64
|
||||
total uint64
|
||||
}
|
||||
|
||||
func newRequestDurationHistogram(buckets []float64) *requestDurationHistogram {
|
||||
return &requestDurationHistogram{
|
||||
buckets: append([]float64(nil), buckets...),
|
||||
counts: make([]uint64, len(buckets)+1),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *requestDurationHistogram) observe(durationSeconds float64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.total++
|
||||
h.sum += durationSeconds
|
||||
for index, bucket := range h.buckets {
|
||||
if durationSeconds <= bucket {
|
||||
h.counts[index]++
|
||||
return
|
||||
}
|
||||
}
|
||||
h.counts[len(h.counts)-1]++
|
||||
}
|
||||
|
||||
func (h *requestDurationHistogram) snapshot() (counts []uint64, sum float64, total uint64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
counts = append([]uint64(nil), h.counts...)
|
||||
return counts, h.sum, h.total
|
||||
}
|
||||
|
||||
// ObserveRequestDuration records a chat HTTP request duration in seconds.
|
||||
func ObserveRequestDuration(duration time.Duration) {
|
||||
chatRequestDurationHistogram.observe(duration.Seconds())
|
||||
}
|
||||
|
||||
// WrapRequestTiming records request duration for the provided handler.
|
||||
func WrapRequestTiming(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
startedAt := time.Now()
|
||||
defer func() { ObserveRequestDuration(time.Since(startedAt)) }()
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMetrics returns Prometheus-format metrics for the chat service.
|
||||
func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var storedMessages int64
|
||||
if gormDB != nil {
|
||||
_ = gormDB.Model(&db.Message{}).Count(&storedMessages).Error
|
||||
}
|
||||
|
||||
var moderationActions int64
|
||||
if gormDB != nil {
|
||||
_ = gormDB.Model(&db.ModerationLog{}).Count(&moderationActions).Error
|
||||
}
|
||||
|
||||
redisEnabled := 0
|
||||
redisUp := 0
|
||||
redisClients := 0
|
||||
redisMemoryUsedBytes := int64(0)
|
||||
redisMemoryMaxBytes := int64(0)
|
||||
redisMemoryUsageRatio := 0.0
|
||||
if redis != nil {
|
||||
redisEnabled = 1
|
||||
if err := redis.Ping(context.Background()); err == nil {
|
||||
redisUp = 1
|
||||
if info, err := redis.Info(context.Background(), "clients"); err == nil {
|
||||
for _, line := range strings.Split(info, "\n") {
|
||||
if strings.HasPrefix(line, "connected_clients:") {
|
||||
if value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "connected_clients:"))); err == nil {
|
||||
redisClients = value
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if info, err := redis.Info(context.Background(), "memory"); err == nil {
|
||||
if value := parseRedisMemoryBytes(info, "used_memory_human"); value > 0 {
|
||||
redisMemoryUsedBytes = value
|
||||
}
|
||||
if value := parseRedisMemoryBytes(info, "maxmemory_human"); value > 0 {
|
||||
redisMemoryMaxBytes = value
|
||||
} else if value := parseRedisMemoryBytes(info, "maxmemory"); value > 0 {
|
||||
redisMemoryMaxBytes = value
|
||||
}
|
||||
if redisMemoryMaxBytes > 0 {
|
||||
redisMemoryUsageRatio = math.Min(float64(redisMemoryUsedBytes)/float64(redisMemoryMaxBytes), 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectedClients := 0
|
||||
dmsSent := int64(0)
|
||||
if hub != nil {
|
||||
connectedClients = hub.ConnectedClients()
|
||||
dmsSent = hub.DMsSent
|
||||
}
|
||||
|
||||
activeSessions := 0
|
||||
if sessionStore != nil {
|
||||
activeSessions = sessionStore.CountActive()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
|
||||
writeLine := func(name string, value any) {
|
||||
fmt.Fprintf(w, "%s %v\n", name, value)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge")
|
||||
writeLine("linearvoid_chat_connected_clients", connectedClients)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
|
||||
writeLine("linearvoid_chat_messages_total", storedMessages)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_dms_total Total private messages sent.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_dms_total counter")
|
||||
writeLine("linearvoid_chat_dms_total", dmsSent)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
|
||||
writeLine("linearvoid_chat_moderation_actions_total", moderationActions)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_enabled Redis configured for chat service.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_enabled gauge")
|
||||
writeLine("linearvoid_chat_redis_enabled", redisEnabled)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_up Redis reachable from chat service.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_up gauge")
|
||||
writeLine("linearvoid_chat_redis_up", redisUp)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_connected_clients Redis connected client count seen by chat service.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
|
||||
writeLine("linearvoid_chat_redis_connected_clients", redisClients)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_used_bytes Redis used memory reported by INFO memory.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_used_bytes gauge")
|
||||
writeLine("linearvoid_chat_redis_memory_used_bytes", redisMemoryUsedBytes)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_max_bytes Redis maxmemory limit reported by INFO memory.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_max_bytes gauge")
|
||||
writeLine("linearvoid_chat_redis_memory_max_bytes", redisMemoryMaxBytes)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_usage_ratio Redis memory usage ratio (used / max).")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_usage_ratio gauge")
|
||||
writeLine("linearvoid_chat_redis_memory_usage_ratio", redisMemoryUsageRatio)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
|
||||
writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
|
||||
fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
|
||||
|
||||
counts, sum, total := chatRequestDurationHistogram.snapshot()
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_request_duration_seconds HTTP request duration for chat API handlers.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_request_duration_seconds histogram")
|
||||
cumulative := uint64(0)
|
||||
for index, bucket := range requestDurationBuckets {
|
||||
cumulative += counts[index]
|
||||
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"%g\"} %d\n", bucket, cumulative)
|
||||
}
|
||||
cumulative += counts[len(counts)-1]
|
||||
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"+Inf\"} %d\n", cumulative)
|
||||
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_sum %g\n", sum)
|
||||
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_count %d\n", total)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleHealth returns a simple health check endpoint.
|
||||
func HandleHealth() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleNotFound returns a 404 JSON response for all unhandled paths.
|
||||
func HandleNotFound() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprint(w, `{"error":"not found"}`)
|
||||
}
|
||||
}
|
||||
|
||||
func parseRedisMemoryBytes(info, key string) int64 {
|
||||
for _, line := range strings.Split(info, "\n") {
|
||||
if !strings.HasPrefix(line, key+":") {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(strings.TrimPrefix(line, key+":"))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if raw, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
return raw
|
||||
}
|
||||
return parseHumanBytes(value)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseHumanBytes(value string) int64 {
|
||||
value = strings.TrimSpace(strings.ToUpper(value))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
multiplier := float64(1)
|
||||
suffixes := map[string]float64{
|
||||
"B": 1,
|
||||
"K": 1024,
|
||||
"KB": 1024,
|
||||
"M": 1024 * 1024,
|
||||
"MB": 1024 * 1024,
|
||||
"G": 1024 * 1024 * 1024,
|
||||
"GB": 1024 * 1024 * 1024,
|
||||
"T": 1024 * 1024 * 1024 * 1024,
|
||||
"TB": 1024 * 1024 * 1024 * 1024,
|
||||
}
|
||||
|
||||
for suffix, factor := range suffixes {
|
||||
if strings.HasSuffix(value, suffix) {
|
||||
multiplier = factor
|
||||
value = strings.TrimSpace(strings.TrimSuffix(value, suffix))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(parsed * multiplier)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/auth"
|
||||
"chat/src/db"
|
||||
"chat/src/ws"
|
||||
)
|
||||
|
||||
func TestHandleHealth(t *testing.T) {
|
||||
handler := HandleHealth()
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"healthy"`) {
|
||||
t.Errorf("body missing healthy: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"chat"`) {
|
||||
t.Errorf("body missing service name: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNotFound(t *testing.T) {
|
||||
handler := HandleNotFound()
|
||||
req := httptest.NewRequest("GET", "/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"not found"`) {
|
||||
t.Errorf("body missing not found: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMetrics(t *testing.T) {
|
||||
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||
ObserveRequestDuration(100 * time.Millisecond)
|
||||
ObserveRequestDuration(750 * time.Millisecond)
|
||||
|
||||
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test DB: %v", err)
|
||||
}
|
||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||
|
||||
// Add some test data
|
||||
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
|
||||
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
|
||||
|
||||
sessionStore := auth.NewSessionStore()
|
||||
hub := ws.NewHub(gormDB, nil, "test-instance", 10, 10*time.Second, "admin")
|
||||
hub.DMsSent = 5
|
||||
|
||||
handler := HandleMetrics(gormDB, nil, hub, sessionStore, "sqlite", "test-instance")
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Check required metric names
|
||||
requiredMetrics := []string{
|
||||
"linearvoid_chat_connected_clients",
|
||||
"linearvoid_chat_messages_total",
|
||||
"linearvoid_chat_dms_total",
|
||||
"linearvoid_chat_moderation_actions_total",
|
||||
"linearvoid_chat_redis_enabled",
|
||||
"linearvoid_chat_redis_up",
|
||||
"linearvoid_chat_redis_connected_clients",
|
||||
"linearvoid_chat_redis_memory_used_bytes",
|
||||
"linearvoid_chat_redis_memory_max_bytes",
|
||||
"linearvoid_chat_redis_memory_usage_ratio",
|
||||
"linearvoid_chat_admin_sessions_active",
|
||||
"linearvoid_chat_build_info",
|
||||
"linearvoid_chat_request_duration_seconds",
|
||||
}
|
||||
|
||||
for _, metric := range requiredMetrics {
|
||||
if !strings.Contains(body, metric) {
|
||||
t.Errorf("body missing metric %s", metric)
|
||||
}
|
||||
}
|
||||
|
||||
// Check build_info has instance_id label
|
||||
if !strings.Contains(body, `instance_id="test-instance"`) {
|
||||
t.Error("build_info missing instance_id label")
|
||||
}
|
||||
|
||||
// Check values
|
||||
if !strings.Contains(body, "linearvoid_chat_messages_total 1") {
|
||||
t.Error("messages_total should be 1")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_moderation_actions_total 1") {
|
||||
t.Error("moderation_actions_total should be 1")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_dms_total 5") {
|
||||
t.Error("dms_total should be 5")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") {
|
||||
t.Error("redis_enabled should be 0 (nil redis)")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_bucket{le=\"1\"} 2") {
|
||||
t.Error("request duration histogram should include both observed samples by the 1s bucket")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 2") {
|
||||
t.Error("request duration count should be 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMetrics_WrongMethod(t *testing.T) {
|
||||
handler := HandleMetrics(nil, nil, nil, nil, "", "")
|
||||
req := httptest.NewRequest("POST", "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want 405", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMetrics_WithRedis(t *testing.T) {
|
||||
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
|
||||
ObserveRequestDuration(250 * time.Millisecond)
|
||||
|
||||
gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||
|
||||
// mockRedis for metrics test
|
||||
mr := &metricsMockRedis{}
|
||||
sessionStore := auth.NewSessionStore()
|
||||
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
|
||||
|
||||
handler := HandleMetrics(gormDB, mr, hub, sessionStore, "sqlite", "inst-123")
|
||||
req := httptest.NewRequest("GET", "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_enabled 1") {
|
||||
t.Error("redis_enabled should be 1")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_up 1") {
|
||||
t.Error("redis_up should be 1")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") {
|
||||
t.Error("redis_connected_clients should be 5")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_memory_used_bytes 1048576") {
|
||||
t.Error("redis_memory_used_bytes should be 1048576")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_redis_memory_usage_ratio 1") {
|
||||
t.Error("redis_memory_usage_ratio should be 1 when maxmemory is unavailable or equal to used memory in tests")
|
||||
}
|
||||
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 1") {
|
||||
t.Error("request duration count should be 1")
|
||||
}
|
||||
}
|
||||
|
||||
// metricsMockRedis is a minimal mock for metrics tests.
|
||||
type metricsMockRedis struct{}
|
||||
|
||||
func (m *metricsMockRedis) Ping(ctx context.Context) error { return nil }
|
||||
func (m *metricsMockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (m *metricsMockRedis) Get(ctx context.Context, key string) (string, error) { return "", nil }
|
||||
func (m *metricsMockRedis) Del(ctx context.Context, keys ...string) error { return nil }
|
||||
func (m *metricsMockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||
func (m *metricsMockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||
func (m *metricsMockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (m *metricsMockRedis) Exists(ctx context.Context, keys ...string) (int64, error) { return 0, nil }
|
||||
func (m *metricsMockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||
return nil
|
||||
}
|
||||
func (m *metricsMockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub { return nil }
|
||||
func (m *metricsMockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) {
|
||||
return "connected_clients:5\nused_memory_human:1M\nmaxmemory_human:1M\n", nil
|
||||
}
|
||||
func (m *metricsMockRedis) Close() error { return nil }
|
||||
@@ -0,0 +1,75 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RedisClient is the subset of Redis operations needed for rate limiting.
|
||||
type RedisClient interface {
|
||||
Incr(ctx context.Context, key string) (int64, error)
|
||||
Expire(ctx context.Context, key string, expiration time.Duration) error
|
||||
Decr(ctx context.Context, key string) (int64, error)
|
||||
}
|
||||
|
||||
// CheckMessageRate checks if a user has exceeded the message rate limit.
|
||||
// Returns true if allowed, false if rate limited.
|
||||
func CheckMessageRate(ctx context.Context, redis RedisClient, username string, max int, window time.Duration) (bool, error) {
|
||||
if redis == nil {
|
||||
return true, nil // No Redis = no rate limiting
|
||||
}
|
||||
key := fmt.Sprintf("rate:%s", username)
|
||||
count, err := redis.Incr(ctx, key)
|
||||
if err != nil {
|
||||
return true, err // Fail open on Redis error
|
||||
}
|
||||
if count == 1 {
|
||||
_ = redis.Expire(ctx, key, window)
|
||||
}
|
||||
return count <= int64(max), nil
|
||||
}
|
||||
|
||||
// CheckLoginRate checks if an IP has exceeded the login attempt limit.
|
||||
// Returns true if allowed, false if rate limited.
|
||||
func CheckLoginRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
|
||||
if redis == nil {
|
||||
return true, nil
|
||||
}
|
||||
key := fmt.Sprintf("login_attempts:%s", clientIP)
|
||||
count, err := redis.Incr(ctx, key)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if count == 1 {
|
||||
_ = redis.Expire(ctx, key, 5*time.Minute)
|
||||
}
|
||||
return count <= 5, nil
|
||||
}
|
||||
|
||||
// CheckWSConnRate checks if an IP has exceeded the WebSocket connection limit.
|
||||
// Returns true if allowed, false if rate limited.
|
||||
// Caller must call Decr on disconnect.
|
||||
func CheckWSConnRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
|
||||
if redis == nil {
|
||||
return true, nil
|
||||
}
|
||||
key := fmt.Sprintf("ws_conn:%s", clientIP)
|
||||
count, err := redis.Incr(ctx, key)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if count == 1 {
|
||||
_ = redis.Expire(ctx, key, 60*time.Second)
|
||||
}
|
||||
return count <= 5, nil
|
||||
}
|
||||
|
||||
// DecrWSConn decrements the WebSocket connection counter for an IP.
|
||||
func DecrWSConn(ctx context.Context, redis RedisClient, clientIP string) {
|
||||
if redis == nil {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("ws_conn:%s", clientIP)
|
||||
_, _ = redis.Decr(ctx, key)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockRedis implements RedisClient for testing.
|
||||
type mockRedis struct {
|
||||
mu sync.Mutex
|
||||
store map[string]int64
|
||||
}
|
||||
|
||||
func newMockRedis() *mockRedis {
|
||||
return &mockRedis{store: make(map[string]int64)}
|
||||
}
|
||||
|
||||
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.store[key]++
|
||||
return m.store[key], nil
|
||||
}
|
||||
|
||||
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.store[key]--
|
||||
return m.store[key], nil
|
||||
}
|
||||
|
||||
func TestCheckMessageRate(t *testing.T) {
|
||||
redis := newMockRedis()
|
||||
ctx := context.Background()
|
||||
|
||||
// First 10 messages allowed
|
||||
for i := 0; i < 10; i++ {
|
||||
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckMessageRate #%d error = %v", i+1, err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Errorf("CheckMessageRate #%d = false, want true", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 11th message blocked
|
||||
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckMessageRate #11 error = %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("CheckMessageRate #11 = true, want false (rate limited)")
|
||||
}
|
||||
|
||||
// Different user not affected
|
||||
allowed, err = CheckMessageRate(ctx, redis, "user2", 10, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckMessageRate user2 error = %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("CheckMessageRate user2 = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckMessageRate_NilRedis(t *testing.T) {
|
||||
allowed, err := CheckMessageRate(context.Background(), nil, "user1", 10, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckMessageRate nil redis error = %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("CheckMessageRate nil redis = false, want true (fail open)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLoginRate(t *testing.T) {
|
||||
redis := newMockRedis()
|
||||
ctx := context.Background()
|
||||
|
||||
// First 5 attempts allowed
|
||||
for i := 0; i < 5; i++ {
|
||||
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckLoginRate #%d error = %v", i+1, err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Errorf("CheckLoginRate #%d = false, want true", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 6th attempt blocked
|
||||
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckLoginRate #6 error = %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("CheckLoginRate #6 = true, want false (rate limited)")
|
||||
}
|
||||
|
||||
// Different IP not affected
|
||||
allowed, err = CheckLoginRate(ctx, redis, "10.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckLoginRate other IP error = %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("CheckLoginRate other IP = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLoginRate_NilRedis(t *testing.T) {
|
||||
allowed, err := CheckLoginRate(context.Background(), nil, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckLoginRate nil redis error = %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("CheckLoginRate nil redis = false, want true (fail open)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckWSConnRate(t *testing.T) {
|
||||
redis := newMockRedis()
|
||||
ctx := context.Background()
|
||||
|
||||
// First 5 connections allowed
|
||||
for i := 0; i < 5; i++ {
|
||||
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckWSConnRate #%d error = %v", i+1, err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Errorf("CheckWSConnRate #%d = false, want true", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 6th connection blocked
|
||||
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckWSConnRate #6 error = %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Error("CheckWSConnRate #6 = true, want false (rate limited)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckWSConnRate_NilRedis(t *testing.T) {
|
||||
allowed, err := CheckWSConnRate(context.Background(), nil, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckWSConnRate nil redis error = %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("CheckWSConnRate nil redis = false, want true (fail open)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrWSConn(t *testing.T) {
|
||||
redis := newMockRedis()
|
||||
ctx := context.Background()
|
||||
|
||||
// Increment to 5
|
||||
for i := 0; i < 5; i++ {
|
||||
CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||
}
|
||||
|
||||
// 6th should be blocked
|
||||
allowed, _ := CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||
if allowed {
|
||||
t.Error("6th connection should be blocked")
|
||||
}
|
||||
|
||||
// Decrement twice (simulate 2 disconnects)
|
||||
DecrWSConn(ctx, redis, "192.168.1.1")
|
||||
DecrWSConn(ctx, redis, "192.168.1.1")
|
||||
|
||||
// Now should be allowed again (counter 6→4, then incr→5)
|
||||
allowed, _ = CheckWSConnRate(ctx, redis, "192.168.1.1")
|
||||
if !allowed {
|
||||
t.Error("after 2x DecrWSConn, connection should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrWSConn_NilRedis(t *testing.T) {
|
||||
// Should not panic
|
||||
DecrWSConn(context.Background(), nil, "192.168.1.1")
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/db"
|
||||
"chat/src/ratelimit"
|
||||
)
|
||||
|
||||
// Upgrader is the WebSocket upgrader configuration.
|
||||
var Upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// ClientInfo stores metadata about a connected WebSocket client.
|
||||
type ClientInfo struct {
|
||||
Username string
|
||||
Room string
|
||||
}
|
||||
|
||||
// Hub manages all WebSocket connections and message routing.
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[*websocket.Conn]bool
|
||||
clientMeta map[*websocket.Conn]ClientInfo
|
||||
DB *gorm.DB
|
||||
Redis db.RedisClient
|
||||
InstanceID string
|
||||
RateLimitMax int
|
||||
RateLimitWindow time.Duration
|
||||
AdminUsername string
|
||||
DMsSent int64
|
||||
}
|
||||
|
||||
// NewHub creates a new WebSocket hub.
|
||||
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*websocket.Conn]bool),
|
||||
clientMeta: make(map[*websocket.Conn]ClientInfo),
|
||||
DB: gormDB,
|
||||
Redis: redis,
|
||||
InstanceID: instanceID,
|
||||
RateLimitMax: rateMax,
|
||||
RateLimitWindow: rateWindow,
|
||||
AdminUsername: adminUser,
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectedClients returns the number of currently connected clients.
|
||||
func (h *Hub) ConnectedClients() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all connected clients and persists to DB.
|
||||
func (h *Hub) Broadcast(msg db.Message) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// Persist to configured SQL backend.
|
||||
if h.DB != nil {
|
||||
h.DB.Create(&msg)
|
||||
}
|
||||
|
||||
// Local fan-out
|
||||
for client := range h.clients {
|
||||
if err := client.WriteJSON(msg); err != nil {
|
||||
client.Close()
|
||||
delete(h.clients, client)
|
||||
delete(h.clientMeta, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Redis pub/sub for multi-instance fan-out
|
||||
if h.Redis != nil && msg.Room != "" {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"instance": h.InstanceID,
|
||||
"msg": msg,
|
||||
})
|
||||
_ = h.Redis.Publish(context.Background(), fmt.Sprintf("room:%s:messages", msg.Room), payload)
|
||||
}
|
||||
}
|
||||
|
||||
// PushChatHistory sends the last N messages for a room via WebSocket.
|
||||
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
||||
if h.DB == nil || room == "" {
|
||||
return
|
||||
}
|
||||
var messages []db.Message
|
||||
h.DB.Where("room = ?", room).Order("timestamp desc").Limit(limit).Find(&messages)
|
||||
// Reverse to chronological order
|
||||
for i := len(messages)/2 - 1; i >= 0; i-- {
|
||||
opp := len(messages) - 1 - i
|
||||
messages[i], messages[opp] = messages[opp], messages[i]
|
||||
}
|
||||
h.mu.Lock()
|
||||
for _, msg := range messages {
|
||||
conn.WriteJSON(msg)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// HandleConnection handles a new WebSocket connection.
|
||||
func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := r.Header.Get("X-Forwarded-For")
|
||||
if clientIP == "" {
|
||||
clientIP = r.RemoteAddr
|
||||
}
|
||||
|
||||
// IP-based connection rate limiting
|
||||
allowed, err := ratelimit.CheckWSConnRate(context.Background(), h.Redis, clientIP)
|
||||
if err != nil {
|
||||
log.Printf("WS rate limit check error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, "too many connections from this IP", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
defer ratelimit.DecrWSConn(context.Background(), h.Redis, clientIP)
|
||||
|
||||
conn, err := Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Println("Upgrade error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.URL.Query().Get("username")
|
||||
room := r.URL.Query().Get("room")
|
||||
if username == "" {
|
||||
username = fmt.Sprintf("anon_%d", time.Now().UnixNano()%10000)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.clients[conn] = true
|
||||
h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
|
||||
h.mu.Unlock()
|
||||
|
||||
// Announce presence (ADR-007)
|
||||
if h.Redis != nil && room != "" {
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
|
||||
}
|
||||
|
||||
// Push chat history on join (last 50 messages)
|
||||
h.PushChatHistory(conn, room, 50)
|
||||
|
||||
// Heartbeat goroutine: refresh presence TTL every 30s
|
||||
heartbeatStop := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if h.Redis != nil && room != "" {
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||
}
|
||||
// Ping to detect dead connections
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
case <-heartbeatStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
close(heartbeatStop)
|
||||
h.mu.Lock()
|
||||
delete(h.clients, conn)
|
||||
delete(h.clientMeta, conn)
|
||||
h.mu.Unlock()
|
||||
if h.Redis != nil && room != "" {
|
||||
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
||||
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
var msg db.Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
msg.Timestamp = time.Now()
|
||||
msg.Username = username // enforce from query param for security
|
||||
|
||||
// Check if banned before processing
|
||||
if h.Redis != nil {
|
||||
if isBanned, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("banned:%s", username)); isBanned > 0 {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "You are banned from chat.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
if isMuted, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("muted:%s", username)); isMuted > 0 {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "You are muted.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting check
|
||||
allowed, _ := ratelimit.CheckMessageRate(context.Background(), h.Redis, msg.Username, h.RateLimitMax, h.RateLimitWindow)
|
||||
if !allowed {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "Rate limit exceeded. Please slow down.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle moderation commands
|
||||
if strings.HasPrefix(msg.Text, "/mod ") || strings.HasPrefix(msg.Text, "/mute ") || strings.HasPrefix(msg.Text, "/ban ") {
|
||||
h.handleModerationCommand(msg, conn)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle private DMs
|
||||
if strings.HasPrefix(msg.Room, "dm:") {
|
||||
h.handlePrivateDM(msg)
|
||||
continue
|
||||
}
|
||||
|
||||
msg.Type = "public"
|
||||
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
|
||||
h.Broadcast(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// handlePrivateDM routes private messages to the specific user pair.
|
||||
func (h *Hub) handlePrivateDM(msg db.Message) {
|
||||
msg.Type = "private"
|
||||
h.DMsSent++
|
||||
|
||||
// Persist for history
|
||||
if h.DB != nil {
|
||||
h.DB.Create(&msg)
|
||||
}
|
||||
|
||||
// Deliver to target if online
|
||||
h.mu.Lock()
|
||||
targetOnline := false
|
||||
for conn, info := range h.clientMeta {
|
||||
if info.Username == msg.Room || strings.Contains(msg.Room, info.Username) {
|
||||
if err := conn.WriteJSON(msg); err == nil {
|
||||
targetOnline = true
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if !targetOnline {
|
||||
log.Printf("DM stored for offline user: %s -> room %s", msg.Username, msg.Room)
|
||||
}
|
||||
|
||||
// Also deliver to sender for confirmation
|
||||
h.mu.Lock()
|
||||
for conn, info := range h.clientMeta {
|
||||
if info.Username == msg.Username {
|
||||
conn.WriteJSON(msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
log.Printf("Private DM from %s to room %s: %s", msg.Username, msg.Room, msg.Text)
|
||||
}
|
||||
|
||||
// logModerationAction logs an action to the immutable ModerationLog table.
|
||||
func (h *Hub) logModerationAction(action, target, moderator, reason string) {
|
||||
logEntry := db.ModerationLog{
|
||||
Action: action,
|
||||
Target: target,
|
||||
Moderator: moderator,
|
||||
Reason: reason,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if h.DB != nil {
|
||||
h.DB.Create(&logEntry)
|
||||
}
|
||||
log.Printf("Moderation action logged: %s %s by %s - %s", action, target, moderator, reason)
|
||||
}
|
||||
|
||||
// handleModerationCommand processes /mod, /mute, /ban commands.
|
||||
func (h *Hub) handleModerationCommand(msg db.Message, conn *websocket.Conn) {
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 3 {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "Usage: /mute <user> <duration_m> <reason> or /ban <user> <reason>", Timestamp: time.Now()})
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Username != h.AdminUsername {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "Only moderators can use moderation commands.", Timestamp: time.Now()})
|
||||
return
|
||||
}
|
||||
|
||||
action := strings.TrimPrefix(parts[0], "/")
|
||||
target := parts[1]
|
||||
reason := strings.Join(parts[2:], " ")
|
||||
|
||||
if h.Redis != nil {
|
||||
switch action {
|
||||
case "mute", "mod":
|
||||
durationMinutes := 10 // default
|
||||
if len(parts) >= 3 {
|
||||
if d, err := strconv.Atoi(parts[2]); err == nil && d > 0 {
|
||||
durationMinutes = d
|
||||
if len(parts) > 3 {
|
||||
reason = strings.Join(parts[3:], " ")
|
||||
} else {
|
||||
reason = "no reason given"
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("muted:%s", target), "1", time.Duration(durationMinutes)*time.Minute)
|
||||
case "ban":
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("banned:%s", target), "1", 0) // no expiry
|
||||
}
|
||||
}
|
||||
|
||||
h.logModerationAction(action, target, msg.Username, reason)
|
||||
|
||||
h.Broadcast(db.Message{
|
||||
Room: msg.Room,
|
||||
Username: "system",
|
||||
Type: "system",
|
||||
Text: fmt.Sprintf("%s was %s by %s: %s", target, action, msg.Username, reason),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// SubscribeRoomMessages listens for Redis pub/sub messages and fans out locally.
|
||||
func (h *Hub) SubscribeRoomMessages() {
|
||||
if h.Redis == nil {
|
||||
return
|
||||
}
|
||||
pubsub := h.Redis.PSubscribe(context.Background(), "room:*:messages")
|
||||
defer pubsub.Close()
|
||||
|
||||
log.Printf("Redis pub/sub subscriber started (instance %s)", h.InstanceID)
|
||||
|
||||
for {
|
||||
msg, err := pubsub.ReceiveMessage(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("Redis pub/sub error: %v — retrying in 5s", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
pubsub = h.Redis.PSubscribe(context.Background(), "room:*:messages")
|
||||
continue
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Instance string `json:"instance"`
|
||||
Msg db.Message `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip messages originating from this instance
|
||||
if envelope.Instance == h.InstanceID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fan out to local clients
|
||||
h.mu.Lock()
|
||||
for client := range h.clients {
|
||||
client.WriteJSON(envelope.Msg)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ScanPresence returns all active presence entries from Redis.
|
||||
func (h *Hub) ScanPresence(ctx context.Context) ([]db.Presence, error) {
|
||||
if h.Redis == nil {
|
||||
return nil, nil
|
||||
}
|
||||
keys, _, err := h.Redis.Scan(ctx, 0, "presence:*", 200)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var presences []db.Presence
|
||||
for _, key := range keys {
|
||||
parts := strings.SplitN(key, ":", 3)
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
presences = append(presences, db.Presence{
|
||||
Room: parts[1],
|
||||
Username: parts[2],
|
||||
Online: true,
|
||||
})
|
||||
}
|
||||
return presences, nil
|
||||
}
|
||||
|
||||
// MuteUser mutes a user for a duration.
|
||||
func (h *Hub) MuteUser(ctx context.Context, username string, duration time.Duration) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Set(ctx, fmt.Sprintf("muted:%s", username), "1", duration)
|
||||
}
|
||||
|
||||
// UnmuteUser removes a mute.
|
||||
func (h *Hub) UnmuteUser(ctx context.Context, username string) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Del(ctx, fmt.Sprintf("muted:%s", username))
|
||||
}
|
||||
|
||||
// BanUser bans a user permanently.
|
||||
func (h *Hub) BanUser(ctx context.Context, username string) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Set(ctx, fmt.Sprintf("banned:%s", username), "1", 0)
|
||||
}
|
||||
|
||||
// UnbanUser removes a ban.
|
||||
func (h *Hub) UnbanUser(ctx context.Context, username string) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Del(ctx, fmt.Sprintf("banned:%s", username))
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/gorilla/websocket"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/db"
|
||||
)
|
||||
|
||||
// mockRedis implements db.RedisClient for testing.
|
||||
type mockRedis struct {
|
||||
mu sync.Mutex
|
||||
store map[string]string
|
||||
pubsubs []*mockPubSub
|
||||
}
|
||||
|
||||
func newMockRedis() *mockRedis {
|
||||
return &mockRedis{store: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (m *mockRedis) Ping(ctx context.Context) error { return nil }
|
||||
func (m *mockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.store[key] = value.(string)
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Get(ctx context.Context, key string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.store[key], nil
|
||||
}
|
||||
func (m *mockRedis) Del(ctx context.Context, keys ...string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, k := range keys {
|
||||
delete(m.store, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
|
||||
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, k := range keys {
|
||||
if _, ok := m.store[k]; ok {
|
||||
return 1, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
payload, _ := json.Marshal(message)
|
||||
for _, ps := range m.pubsubs {
|
||||
ps.messages <- &db.PubSubMessage{Channel: channel, Payload: string(payload)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub {
|
||||
ps := &mockPubSub{messages: make(chan *db.PubSubMessage, 100)}
|
||||
m.mu.Lock()
|
||||
m.pubsubs = append(m.pubsubs, ps)
|
||||
m.mu.Unlock()
|
||||
return ps
|
||||
}
|
||||
func (m *mockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range m.store {
|
||||
if strings.Contains(k, strings.TrimSuffix(match, "*")) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
return keys, 0, nil
|
||||
}
|
||||
func (m *mockRedis) Info(ctx context.Context, sections ...string) (string, error) { return "", nil }
|
||||
func (m *mockRedis) Close() error { return nil }
|
||||
|
||||
type mockPubSub struct {
|
||||
messages chan *db.PubSubMessage
|
||||
}
|
||||
|
||||
func (m *mockPubSub) ReceiveMessage(ctx context.Context) (*db.PubSubMessage, error) {
|
||||
select {
|
||||
case msg := <-m.messages:
|
||||
return msg, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
func (m *mockPubSub) Close() error { return nil }
|
||||
|
||||
func setupTestHub(t *testing.T) (*Hub, *gorm.DB, *mockRedis) {
|
||||
t.Helper()
|
||||
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test DB: %v", err)
|
||||
}
|
||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
|
||||
mr := newMockRedis()
|
||||
hub := NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
|
||||
return hub, gormDB, mr
|
||||
}
|
||||
|
||||
func setupWSServer(hub *Hub) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(hub.HandleConnection))
|
||||
}
|
||||
|
||||
// wsClient wraps a websocket.Conn with a channel-based read helper.
|
||||
type wsClient struct {
|
||||
conn *websocket.Conn
|
||||
msgs chan db.Message
|
||||
errors chan error
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newWSClient(t *testing.T, url string) *wsClient {
|
||||
t.Helper()
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial error: %v", err)
|
||||
}
|
||||
wc := &wsClient{
|
||||
conn: conn,
|
||||
msgs: make(chan db.Message, 100),
|
||||
errors: make(chan error, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go wc.readLoop()
|
||||
return wc
|
||||
}
|
||||
|
||||
func (wc *wsClient) readLoop() {
|
||||
defer close(wc.msgs)
|
||||
for {
|
||||
var msg db.Message
|
||||
if err := wc.conn.ReadJSON(&msg); err != nil {
|
||||
wc.errors <- err
|
||||
return
|
||||
}
|
||||
select {
|
||||
case wc.msgs <- msg:
|
||||
case <-wc.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (wc *wsClient) close() {
|
||||
close(wc.done)
|
||||
wc.conn.Close()
|
||||
}
|
||||
|
||||
// recvMsg reads the next message with a timeout.
|
||||
func (wc *wsClient) recvMsg(timeout time.Duration) (*db.Message, error) {
|
||||
select {
|
||||
case msg := <-wc.msgs:
|
||||
return &msg, nil
|
||||
case err := <-wc.errors:
|
||||
return nil, err
|
||||
case <-time.After(timeout):
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
}
|
||||
|
||||
// skipSystem reads and discards system messages.
|
||||
func (wc *wsClient) skipSystem(timeout time.Duration) {
|
||||
for {
|
||||
msg, err := wc.recvMsg(timeout)
|
||||
if err != nil || msg.Type != "system" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (wc *wsClient) writeJSON(v interface{}) error {
|
||||
return wc.conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
func TestHub_Broadcast(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
|
||||
msg := db.Message{
|
||||
Room: "main",
|
||||
Username: "user1",
|
||||
Text: "hello",
|
||||
Timestamp: time.Now(),
|
||||
Type: "public",
|
||||
}
|
||||
hub.Broadcast(msg)
|
||||
|
||||
var count int64
|
||||
hub.DB.Model(&db.Message{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("message count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ConnectedClients(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
if hub.ConnectedClients() != 0 {
|
||||
t.Errorf("ConnectedClients() = %d, want 0", hub.ConnectedClients())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_WebSocketConnection(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
msg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg.Text, "joined") {
|
||||
t.Errorf("expected join message, got text=%s", msg.Text)
|
||||
}
|
||||
|
||||
if hub.ConnectedClients() != 1 {
|
||||
t.Errorf("ConnectedClients() = %d, want 1", hub.ConnectedClients())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SendMessage(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
wc.skipSystem(2 * time.Second)
|
||||
|
||||
if err := wc.writeJSON(db.Message{Room: "main", Text: "hello world"}); err != nil {
|
||||
t.Fatalf("writeJSON error: %v", err)
|
||||
}
|
||||
|
||||
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if recvMsg.Text != "hello world" {
|
||||
t.Errorf("recvMsg.Text = %q, want hello world", recvMsg.Text)
|
||||
}
|
||||
if recvMsg.Username != "testuser" {
|
||||
t.Errorf("recvMsg.Username = %q, want testuser", recvMsg.Username)
|
||||
}
|
||||
if recvMsg.Type != "public" {
|
||||
t.Errorf("recvMsg.Type = %q, want public", recvMsg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Disconnect(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
wc.skipSystem(2 * time.Second)
|
||||
|
||||
if hub.ConnectedClients() != 1 {
|
||||
t.Errorf("ConnectedClients() = %d, want 1", hub.ConnectedClients())
|
||||
}
|
||||
|
||||
wc.close()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if hub.ConnectedClients() != 0 {
|
||||
t.Errorf("ConnectedClients() after close = %d, want 0", hub.ConnectedClients())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_BannedUser(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
mr.Set(context.Background(), "banned:testuser", "1", 0)
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
wc.skipSystem(2 * time.Second)
|
||||
wc.writeJSON(db.Message{Room: "main", Text: "hello"})
|
||||
|
||||
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if !strings.Contains(recvMsg.Text, "banned") {
|
||||
t.Errorf("expected banned message, got %q", recvMsg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_MutedUser(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
mr.Set(context.Background(), "muted:testuser", "1", 0)
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=testuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
wc.skipSystem(2 * time.Second)
|
||||
wc.writeJSON(db.Message{Room: "main", Text: "hello"})
|
||||
|
||||
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if !strings.Contains(recvMsg.Text, "muted") {
|
||||
t.Errorf("expected muted message, got %q", recvMsg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ModerationCommand(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=admin&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
wc.skipSystem(2 * time.Second)
|
||||
wc.writeJSON(db.Message{Room: "main", Text: "/mute baduser 5 spamming"})
|
||||
|
||||
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if !strings.Contains(recvMsg.Text, "mute") {
|
||||
t.Errorf("expected mute broadcast, got text=%s", recvMsg.Text)
|
||||
}
|
||||
|
||||
var count int64
|
||||
hub.DB.Model(&db.ModerationLog{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("moderation log count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_NonAdminModerationCommand(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=regularuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
wc.skipSystem(2 * time.Second)
|
||||
wc.writeJSON(db.Message{Room: "main", Text: "/mute baduser 5 spamming"})
|
||||
|
||||
recvMsg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("recvMsg error: %v", err)
|
||||
}
|
||||
if !strings.Contains(recvMsg.Text, "Only moderators") {
|
||||
t.Errorf("expected moderator-only message, got %q", recvMsg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_PrivateDM(t *testing.T) {
|
||||
hub, _, _ := setupTestHub(t)
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
wc1 := newWSClient(t, wsURL+"?username=alice&room=main")
|
||||
defer wc1.close()
|
||||
wc2 := newWSClient(t, wsURL+"?username=bob&room=main")
|
||||
defer wc2.close()
|
||||
|
||||
wc1.skipSystem(2 * time.Second)
|
||||
wc2.skipSystem(2 * time.Second)
|
||||
|
||||
wc1.writeJSON(db.Message{Room: "dm:bob", Text: "hey bob"})
|
||||
|
||||
recvMsg, err := wc2.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("bob recvMsg error: %v", err)
|
||||
}
|
||||
if recvMsg.Type != "private" {
|
||||
t.Errorf("expected private type, got %s", recvMsg.Type)
|
||||
}
|
||||
if recvMsg.Text != "hey bob" {
|
||||
t.Errorf("expected 'hey bob', got %q", recvMsg.Text)
|
||||
}
|
||||
|
||||
recvMsg2, err := wc1.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("alice recvMsg error: %v", err)
|
||||
}
|
||||
if recvMsg2.Type != "private" {
|
||||
t.Errorf("expected private confirmation, got %s", recvMsg2.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_PushChatHistory(t *testing.T) {
|
||||
hub, gormDB, _ := setupTestHub(t)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
gormDB.Create(&db.Message{
|
||||
Room: "main",
|
||||
Username: "user1",
|
||||
Text: "msg",
|
||||
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
|
||||
Type: "public",
|
||||
})
|
||||
}
|
||||
|
||||
srv := setupWSServer(hub)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?username=newuser&room=main"
|
||||
wc := newWSClient(t, wsURL)
|
||||
defer wc.close()
|
||||
|
||||
count := 0
|
||||
for i := 0; i < 10; i++ {
|
||||
msg, err := wc.recvMsg(2 * time.Second)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if msg.Type == "public" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 5 {
|
||||
t.Errorf("history message count = %d, want 5", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_MuteUser_UnmuteUser(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := hub.MuteUser(ctx, "user1", 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("MuteUser error = %v", err)
|
||||
}
|
||||
|
||||
exists, _ := mr.Exists(ctx, "muted:user1")
|
||||
if exists != 1 {
|
||||
t.Error("muted key not found after MuteUser")
|
||||
}
|
||||
|
||||
err = hub.UnmuteUser(ctx, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("UnmuteUser error = %v", err)
|
||||
}
|
||||
|
||||
exists, _ = mr.Exists(ctx, "muted:user1")
|
||||
if exists != 0 {
|
||||
t.Error("muted key still exists after UnmuteUser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_BanUser_UnbanUser(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := hub.BanUser(ctx, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("BanUser error = %v", err)
|
||||
}
|
||||
|
||||
exists, _ := mr.Exists(ctx, "banned:user1")
|
||||
if exists != 1 {
|
||||
t.Error("banned key not found after BanUser")
|
||||
}
|
||||
|
||||
err = hub.UnbanUser(ctx, "user1")
|
||||
if err != nil {
|
||||
t.Fatalf("UnbanUser error = %v", err)
|
||||
}
|
||||
|
||||
exists, _ = mr.Exists(ctx, "banned:user1")
|
||||
if exists != 0 {
|
||||
t.Error("banned key still exists after UnbanUser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ScanPresence(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mr.Set(ctx, "presence:main:user1", "online", 0)
|
||||
mr.Set(ctx, "presence:main:user2", "online", 0)
|
||||
mr.Set(ctx, "presence:lobby:user3", "online", 0)
|
||||
|
||||
presences, err := hub.ScanPresence(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanPresence error = %v", err)
|
||||
}
|
||||
if len(presences) != 3 {
|
||||
t.Errorf("ScanPresence len = %d, want 3", len(presences))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SubscribeRoomMessages(t *testing.T) {
|
||||
hub, _, mr := setupTestHub(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
hub.SubscribeRoomMessages()
|
||||
}()
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"instance": "other-instance",
|
||||
"msg": db.Message{
|
||||
Room: "main",
|
||||
Username: "remoteuser",
|
||||
Text: "remote hello",
|
||||
Timestamp: time.Now(),
|
||||
Type: "public",
|
||||
},
|
||||
})
|
||||
mr.Publish(ctx, "room:main:messages", string(payload))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
ownPayload, _ := json.Marshal(map[string]any{
|
||||
"instance": "test-instance",
|
||||
"msg": db.Message{
|
||||
Room: "main",
|
||||
Username: "localuser",
|
||||
Text: "local hello",
|
||||
Timestamp: time.Now(),
|
||||
Type: "public",
|
||||
},
|
||||
})
|
||||
mr.Publish(ctx, "room:main:messages", string(ownPayload))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
@@ -2,11 +2,10 @@ FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/ ./src/
|
||||
COPY src/go.mod src/go.sum ./
|
||||
|
||||
# Initialize Go module if not present
|
||||
RUN go mod init playout || true
|
||||
RUN go mod tidy
|
||||
|
||||
# Download dependencies and build
|
||||
RUN go mod download
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /playlist-manager ./src/playlist.go
|
||||
|
||||
FROM alpine:3.20
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
module playout
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.4
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.35
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect
|
||||
github.com/aws/smithy-go v1.27.6 // indirect
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE=
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0 h1:OkYV+1171za+ab9otU1tGxMXhx6uZvwVEtVddjLuYTg=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc=
|
||||
github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA=
|
||||
github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
@@ -28,6 +28,9 @@ type VideoFile struct {
|
||||
|
||||
var (
|
||||
r2Client *s3.Client
|
||||
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
return client.PutObject(context.TODO(), input)
|
||||
}
|
||||
bucket = os.Getenv("R2_BUCKET_NAME")
|
||||
playoutMode = "starting" // "live", "slate", "degraded"
|
||||
restartCount = 0
|
||||
@@ -39,6 +42,9 @@ var (
|
||||
const (
|
||||
maxRestartsPerMinute = 5
|
||||
slateManifestName = "playlist.m3u8"
|
||||
// Cache-Control header values for R2 uploads.
|
||||
segmentCacheControl = "public, max-age=31536000, immutable"
|
||||
manifestCacheControl = "public, max-age=2"
|
||||
)
|
||||
|
||||
// initR2Client initializes the R2 (S3-compatible) client
|
||||
@@ -203,7 +209,7 @@ func validateKeyframeInterval(videoPath string) error {
|
||||
"-v", "quiet",
|
||||
"-select_streams", "v:0",
|
||||
"-show_frames",
|
||||
"-show_entries", "frame=key_frame,pkt_pts_time",
|
||||
"-show_entries", "frame=key_frame,best_effort_timestamp_time,pkt_pts_time",
|
||||
"-of", "csv=p=0",
|
||||
videoPath)
|
||||
output, err := cmd.Output()
|
||||
@@ -223,8 +229,16 @@ func validateKeyframeInterval(videoPath string) error {
|
||||
if len(parts) < 2 || parts[0] != "1" {
|
||||
continue
|
||||
}
|
||||
pts, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
if err != nil {
|
||||
var pts float64
|
||||
var found bool
|
||||
for _, raw := range parts[1:] {
|
||||
if value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64); err == nil {
|
||||
pts = value
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
keyframes = append(keyframes, kfEntry{pts})
|
||||
@@ -284,7 +298,11 @@ func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
// SyncToR2 uploads a file to R2 and returns the key
|
||||
// SyncToR2 uploads a file to R2 and returns the key.
|
||||
// Cache-Control is set automatically based on file extension:
|
||||
// - .m4s, .ts, .mp4, .m4a -> immutable (1 year)
|
||||
// - .m3u8 -> short-lived (2s, live manifest)
|
||||
// - everything else -> no cache header
|
||||
func SyncToR2(localPath, r2Key string) error {
|
||||
if r2Client == nil {
|
||||
if err := initR2Client(); err != nil {
|
||||
@@ -298,11 +316,23 @@ func SyncToR2(localPath, r2Key string) error {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = r2Client.PutObject(context.TODO(), &s3.PutObjectInput{
|
||||
input := &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(r2Key),
|
||||
Body: file,
|
||||
})
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(r2Key))
|
||||
switch ext {
|
||||
case ".m4s", ".ts", ".mp4", ".m4a":
|
||||
input.CacheControl = aws.String(segmentCacheControl)
|
||||
input.ContentType = aws.String("video/MP2T")
|
||||
case ".m3u8":
|
||||
input.CacheControl = aws.String(manifestCacheControl)
|
||||
input.ContentType = aws.String("application/vnd.apple.mpegurl")
|
||||
}
|
||||
|
||||
_, err = putObject(r2Client, input)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
package main
|
||||
|
||||
// Test fixture generation commands (run from repository root):
|
||||
//
|
||||
// Valid 720p30 fixture (1.5s, H.264/AAC, yuv420p, 48000Hz, keyframe every 30 frames):
|
||||
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
|
||||
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 30 -keyint_min 30 \
|
||||
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||
// backend/playout/testdata/valid_720p30.mp4
|
||||
//
|
||||
// Invalid h265 fixture (1.5s, HEVC, otherwise compliant):
|
||||
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
|
||||
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||
// -c:v libx265 -preset ultrafast -crf 28 -pix_fmt yuv420p -g 30 -keyint_min 30 \
|
||||
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||
// -tag:v hvc1 backend/playout/testdata/invalid_h265.mp4
|
||||
//
|
||||
// Invalid 24fps fixture (1.5s, correct codec but wrong framerate):
|
||||
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=24 \
|
||||
// -f lavfi -i sine=frequency=440:duration=1.5 \
|
||||
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 24 -keyint_min 24 \
|
||||
// -c:a aac -ar 48000 -ac 2 -shortest \
|
||||
// backend/playout/testdata/invalid_24fps.mp4
|
||||
//
|
||||
// Test playlist fixtures are plain text files with paths relative to /app/media.
|
||||
// See backend/playout/testdata/*.txt.
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
func ensureFFProbeInPath(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := exec.LookPath("ffprobe"); err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
windowsFFProbe := `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffprobe.exe`
|
||||
if _, err := os.Stat(windowsFFProbe); err == nil {
|
||||
t.Setenv("PATH", filepath.Dir(windowsFFProbe)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return
|
||||
}
|
||||
|
||||
t.Skip("ffprobe not available on PATH and Windows WinGet install path not found")
|
||||
}
|
||||
|
||||
func TestLoadPlaylist(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
wantLen int
|
||||
wantErr bool
|
||||
first string
|
||||
}{
|
||||
{
|
||||
name: "valid playlist",
|
||||
file: "valid_playlist.txt",
|
||||
wantLen: 2,
|
||||
first: "/app/media/demo1.mp4",
|
||||
},
|
||||
{
|
||||
name: "empty playlist",
|
||||
file: "empty_playlist.txt",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "comments only",
|
||||
file: "comments_only.txt",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "mixed valid invalid",
|
||||
file: "mixed_valid_invalid.txt",
|
||||
wantLen: 3,
|
||||
first: "/app/media/demo1.mp4",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := filepath.Join("..", "testdata", tt.file)
|
||||
|
||||
videos, err := LoadPlaylist(path)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if len(videos) != tt.wantLen {
|
||||
t.Errorf("len = %d, want %d", len(videos), tt.wantLen)
|
||||
}
|
||||
if tt.wantLen > 0 && videos[0].Path != tt.first {
|
||||
t.Errorf("videos[0].Path = %q, want %q", videos[0].Path, tt.first)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPlaylist_FileNotFound(t *testing.T) {
|
||||
_, err := LoadPlaylist("/nonexistent/playlist.txt")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateKeyframeInterval(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
csvData string
|
||||
wantErr bool
|
||||
errText string
|
||||
}{
|
||||
{
|
||||
name: "valid keyframes (gaps <=2s)",
|
||||
csvData: "1,0.00\n0,0.50\n1,1.00\n0,1.50\n1,2.00\n0,2.50\n1,3.00\n",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "gap too large >2s",
|
||||
csvData: "1,0.00\n0,1.00\n1,3.50\n0,4.00\n1,6.00\n",
|
||||
wantErr: true,
|
||||
errText: "keyframe interval too large",
|
||||
},
|
||||
{
|
||||
name: "insufficient keyframes (<2)",
|
||||
csvData: "1,0.00\n",
|
||||
wantErr: true,
|
||||
errText: "insufficient keyframes",
|
||||
},
|
||||
{
|
||||
name: "empty output",
|
||||
csvData: "",
|
||||
wantErr: true,
|
||||
errText: "keyframe probe failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// create temp ffprobe that outputs csvData
|
||||
tmpDir := t.TempDir()
|
||||
mockProbe := filepath.Join(tmpDir, "ffprobe")
|
||||
var script string
|
||||
if tt.csvData == "" {
|
||||
script = "@echo off\necho\nexit /b 1\n"
|
||||
} else {
|
||||
script = "@echo off\necho " + strings.ReplaceAll(tt.csvData, "\n", "& echo ") + "\nexit /b 0\n"
|
||||
}
|
||||
os.WriteFile(mockProbe+".bat", []byte(script), 0755)
|
||||
os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
defer os.Unsetenv("PATH")
|
||||
|
||||
err := validateKeyframeInterval("test.mp4")
|
||||
if tt.wantErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if tt.wantErr && tt.errText != "" && err != nil {
|
||||
if !strings.Contains(err.Error(), tt.errText) {
|
||||
t.Errorf("error = %v, want containing %q", err, tt.errText)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVideoStrict(t *testing.T) {
|
||||
ensureFFProbeInPath(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
wantValid bool
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid h264 fixture",
|
||||
file: "valid_720p30.mp4",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "invalid h265 fixture",
|
||||
file: "invalid_h265.mp4",
|
||||
wantErr: "video codec must be h264",
|
||||
},
|
||||
{
|
||||
name: "invalid framerate fixture",
|
||||
file: "invalid_24fps.mp4",
|
||||
wantErr: "framerate must be 30/1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := filepath.Join("..", "testdata", tt.file)
|
||||
result := ValidateVideoStrict(path)
|
||||
|
||||
valid, _ := result["valid"].(bool)
|
||||
if valid != tt.wantValid {
|
||||
t.Fatalf("valid = %v, want %v", valid, tt.wantValid)
|
||||
}
|
||||
|
||||
errs, _ := result["errors"].([]string)
|
||||
if tt.wantErr == "" {
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("errors = %v, want none", errs)
|
||||
}
|
||||
if got, ok := result["duration_seconds"].(float64); !ok || got <= 0 {
|
||||
t.Fatalf("duration_seconds = %v, want positive duration", result["duration_seconds"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(strings.Join(errs, "; "), tt.wantErr) {
|
||||
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHandler(t *testing.T) {
|
||||
// Save and restore globals
|
||||
prevMode := playoutMode
|
||||
prevCount := restartCount
|
||||
playoutMode = "live"
|
||||
restartCount = 3
|
||||
defer func() {
|
||||
playoutMode = prevMode
|
||||
restartCount = prevCount
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
HealthHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"healthy"`) {
|
||||
t.Errorf("body missing healthy: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"playout"`) {
|
||||
t.Errorf("body missing service: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"restarts":3`) {
|
||||
t.Errorf("body missing restarts: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHandler_Degraded(t *testing.T) {
|
||||
prevMode := playoutMode
|
||||
playoutMode = "slate"
|
||||
defer func() { playoutMode = prevMode }()
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
HealthHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"degraded"`) {
|
||||
t.Errorf("body missing degraded: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldEscalateToSlate(t *testing.T) {
|
||||
// Save globals
|
||||
prevCount := restartCount
|
||||
prevWindow := restartWindow
|
||||
defer func() {
|
||||
restartCount = prevCount
|
||||
restartWindow = prevWindow
|
||||
}()
|
||||
|
||||
// Under threshold
|
||||
restartWindow = time.Now()
|
||||
restartCount = 3
|
||||
if shouldEscalateToSlate() {
|
||||
t.Error("shouldEscalateToSlate() = true, want false (under threshold)")
|
||||
}
|
||||
|
||||
// At threshold
|
||||
restartCount = maxRestartsPerMinute
|
||||
if !shouldEscalateToSlate() {
|
||||
t.Error("shouldEscalateToSlate() = false, want true (at threshold)")
|
||||
}
|
||||
|
||||
// Over threshold
|
||||
restartCount = 7
|
||||
if !shouldEscalateToSlate() {
|
||||
t.Error("shouldEscalateToSlate() = false, want true (over threshold)")
|
||||
}
|
||||
|
||||
// Window expired
|
||||
restartWindow = time.Now().Add(-61 * time.Second)
|
||||
restartCount = 10
|
||||
if shouldEscalateToSlate() {
|
||||
t.Error("shouldEscalateToSlate() = true, want false (window expired, should reset)")
|
||||
}
|
||||
if restartCount != 0 {
|
||||
t.Errorf("restartCount after window reset = %d, want 0", restartCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetHLSOutput(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create some segment files
|
||||
os.MkdirAll(tmpDir, 0755)
|
||||
os.WriteFile(filepath.Join(tmpDir, "segment_test.ts"), []byte("data"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "segment_test.m4s"), []byte("data"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "playlist.m3u8"), []byte("#EXTM3U"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "init.mp4"), []byte("init"), 0644)
|
||||
|
||||
err := resetHLSOutput(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("resetHLSOutput error: %v", err)
|
||||
}
|
||||
|
||||
// Verify all cleaned
|
||||
for _, name := range []string{"segment_test.ts", "segment_test.m4s", "playlist.m3u8", "init.mp4"} {
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("file %s still exists after reset", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetHLSOutput_EmptyDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
err := resetHLSOutput(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("resetHLSOutput on empty dir error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateSlate_NoSlate(t *testing.T) {
|
||||
prevSlateDir := slateDir
|
||||
prevHLSDir := hlsDir
|
||||
prevMode := playoutMode
|
||||
tmpDir := t.TempDir()
|
||||
slateDir = filepath.Join(tmpDir, "slate")
|
||||
hlsDir = filepath.Join(tmpDir, "hls")
|
||||
os.MkdirAll(hlsDir, 0755)
|
||||
defer func() {
|
||||
slateDir = prevSlateDir
|
||||
hlsDir = prevHLSDir
|
||||
playoutMode = prevMode
|
||||
}()
|
||||
|
||||
err := activateSlate()
|
||||
if err == nil {
|
||||
t.Error("expected error when slate HLS missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateSlate_Success(t *testing.T) {
|
||||
prevSlateDir := slateDir
|
||||
prevHLSDir := hlsDir
|
||||
prevMode := playoutMode
|
||||
tmpDir := t.TempDir()
|
||||
slateDir = filepath.Join(tmpDir, "slate")
|
||||
hlsDir = filepath.Join(tmpDir, "hls")
|
||||
|
||||
// Create slate HLS structure
|
||||
slateHLS := filepath.Join(slateDir, "hls")
|
||||
os.MkdirAll(slateHLS, 0755)
|
||||
os.MkdirAll(hlsDir, 0755)
|
||||
os.WriteFile(filepath.Join(slateHLS, "slate.m3u8"), []byte("#EXTM3U\n#EXT-X-ENDLIST\n"), 0644)
|
||||
os.WriteFile(filepath.Join(slateHLS, "slate_segment.ts"), []byte("slate-data"), 0644)
|
||||
|
||||
defer func() {
|
||||
slateDir = prevSlateDir
|
||||
hlsDir = prevHLSDir
|
||||
playoutMode = prevMode
|
||||
}()
|
||||
|
||||
err := activateSlate()
|
||||
if err != nil {
|
||||
t.Fatalf("activateSlate error: %v", err)
|
||||
}
|
||||
|
||||
if playoutMode != "slate" {
|
||||
t.Errorf("playoutMode = %q, want slate", playoutMode)
|
||||
}
|
||||
|
||||
// Verify files copied
|
||||
if _, err := os.Stat(filepath.Join(hlsDir, "slate.m3u8")); os.IsNotExist(err) {
|
||||
t.Error("slate.m3u8 not copied to hls dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeactivateSlate(t *testing.T) {
|
||||
prevMode := playoutMode
|
||||
playoutMode = "slate"
|
||||
defer func() { playoutMode = prevMode }()
|
||||
|
||||
deactivateSlate()
|
||||
|
||||
if playoutMode != "live" {
|
||||
t.Errorf("playoutMode = %q, want live", playoutMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePlaylist(t *testing.T) {
|
||||
// ValidatePlaylist calls ValidateVideo which needs ffprobe
|
||||
t.Skip("requires ffprobe mock or test media fixtures")
|
||||
}
|
||||
|
||||
func TestSyncToR2(t *testing.T) {
|
||||
prevClient := r2Client
|
||||
prevBucket := bucket
|
||||
prevPutObject := putObject
|
||||
defer func() {
|
||||
r2Client = prevClient
|
||||
bucket = prevBucket
|
||||
putObject = prevPutObject
|
||||
}()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
localPath := filepath.Join(tmpDir, "segment.m4s")
|
||||
if err := os.WriteFile(localPath, []byte("segment-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
bucket = "linearvoid-media"
|
||||
r2Client = &s3.Client{}
|
||||
var gotBucket, gotKey, gotCacheControl, gotContentType string
|
||||
var gotBody []byte
|
||||
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
gotBucket = aws.ToString(input.Bucket)
|
||||
gotKey = aws.ToString(input.Key)
|
||||
gotCacheControl = aws.ToString(input.CacheControl)
|
||||
gotContentType = aws.ToString(input.ContentType)
|
||||
body, err := io.ReadAll(input.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gotBody = body
|
||||
return &s3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
if err := SyncToR2(localPath, "live/segment.m4s"); err != nil {
|
||||
t.Fatalf("SyncToR2 error: %v", err)
|
||||
}
|
||||
|
||||
if gotBucket != "linearvoid-media" {
|
||||
t.Fatalf("bucket = %q, want %q", gotBucket, "linearvoid-media")
|
||||
}
|
||||
if gotKey != "live/segment.m4s" {
|
||||
t.Fatalf("key = %q, want %q", gotKey, "live/segment.m4s")
|
||||
}
|
||||
if string(gotBody) != "segment-bytes" {
|
||||
t.Fatalf("body = %q, want %q", string(gotBody), "segment-bytes")
|
||||
}
|
||||
if gotCacheControl != segmentCacheControl {
|
||||
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, segmentCacheControl)
|
||||
}
|
||||
if gotContentType != "video/MP2T" {
|
||||
t.Fatalf("ContentType = %q, want %q", gotContentType, "video/MP2T")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncToR2_ManifestCacheHeaders(t *testing.T) {
|
||||
prevClient := r2Client
|
||||
prevBucket := bucket
|
||||
prevPutObject := putObject
|
||||
defer func() {
|
||||
r2Client = prevClient
|
||||
bucket = prevBucket
|
||||
putObject = prevPutObject
|
||||
}()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
localPath := filepath.Join(tmpDir, "playlist.m3u8")
|
||||
if err := os.WriteFile(localPath, []byte("#EXTM3U\n"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
bucket = "linearvoid-media"
|
||||
r2Client = &s3.Client{}
|
||||
var gotCacheControl, gotContentType string
|
||||
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
gotCacheControl = aws.ToString(input.CacheControl)
|
||||
gotContentType = aws.ToString(input.ContentType)
|
||||
return &s3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
if err := SyncToR2(localPath, "hls/playlist.m3u8"); err != nil {
|
||||
t.Fatalf("SyncToR2 error: %v", err)
|
||||
}
|
||||
|
||||
if gotCacheControl != manifestCacheControl {
|
||||
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, manifestCacheControl)
|
||||
}
|
||||
if gotContentType != "application/vnd.apple.mpegurl" {
|
||||
t.Fatalf("ContentType = %q, want %q", gotContentType, "application/vnd.apple.mpegurl")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
|
||||
prevClient := r2Client
|
||||
prevBucket := bucket
|
||||
prevPutObject := putObject
|
||||
defer func() {
|
||||
r2Client = prevClient
|
||||
bucket = prevBucket
|
||||
putObject = prevPutObject
|
||||
}()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
localPath := filepath.Join(tmpDir, "data.bin")
|
||||
if err := os.WriteFile(localPath, []byte("raw"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
bucket = "linearvoid-media"
|
||||
r2Client = &s3.Client{}
|
||||
var gotCacheControl, gotContentType string
|
||||
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
gotCacheControl = aws.ToString(input.CacheControl)
|
||||
gotContentType = aws.ToString(input.ContentType)
|
||||
return &s3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
if err := SyncToR2(localPath, "misc/data.bin"); err != nil {
|
||||
t.Fatalf("SyncToR2 error: %v", err)
|
||||
}
|
||||
|
||||
if gotCacheControl != "" {
|
||||
t.Fatalf("CacheControl = %q, want empty", gotCacheControl)
|
||||
}
|
||||
if gotContentType != "" {
|
||||
t.Fatalf("ContentType = %q, want empty", gotContentType)
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# Linear Void test playlist
|
||||
# No media entries here
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
# Valid entries should still parse
|
||||
file '/app/media/demo1.mp4'
|
||||
|
||||
# Invalid or malformed lines are preserved as literal paths by the parser
|
||||
not-a-file-entry
|
||||
file '/app/media/demo2.mp4'
|
||||
BIN
Binary file not shown.
+2
@@ -0,0 +1,2 @@
|
||||
file '/app/media/demo1.mp4'
|
||||
file '/app/media/demo2.mp4'
|
||||
@@ -180,8 +180,7 @@ services:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
|
||||
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources
|
||||
- grafana-provisioning:/etc/grafana/provisioning
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
||||
@@ -201,3 +200,4 @@ volumes:
|
||||
prometheus-config:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
grafana-provisioning:
|
||||
File renamed without changes.
@@ -8,6 +8,7 @@
|
||||
- [Production Target (Hetzner)](#production-target-hetzner)
|
||||
- [Planned Infrastructure](#planned-infrastructure)
|
||||
- [Cutover Plan](#cutover-plan)
|
||||
- [Production Preflight](#production-preflight)
|
||||
- [Firewall / Security Group](#firewall--security-group)
|
||||
- [Pipeline Readiness](#pipeline-readiness)
|
||||
- [Target Topology](#target-topology)
|
||||
@@ -64,6 +65,19 @@ Linear Void runs in two separate environments:
|
||||
6. Enable HTTPS, WAF, cache rules on production zone
|
||||
7. Decommission test lab as public target
|
||||
|
||||
### Production Preflight
|
||||
|
||||
Before the cutover in the plan above, treat the following as the minimum readiness gate:
|
||||
|
||||
1. Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner.
|
||||
2. Confirm all required production secrets are present under the `CI_PROD_*` prefix.
|
||||
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv` DNS targets are correct for the intended phase.
|
||||
4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
||||
5. Run the production smoke path end to end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, and playout health.
|
||||
6. Verify rollback is still possible before the final DNS switch.
|
||||
|
||||
Readiness is not complete until the checklist above and the manual evidence tracker are both satisfied.
|
||||
|
||||
### Firewall / Security Group
|
||||
|
||||
| Port | Source | Purpose |
|
||||
@@ -135,7 +149,7 @@ See .env.example and SETUP.md for full variable reference.
|
||||
|
||||
## Pull-and-Run Deployment
|
||||
|
||||
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning-and-releases) for the release procedure).
|
||||
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](../CONTRIBUTING.md#versioning-and-releases) for the release procedure).
|
||||
|
||||
Every push to `main` triggers CI (builds `:main` and `:latest` tags) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow which builds `:X.Y.Z`, `:X.Y`, and `:latest` tags and creates a Gitea Release.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# DEVELOPMENT.md — Linear Void Remaining Tasks
|
||||
|
||||
## Test Fixtures (COMPLETE)
|
||||
|
||||
- [x] Create test media fixtures in `backend/playout/testdata/`:
|
||||
- `valid_720p30.mp4` — small (1-2s) H.264/AAC compliant file
|
||||
- `invalid_h265.mp4` — wrong codec
|
||||
- `invalid_24fps.mp4` — wrong framerate
|
||||
- Generate via `ffmpeg` commands documented in test file headers
|
||||
- Windows dev path: `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffmpeg.exe`
|
||||
|
||||
- [x] Create test playlist fixtures in `backend/playout/testdata/`:
|
||||
- `valid_playlist.txt`, `empty_playlist.txt`, `comments_only.txt`, `mixed_valid_invalid.txt`
|
||||
|
||||
- [x] Document ffmpeg fixture-generation commands in `playlist_test.go` header
|
||||
|
||||
## User Frontend Tests (PARTIALLY COMPLETE)
|
||||
|
||||
- [x] Video player behavior (play, pause, seek, buffering, error handling) - HLS player startup, native/fallback, URL resolution covered
|
||||
- [x] Chat functionality (send/receive messages, presence, rate limiting, moderation) - send, receive, presence join/leave, DM flow, reconnect covered
|
||||
- [ ] State management and data flow
|
||||
- [ ] Responsive design and layout across different screen sizes
|
||||
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
||||
- [x] Integration with backend APIs (mocking API responses for testing)
|
||||
- [ ] Test coverage for all critical frontend components and interactions
|
||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||
- [ ] Document test cases and expected outcomes
|
||||
|
||||
## Admin Frontend Tests (PARTIALLY COMPLETE)
|
||||
|
||||
- [x] Playlist management UI (add/remove videos, reorder, normalize) - save/error handling, sortable rendering
|
||||
- [ ] User moderation tools (mute/ban/report users, view moderation logs)
|
||||
- [x] Metrics and monitoring dashboards (Prometheus/Grafana integration) - overview + analytics rendering
|
||||
- [ ] State management and data flow
|
||||
- [ ] Responsive design and layout across different screen sizes
|
||||
- [ ] Accessibility testing (keyboard navigation, screen reader support)
|
||||
- [x] Integration with backend APIs (mocking API responses for testing)
|
||||
- [ ] Test coverage for all critical admin UI components and interactions
|
||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||
- [ ] Document test cases and expected outcomes
|
||||
|
||||
## Cloudflare R2 Integration Test (PARTIALLY COMPLETE)
|
||||
|
||||
- [x] Upload test HLS segments and manifests to R2 bucket (SyncToR2 with mock, verified bucket/key/body)
|
||||
- [x] Verify correct cache headers are applied (immutable segments via segmentCacheControl, short-lived manifests via manifestCacheControl)
|
||||
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
||||
- [ ] Validate cache hit/miss behavior and performance metrics
|
||||
- [x] Ensure integration test can run in CI/CD pipeline with proper mocking/stubbing of R2 API (putObject mock, no network calls)
|
||||
- [x] Document test cases and expected outcomes (TestSyncToR2 + TestSyncToR2_ManifestCacheHeaders + TestSyncToR2_NoCacheHeadersForUnknownExt)
|
||||
- [x] Document required stubs or mocks (putObject variable injection pattern)
|
||||
|
||||
## Cloudflare Configuration (manual — Cloudflare dashboard)
|
||||
|
||||
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||
|
||||
- [ ] Cloudflare Pages: link Gitea repo, build `cd frontend && npm run build`, output `frontend/dist`
|
||||
- [ ] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier
|
||||
- [ ] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode
|
||||
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||
- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2
|
||||
- [ ] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS
|
||||
|
||||
## External Services
|
||||
|
||||
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||
|
||||
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
||||
- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
||||
|
||||
## Production Deployment (Hetzner — not provisioned)
|
||||
|
||||
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04
|
||||
- [ ] Install Docker Engine + Compose plugin, open firewall: 80/443 (public), 2375 (runner IP only)
|
||||
- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs)
|
||||
- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories
|
||||
- [ ] Add `CI_PROD_*` secrets to Gitea: DOCKER_HOST, R2 keys, admin password, DB password, Grafana password
|
||||
- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP
|
||||
- [ ] Verify all 11 containers start healthy, check `/health`, `/hls/playlist.m3u8`, WebSocket
|
||||
- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict), test HTTPS + WSS
|
||||
- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
|
||||
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
|
||||
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
|
||||
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
|
||||
|
||||
## Scaling & Performance (planning only)
|
||||
|
||||
- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration
|
||||
- [ ] Measure TTFB: WebPageTest from 3+ US regions, target <4s warm cache, document baseline
|
||||
- [ ] Load-test WebSocket: `artillery` or Go script, ramp 10→100→500→1000 connections, measure p95 latency + errors (scaffolding complete; benchmark execution pending)
|
||||
- [ ] Benchmark admin API: `wrk`/`k6` against `/api/admin/*`, target p95 <1s, identify slow queries (scaffolding complete; benchmark execution pending)
|
||||
- [ ] Track cache hit ratio: Cloudflare GraphQL → Grafana panel, alert <90%
|
||||
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, chat p95 >1s/5min, CPU >80%/10min, Redis memory >85%/10min
|
||||
- [ ] Phase 2 plan: document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources
|
||||
- [ ] Phase 3 plan: research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
|
||||
File renamed without changes.
@@ -0,0 +1,41 @@
|
||||
# Docs Folder
|
||||
|
||||
This folder contains the documentation for the Linear Void project. The documentation is organized into several markdown files, each covering different aspects of the project.
|
||||
|
||||
## Architecture
|
||||
|
||||
- [Architecture](architecture/README.md) - High-level system architecture and component interactions.
|
||||
|
||||
## API
|
||||
|
||||
- [API](API.md) - Implemented WebSocket and REST API reference.
|
||||
|
||||
## Deployment
|
||||
|
||||
- [Deployment](DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||
|
||||
## Monitoring
|
||||
|
||||
- [Monitoring](MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||
|
||||
## Security
|
||||
|
||||
- [Security](SECURITY.md) - Security model, controls, and hardening baseline.
|
||||
|
||||
## Setup
|
||||
|
||||
- [Setup](SETUP.md) - Test lab setup, local development, and CI/CD workflow.
|
||||
|
||||
## Streaming
|
||||
|
||||
- [Streaming](STREAMING.md) - Streaming architecture, playout, and delivery details.
|
||||
|
||||
## Development
|
||||
|
||||
- [Development](DEVELOPMENT.md) - Remaining tasks and test fixtures for development.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Marketing](marketing/README.md) - Marketing materials and resources.
|
||||
- [Operations](operations/README.md) - Operational procedures and checklists.
|
||||
- [Incident Runbook](incident-runbook.md) - Incident response and disaster recovery procedures.
|
||||
File renamed without changes.
+155
-34
@@ -162,39 +162,6 @@ flowchart LR
|
||||
- Place normalized MP4 files in `data/media/`.
|
||||
- Create a `data/playlist/playlist.txt` with paths (see ADR-001 for normalization requirements).
|
||||
|
||||
## Cloudflare R2 Setup
|
||||
|
||||
1. **Create an R2 bucket**:
|
||||
- Create a bucket such as `linearvoid-media` in Cloudflare R2.
|
||||
- Keep the bucket name aligned with `R2_BUCKET_NAME` in `.env`.
|
||||
|
||||
2. **Generate R2 API credentials**:
|
||||
- Create an access key pair with read/write permissions for the bucket.
|
||||
- Set `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_REGION=auto` in `.env`.
|
||||
|
||||
3. **Find the S3 endpoint**:
|
||||
- Use the account-scoped endpoint format `https://<account-id>.r2.cloudflarestorage.com`.
|
||||
- Store the account ID in both `R2_ENDPOINT` and `CLOUDFLARE_ACCOUNT_ID` where applicable.
|
||||
|
||||
4. **Bind a custom media domain**:
|
||||
- Attach a domain such as `media.linearvoid.tv` to the bucket.
|
||||
- Set `MEDIA_DOMAIN` to that hostname.
|
||||
|
||||
5. **Configure app-domain DNS and TLS**:
|
||||
- Point `linearvoid.tv` to the public entrypoint serving nginx.
|
||||
- Point `www.linearvoid.tv` to the same target and redirect it to `https://linearvoid.tv`.
|
||||
- Keep `media.linearvoid.tv` on the R2 custom domain path so HLS delivery stays split from the app origin.
|
||||
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
|
||||
|
||||
6. **Apply cache behavior**:
|
||||
- Configure the `.ts` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
||||
- 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.
|
||||
|
||||
7. **Verify access**:
|
||||
- Confirm uploads with an S3-compatible client such as `aws s3 ls --endpoint-url <R2_ENDPOINT>`.
|
||||
- After deployment, check Cloudflare analytics for cache hit ratio and origin request volume.
|
||||
|
||||
## Gitea Setup
|
||||
|
||||
1. **Create or verify a Gitea user**:
|
||||
@@ -231,6 +198,160 @@ flowchart LR
|
||||
7. **Validate image availability**:
|
||||
- Before a production rollout, verify that the expected `:latest` tags can be pulled from the registry on the target server.
|
||||
|
||||
## Cloudflare R2 Setup
|
||||
|
||||
1. **Create an R2 bucket**:
|
||||
- Create a bucket such as `linearvoid-media` in Cloudflare R2.
|
||||
- Keep the bucket name aligned with `R2_BUCKET_NAME` in `.env`.
|
||||
|
||||
2. **Generate R2 API credentials**:
|
||||
- Create an access key pair with read/write permissions for the bucket.
|
||||
- Set `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_REGION=auto` in `.env`.
|
||||
|
||||
3. **Find the S3 endpoint**:
|
||||
- Use the account-scoped endpoint format `https://<account-id>.r2.cloudflarestorage.com`.
|
||||
- Store the account ID in both `R2_ENDPOINT` and `CLOUDFLARE_ACCOUNT_ID` where applicable.
|
||||
|
||||
4. **Bind a custom media domain**:
|
||||
- Attach a domain such as `media.linearvoid.tv` to the bucket.
|
||||
- Set `MEDIA_DOMAIN` to that hostname.
|
||||
|
||||
5. **Configure app-domain DNS and TLS**:
|
||||
- Point `linearvoid.tv` to the public entrypoint serving nginx.
|
||||
- Point `www.linearvoid.tv` to the same target and redirect it to `https://linearvoid.tv`.
|
||||
- Keep `media.linearvoid.tv` on the R2 custom domain path so HLS delivery stays split from the app origin.
|
||||
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
|
||||
|
||||
6. **Apply cache behavior**:
|
||||
- Configure the `.m4s` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
||||
- Enable Smart Tiered Cache for the R2-backed hostname.
|
||||
- If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone.
|
||||
|
||||
7. **Verify access**:
|
||||
- Confirm uploads with an S3-compatible client such as `aws s3 ls --endpoint-url <R2_ENDPOINT>`.
|
||||
- After deployment, check Cloudflare analytics for cache hit ratio and origin request volume.
|
||||
|
||||
## Cloudflare
|
||||
|
||||
Use this section for full edge setup (DNS, Pages, cache, security, and monitoring).
|
||||
|
||||
Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
||||
|
||||
### 1. DNS Setup
|
||||
|
||||
1. Add apex record for `linearvoid.tv` pointing to Cloudflare Pages target.
|
||||
2. Add `www` CNAME to Cloudflare Pages and configure redirect to `https://linearvoid.tv`.
|
||||
3. Add `media.linearvoid.tv` for split-domain HLS delivery.
|
||||
4. Keep all records proxied (orange cloud) unless troubleshooting requires DNS-only mode.
|
||||
|
||||
### 2. R2 Bucket
|
||||
|
||||
1. Create bucket `linearvoid-media`.
|
||||
2. Set location hint to `enam` (US-East).
|
||||
3. Attach custom domain `media.linearvoid.tv` to the bucket.
|
||||
4. Verify object read works from the custom domain.
|
||||
|
||||
### 3. Cache Rules
|
||||
|
||||
Configure Cloudflare Cache Rules:
|
||||
|
||||
1. HLS segment files (`*.ts`, `*.m4s`):
|
||||
- Cache: eligible
|
||||
- Edge TTL: `31536000`
|
||||
- Browser TTL: long-lived
|
||||
- Cache key: include full path
|
||||
2. HLS manifests (`*.m3u8`):
|
||||
- Cache: eligible
|
||||
- Edge TTL: `2s`
|
||||
- Revalidation: enabled
|
||||
3. Static assets (`*.js`, `*.css`):
|
||||
- Cache: eligible
|
||||
- Edge TTL: `1y`
|
||||
- Browser TTL: long-lived
|
||||
- Cache key: include full path
|
||||
4. Validate behavior using response headers:
|
||||
- `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
|
||||
|
||||
Reference: `infrastructure/cloudflare/cache-rules.md`.
|
||||
|
||||
### 4. Cloudflare Pages
|
||||
|
||||
1. Connect Gitea repository to Cloudflare Pages.
|
||||
2. Build command:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
3. Output directory:
|
||||
|
||||
```text
|
||||
frontend/dist
|
||||
```
|
||||
|
||||
4. Configure production domain to `linearvoid.tv`.
|
||||
|
||||
### 5. Tiered Cache
|
||||
|
||||
1. Enable Smart Tiered Cache in Cloudflare dashboard.
|
||||
2. Warm a segment and manifest path.
|
||||
3. Confirm tiered behavior through repeated requests and `Cf-Cache-Status: HIT`.
|
||||
|
||||
### 6. WAF and Bot Protection
|
||||
|
||||
1. Enable OWASP Managed Ruleset.
|
||||
2. Start in `Simulate` mode.
|
||||
3. Review events for 48 hours.
|
||||
4. Switch to `Block` once false positives are addressed.
|
||||
5. Enable Bot Fight Mode.
|
||||
|
||||
### 7. Rate Limiting
|
||||
|
||||
Create rules:
|
||||
|
||||
1. Path: `/api/admin/login`
|
||||
2. Path: `/api/admin/*`
|
||||
3. Threshold: `10 requests / 10 seconds / IP`
|
||||
4. Action: `Managed Challenge`
|
||||
|
||||
### 8. HTTPS and WSS
|
||||
|
||||
1. Enable `Always Use HTTPS`.
|
||||
2. Enable HSTS with:
|
||||
- `max-age=31536000`
|
||||
- `includeSubDomains`
|
||||
- `preload`
|
||||
3. Minimum TLS version: `1.2`.
|
||||
4. Keep SSL mode at `Full (strict)` when origin certs are valid.
|
||||
5. Enforce HTTPS redirect at both layers:
|
||||
- Cloudflare edge
|
||||
- nginx origin
|
||||
6. Validate websocket endpoint uses `wss://linearvoid.tv/ws`.
|
||||
|
||||
### 9. UptimeRobot Monitors
|
||||
|
||||
Create external monitors:
|
||||
|
||||
1. HTTP monitor:
|
||||
- URL: `https://linearvoid.tv/health`
|
||||
- Interval: 5 minutes
|
||||
2. Keyword monitor:
|
||||
- URL: `https://linearvoid.tv/hls/playlist.m3u8`
|
||||
- Keyword: `#EXTM3U`
|
||||
- Interval: 5 minutes
|
||||
3. Alert policy:
|
||||
- Trigger on 2 consecutive failures
|
||||
- Notify via email and Discord/Slack webhook
|
||||
|
||||
### 10. Verification Checklist
|
||||
|
||||
1. DNS resolves for `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv`.
|
||||
2. Pages serves frontend for apex and `www` redirects correctly.
|
||||
3. HLS segments are cacheable long-term, manifests refresh every ~2s.
|
||||
4. Admin paths are rate-limited and WAF events are visible.
|
||||
5. HTTPS and WSS both succeed with valid certificates.
|
||||
6. UptimeRobot alerts fire as expected when endpoints are intentionally made unavailable.
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Local Development
|
||||
@@ -450,7 +571,7 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs
|
||||
- **R2 Upload Fails**: Check credentials in `.env` and ensure the bucket exists. Verify with `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
|
||||
- **Normalization Errors**: Run `backend/playout/start-playout.sh` manually to see ffprobe output. All videos must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001).
|
||||
- **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
|
||||
- **Cache Not Working**: Verify Cloudflare cache rules (`.ts` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
|
||||
- **Cache Not Working**: Verify Cloudflare cache rules (`.m4s` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
|
||||
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
|
||||
- **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow.
|
||||
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Streaming Pipeline
|
||||
|
||||
This document is the operational contract for Linear Void video ingest, playout, publication, caching, and browser playback. General deployment, chat, and admin details remain in `DEPLOYMENT.md`, `SETUP.md`, and `API.md`.
|
||||
|
||||
## Production flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Normalized MP4 assets"] --> B["Playlist and schedule"]
|
||||
B --> C["Playout service and FFmpeg"]
|
||||
C --> D["Local HLS staging"]
|
||||
D --> E["R2 object storage"]
|
||||
E --> F["Cloudflare edge"]
|
||||
F --> G["hls.js or native HLS"]
|
||||
```
|
||||
|
||||
The playout service is the timeline authority. It reads the ordered playlist, runs FFmpeg continuously, stages a live HLS window under `data/hls/`, and publishes complete objects to R2. Viewers read only the public R2 custom domain through Cloudflare:
|
||||
|
||||
```text
|
||||
https://media.linearvoid.tv/hls/playlist.m3u8
|
||||
```
|
||||
|
||||
Nginx `/hls/` is a local/test fallback, not the production media origin. The SPA, admin API, and chat use `linearvoid.tv`; media uses `media.linearvoid.tv`.
|
||||
|
||||
## Source-media contract
|
||||
|
||||
Only validated files may enter the production playlist.
|
||||
|
||||
| Property | Required profile |
|
||||
| ---------- | ------------------------------------------------------------------------ |
|
||||
| Container | MP4 |
|
||||
| Video | H.264/AVC, `yuv420p` |
|
||||
| Resolution | 1280x720 or 1920x1080; avoid changing resolution within a playlist block |
|
||||
| Frame rate | Constant 30 fps |
|
||||
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
|
||||
| Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s |
|
||||
|
||||
The ingest gate uses `ffprobe` to reject incompatible codecs, variable frame rate, missing audio, invalid dimensions, or incorrect keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID and object key.
|
||||
|
||||
Reference normalization:
|
||||
|
||||
```bash
|
||||
ffmpeg -i input.mov \
|
||||
-c:v libx264 -preset medium -crf 21 \
|
||||
-r 30 -g 60 -keyint_min 60 -sc_threshold 0 \
|
||||
-pix_fmt yuv420p \
|
||||
-c:a aac -b:a 160k -ar 48000 -ac 2 \
|
||||
-movflags +faststart \
|
||||
output-normalized.mp4
|
||||
```
|
||||
|
||||
## Playlist and timeline
|
||||
|
||||
- `data/playlist/playlist.txt` contains one local media path per line; blank lines and `#` comments are ignored.
|
||||
- The service validates the complete playlist before activating a revision.
|
||||
- Playlist edits are written atomically and receive a revision ID. The active FFmpeg run keeps its current item and adopts the new revision at the next item boundary.
|
||||
- All viewers follow the same live manifest; clients do not calculate the schedule independently.
|
||||
- Schedule metadata must be derived from the same active playlist revision and timeline so “now/next” agrees with the video.
|
||||
- When the last item ends, playout returns to the first item unless an active schedule override says otherwise.
|
||||
|
||||
## Playout and HLS packaging
|
||||
|
||||
The playout service wraps FFmpeg, tracks the active item and playlist revision, restarts failed processes, and exposes `/health` plus Prometheus metrics.
|
||||
|
||||
Baseline packaging:
|
||||
|
||||
| Setting | Value |
|
||||
| ---------------- | ------------------------------------------------------------------------------------- |
|
||||
| Protocol | Standard live HLS, not LL-HLS |
|
||||
| Segment format | fMP4 (`.m4s`) with an initialization segment; `.ts` may be supported during migration |
|
||||
| Segment target | 6 seconds (`HLS_SEGMENT_DURATION=6`) |
|
||||
| Live window | 24 segments (`HLS_WINDOW_SIZE=24`), about 144 seconds |
|
||||
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
||||
| Expected latency | 15–35 seconds |
|
||||
|
||||
Segment boundaries must align with keyframes. Timestamps must remain monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new initialization segment.
|
||||
|
||||
Every segment name is globally unique and never reused, including after a restart or playlist loop. Use a run/session ID plus a monotonically increasing media sequence, for example:
|
||||
|
||||
```text
|
||||
hls/r-20260807T170412Z/init-000001.mp4
|
||||
hls/r-20260807T170412Z/seg-000001234.m4s
|
||||
```
|
||||
|
||||
This naming rule is essential: a URL advertised as immutable must never later contain different bytes.
|
||||
|
||||
## Safe publication to R2
|
||||
|
||||
Publication order prevents a manifest from referencing an unavailable segment:
|
||||
|
||||
1. FFmpeg writes a segment to a temporary local name and closes it.
|
||||
2. The publisher uploads the initialization segment, if new, and media segment to their final immutable R2 keys.
|
||||
3. It verifies successful upload and records object size/checksum.
|
||||
4. It renders the next media manifest referencing only confirmed objects.
|
||||
5. It uploads the manifest to a temporary key, then replaces `hls/playlist.m3u8` as the final commit step.
|
||||
|
||||
Never upload an actively written segment or publish the manifest first. Use a single publisher instance, or fencing/leader election, so two playout workers cannot update the live manifest concurrently.
|
||||
|
||||
The local `data/hls/` directory is staging data and may be regenerated. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
|
||||
|
||||
## Cache contract
|
||||
|
||||
| Objects | Edge policy | Browser policy | Required headers/behavior |
|
||||
| -------------------------- | ---------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `.m4s`, `.ts`, init `.mp4` | Cache eligible; TTL 1 year | TTL 1 year | `Cache-Control: public, max-age=31536000, immutable`; full path in cache key |
|
||||
| Live `.m3u8` | Cache eligible; edge TTL 2 seconds | Do not retain beyond freshness interval | `Cache-Control: public, max-age=1, s-maxage=2, must-revalidate` |
|
||||
| Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
|
||||
|
||||
Further rules:
|
||||
|
||||
- Do not add query-string cache busters to segment URLs; uniqueness belongs in the path.
|
||||
- Cache keys must preserve hostname and full path. Ignore only explicitly approved tracking parameters.
|
||||
- HLS responses require `Access-Control-Allow-Origin: https://linearvoid.tv` (or the reviewed origin allowlist), `GET`, `HEAD`, and `OPTIONS` support, and correct MIME types.
|
||||
- Enable Smart Tiered Cache for `media.linearvoid.tv` to reduce R2 reads on regional misses.
|
||||
- Purge manifests when necessary, but never purge immutable segments during normal operation.
|
||||
- Retain expired-window segments in R2 for at least the maximum manifest TTL plus client retry/grace time. A practical minimum is one hour; lifecycle deletion may run later according to storage policy.
|
||||
|
||||
Expected validation:
|
||||
|
||||
```bash
|
||||
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
||||
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
|
||||
```
|
||||
|
||||
The manifest must refresh within a few seconds. Repeated segment requests should progress from `CF-Cache-Status: MISS` to `HIT`, retain the same ETag/content length, and carry the immutable cache header.
|
||||
|
||||
## Player behavior
|
||||
|
||||
The SPA uses native HLS where supported and hls.js elsewhere.
|
||||
|
||||
- Start at the live edge with a conservative buffer; do not seek to playlist start.
|
||||
- Treat the manifest as the synchronization source and target roughly three segments behind the newest segment unless testing supports a smaller margin.
|
||||
- Recover transient network/media errors with bounded retries and exponential backoff.
|
||||
- On a fatal hls.js network error, reload the manifest; on a fatal media error, attempt media recovery once before recreating the player.
|
||||
- When tab suspension or drift leaves playback materially behind the target live position, seek forward to the target rather than playing the backlog.
|
||||
- Surface a clear reconnecting state. Never silently loop stale media.
|
||||
- Schedule/chat timestamps should account for the measured player latency rather than assuming wall-clock playout is already visible.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
The service retries an item failure with a bounded backoff. After the configured failure threshold it switches to a pre-normalized looping slate and reports `mode=slate`. It periodically retries normal playout and returns only after a segment and committed manifest have been published successfully.
|
||||
|
||||
On process or host restart:
|
||||
|
||||
- create a new run/session ID and continue with globally unique segment paths;
|
||||
- resume from the schedule-derived current position when reliable state exists, otherwise start the current playlist item;
|
||||
- never overwrite old segment objects;
|
||||
- keep the last valid manifest available until the replacement stream is playable;
|
||||
- emit a discontinuity when timeline continuity cannot be guaranteed.
|
||||
|
||||
Health is not merely “process running.” `/health` should report at least mode (`live`, `slate`, `starting`, `degraded`), active item, playlist revision, run ID, last segment sequence/time, last successful R2 publication, consecutive failures, and uptime. Mark the service unhealthy if the manifest or media sequence has not advanced for more than two segment durations.
|
||||
|
||||
## Monitoring and acceptance checks
|
||||
|
||||
Monitor and alert on:
|
||||
|
||||
- manifest age and media-sequence advancement;
|
||||
- time since last successfully published segment;
|
||||
- FFmpeg restarts and consecutive item failures;
|
||||
- live/slate mode;
|
||||
- R2 upload latency and errors;
|
||||
- segment 404/5xx rate and manifest/segment request latency;
|
||||
- Cloudflare cache-hit ratio, origin requests, and R2 Class B operations;
|
||||
- player startup time, fatal errors, rebuffer ratio, and live-edge drift;
|
||||
- local staging disk usage.
|
||||
|
||||
A release is streaming-ready only when one full playlist boundary has played without timestamp errors, the manifest never references a missing object, a second viewer joins the same timeline, cache headers match the table above, a forced FFmpeg failure activates the slate, and normal playout recovers without reusing an object URL.
|
||||
|
||||
## Required configuration
|
||||
|
||||
| Variable | Purpose |
|
||||
| ------------------------------------------ | -------------------------------------------------- |
|
||||
| `PLAYLIST_PATH` | Active playlist file |
|
||||
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `6` |
|
||||
| `HLS_WINDOW_SIZE` | Manifest window; baseline `24` |
|
||||
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
||||
| `R2_BUCKET_NAME` | Media bucket |
|
||||
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, available only to the publisher |
|
||||
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
||||
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
||||
|
||||
Production values are `MEDIA_DOMAIN=media.linearvoid.tv` and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. Local fallback may use `http://localhost/hls/playlist.m3u8`.
|
||||
|
||||
## Explicitly deferred
|
||||
|
||||
Adaptive-bitrate ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any of them requires an ADR and an update to this document.
|
||||
+48
-44
@@ -87,10 +87,12 @@
|
||||
|
||||
## Companion Documentation
|
||||
|
||||
- [API.md](../../API.md) - Implemented WebSocket and REST API reference.
|
||||
- [DEPLOYMENT.md](../../DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||
- [MONITORING.md](../../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||
- [SECURITY.md](../../SECURITY.md) - Security model, controls, and hardening baseline.
|
||||
- [API.md](../API.md) - Implemented WebSocket and REST API reference.
|
||||
- [DEPLOYMENT.md](../DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||
- [MONITORING.md](../MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||
- [SECURITY.md](../SECURITY.md) - Security model, controls, and hardening baseline.
|
||||
- [SETUP.md](../SETUP.md) - Test lab setup, local development, and CI/CD workflow.
|
||||
- [STREAMING.md](../STREAMING.md) - Streaming architecture, playout, and delivery details.
|
||||
|
||||
## Introduction and Goals
|
||||
|
||||
@@ -114,8 +116,8 @@ Full decision rationale, alternatives, and consequences in [`adr/ADR-001.md`](ad
|
||||
### Quality Goals
|
||||
|
||||
| Priority | Quality Attribute | Goal | Scenario |
|
||||
| -------- | ------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| 1 | **Cost Efficiency** | <$25/month for moderate scale (100s concurrent) | R2 $0 egress + immutable `.ts` edge caching eliminates per-viewer bandwidth cost |
|
||||
| -------- | ------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| 1 | **Cost Efficiency** | <$25/month for moderate scale (100s concurrent) | R2 $0 egress + immutable `.m4s` and `.ts` edge caching eliminates per-viewer bandwidth cost |
|
||||
| 2 | **Performance / Latency** | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) |
|
||||
| 3 | **Availability** | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart loop, fallback slate, monitoring |
|
||||
| 4 | **Scalability** | Grow from 10s to 1000s viewers without proportional cost | Edge caching + R2; only request volume limits |
|
||||
@@ -186,7 +188,7 @@ flowchart TD
|
||||
|
||||
**External Interfaces**:
|
||||
|
||||
- **Cloudflare**: CDN (cache rules for `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Pages (static hosting), WAF/TLS.
|
||||
- **Cloudflare**: CDN (cache rules for `.m4s` and `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Pages (static hosting), WAF/TLS.
|
||||
- **Hetzner VPS**: Compute for playout and chat (low-cost US-East with good peering).
|
||||
- **Browsers**: Native HLS (`<video>` + hls.js) and WebSocket APIs.
|
||||
- **Source**: Pre-normalized MP4 files uploaded to R2.
|
||||
@@ -196,7 +198,7 @@ This diagram synthesizes the context views from all original ADRs. Full decision
|
||||
### Technical Context
|
||||
|
||||
- **Playout**: FFmpeg consuming normalized playlist → continuous HLS.
|
||||
- **Packaging/Delivery**: MediaMTX/SRS/Nginx → immutable `.ts` segments to R2; Cloudflare edge cache.
|
||||
- **Packaging/Delivery**: MediaMTX/SRS/Nginx → immutable `.m4s` and `.ts` segments to R2; Cloudflare edge cache.
|
||||
- **Frontend**: Static SPA with hls.js.
|
||||
- **Chat**: Lightweight WebSocket service (Node/Go) + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation).
|
||||
- **Normalization**: All source videos must be H.264/AAC, 30 fps constant, 2s keyframes.
|
||||
@@ -212,7 +214,7 @@ This diagram synthesizes the context views from all original ADRs. Full decision
|
||||
|
||||
1. **US-East Playout on Hetzner Ashburn VPS** (`CX22` or equivalent): FFmpeg + MediaMTX/SRS for continuous playlist-to-HLS conversion (`-c copy` where possible).
|
||||
2. **Immutable HLS to Cloudflare R2**: Segments use unique names; R2 acts as origin with $0 egress.
|
||||
3. **Aggressive Edge Caching**: Long TTL for `.ts` (immutable), short TTL (1–10s) for live `.m3u8`. Tiered Cache enabled. This makes viewer delivery essentially free.
|
||||
3. **Aggressive Edge Caching**: Long TTL for `.m4s` and `.ts` (immutable), short TTL (1–10s) for live `.m3u8`. Tiered Cache enabled. This makes viewer delivery essentially free.
|
||||
4. **Lightweight Custom Frontend**: Static HTML/JS SPA + hls.js player + embedded chat sidebar.
|
||||
5. **Separate but co-located Chat Service**: WebSockets with persistence and moderation (low bandwidth).
|
||||
6. **Normalization & Monitoring**: Pre-process all videos; use dashboards + alerts for playout health, cache hit ratio, R2 ops.
|
||||
@@ -230,7 +232,7 @@ Overview (Mermaid diagram):
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Cloudflare
|
||||
CDN[CDN + Cache Rules<br/>.ts immutable long TTL<br/>.m3u8 short TTL]
|
||||
CDN[CDN + Cache Rules<br/>.m4s and .ts immutable long TTL<br/>.m3u8 short TTL]
|
||||
R2[R2 - HLS + Source]
|
||||
Pages[Pages - Static SPA]
|
||||
end
|
||||
@@ -306,7 +308,7 @@ graph TB
|
||||
|
||||
- **Role**: Continuous HLS encoding loop using FFmpeg concat demuxer
|
||||
- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths
|
||||
- **Output**: HLS segments (`.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
|
||||
- **Output**: HLS segments (`.m4s` and `.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
|
||||
- **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081
|
||||
- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval)
|
||||
|
||||
@@ -383,7 +385,7 @@ Module-level breakdown per service.
|
||||
2. Playout worker (FFmpeg loop with concat demuxer) reads next file, packages as HLS segments (4-6s).
|
||||
3. Segments uploaded to R2 with unique immutable names; live `.m3u8` updated.
|
||||
4. Viewer loads SPA → requests master/live manifest → hls.js player follows current edge.
|
||||
5. Cloudflare serves `.ts` from nearest edge cache (origin/R2 hit only once per POP/region).
|
||||
5. Cloudflare serves `.m4s` and `.ts` from nearest edge cache (origin/R2 hit only once per POP/region).
|
||||
6. Parallel WebSocket connection to chat service for synchronized interaction.
|
||||
|
||||
Notable aspects: Manifest freshness ensures all viewers stay on same timeline; cache hit ratio directly impacts cost and latency. Fallback slate activates on playout failure. Full cache policy, example configs (FFmpeg bash loop, Nginx RTMP HLS), and storage tables in `adr/ADR-001.md`.
|
||||
@@ -394,7 +396,7 @@ Notable aspects: Manifest freshness ensures all viewers stay on same timeline; c
|
||||
|
||||
### CI/CD Pipeline Architecture
|
||||
|
||||
The deployment follows a two-stage pipeline: **CI builds and pushes images**, then **CD pulls and deploys** them. Currently targeting the **test lab** (192.168.88.40). Production Hetzner VPS is planned but not yet provisioned — the same pipeline will deploy there by changing `DOCKER_HOST`.
|
||||
The deployment uses parallel workflows on each push to `main`: **CI builds and pushes images** while **CD deploys latest images** to the test lab. A dedicated release workflow handles semantic version tags. Current runtime target is the **test lab** (192.168.88.40). Production Hetzner VPS is planned but not yet provisioned — the same pipeline will deploy there by changing `DOCKER_HOST`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -431,18 +433,22 @@ flowchart LR
|
||||
**Pipeline Sequence:**
|
||||
|
||||
1. **CI Workflow** (`.gitea/workflows/ci.yml`):
|
||||
- Triggers on `push` to `main` or `pull_request`
|
||||
- Runs `test` job: frontend lint/build, Go validation, `docker compose build`
|
||||
- Triggers on `push` to `main` and `pull_request`
|
||||
- Runs `test` job: frontend lint/build and Go validation
|
||||
- Runs `docker` job (after `test` passes, only on `main` push):
|
||||
- Builds `playout`, `chat`, `nginx` images with Buildx
|
||||
- Pushes to Gitea Container Registry with `main` and `latest` tags
|
||||
2. **CD Workflow** (`.gitea/workflows/cd.yml`):
|
||||
- Triggers on `workflow_run` when CI completes successfully, or manually via `workflow_dispatch`
|
||||
- Pulls latest images from registry
|
||||
- Triggers on `push` to `main` (parallel to CI), or manually via `workflow_dispatch`
|
||||
- Logs in remote Docker host to private registry
|
||||
- Generates `.env` from secrets
|
||||
- Seeds Prometheus config into named volume
|
||||
- Deploys via `docker compose -f docker-compose.prod.yml up -d` to remote host (`192.168.88.40:2375` — test lab; switch to Hetzner IP for production)
|
||||
- Seeds Prometheus and Grafana provisioning into named volumes
|
||||
- Deploys via `docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate` to remote host (`192.168.88.40:2375` — test lab; switch to Hetzner IP for production)
|
||||
- Runs health checks with retry logic for playout initialization
|
||||
3. **Release Workflow** (`.gitea/workflows/release.yml`):
|
||||
- Triggers on semantic tags in `*.*.*` format (for example `1.0.9`)
|
||||
- Builds and pushes versioned image tags (`X.Y.Z`, `X.Y`, `latest`)
|
||||
- Creates release entry in Gitea
|
||||
|
||||
### Infrastructure Level 1
|
||||
|
||||
@@ -465,7 +471,7 @@ flowchart LR
|
||||
**Cloudflare Layer**:
|
||||
|
||||
- R2 bucket for media.
|
||||
- Cache Rules (`.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate).
|
||||
- Cache Rules (`.m4s` and `.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate).
|
||||
- WAF, rate limiting, Tiered Cache.
|
||||
- Pages for SPA hosting.
|
||||
|
||||
@@ -475,7 +481,7 @@ Future evolution: Docker Compose, managed DB, secondary regions (detailed in sub
|
||||
|
||||
### Caching & Cost Optimization
|
||||
|
||||
Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = near-zero viewer bandwidth cost. Long TTL on `.ts`, short on live manifests. Monitor hit ratio and R2 Class B ops.
|
||||
Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = near-zero viewer bandwidth cost. Long TTL on `.m4s` and `.ts`, short on live manifests. Monitor hit ratio and R2 Class B ops.
|
||||
|
||||
### Video Normalization & Playout
|
||||
|
||||
@@ -524,14 +530,14 @@ Linear Void uses a two-layer monitoring model:
|
||||
|
||||
- Config file: `monitoring/prometheus/prometheus.yml`
|
||||
- Default scrape interval: `15s`
|
||||
- Current scrape jobs: `chat` -> `chat:3000`, `prometheus` -> `prometheus:9090`.
|
||||
|
||||
Recommended next-step scrape targets (if/when exporters are introduced):
|
||||
|
||||
- `nginx` (via nginx-exporter)
|
||||
- `redis` (via redis-exporter)
|
||||
- `postgres` (via postgres-exporter)
|
||||
- `node` (via node-exporter)
|
||||
- Current scrape jobs (7 total):
|
||||
- `chat` -> `chat:3000`
|
||||
- `playout` -> `playout:8081`
|
||||
- `nginx` -> `nginx-exporter:9113`
|
||||
- `redis` -> `redis-exporter:9121`
|
||||
- `postgres` -> `postgres-exporter:9187`
|
||||
- `node` -> `node-exporter:9100`
|
||||
- `prometheus` -> `localhost:9090`
|
||||
|
||||
#### Grafana Dashboards
|
||||
|
||||
@@ -542,22 +548,20 @@ Use the app dashboard for service-level behavior and the Prometheus overview das
|
||||
|
||||
#### Alerting Rules (Prometheus Rule Groups)
|
||||
|
||||
Define alert rules as grouped policies (for example in `monitoring/prometheus/alerts.yml`) and include them from `prometheus.yml`.
|
||||
Rules are implemented in `monitoring/prometheus/alerts.yml` and loaded via `rule_files` from `prometheus.yml`.
|
||||
|
||||
Minimum recommended alert set:
|
||||
Current rule set (8):
|
||||
|
||||
- **Availability**
|
||||
`up{job="chat"} == 0` for `2m`; `up{job="prometheus"} == 0` for `2m`.
|
||||
- **Chat service health**
|
||||
`linearvoid_chat_redis_up == 0` for `2m`; sudden disconnect spike / connected clients collapse.
|
||||
- **Playout continuity**
|
||||
No HLS segment progression for `30-60s` (from playout-specific metric when available).
|
||||
- **Prometheus capacity and ingestion health**
|
||||
Sustained growth in `prometheus_tsdb_wal_writes_failed_total` or `prometheus_tsdb_compactions_failed_total`, abnormal jump in `prometheus_tsdb_out_of_order_samples_total`, and high `prometheus_engine_queries` near concurrent limit.
|
||||
- **Latency and user-impact**
|
||||
Elevated p99 handler latency from `prometheus_http_request_duration_seconds_bucket` and elevated p99 query execution latency from `prometheus_engine_query_duration_histogram_seconds_bucket`.
|
||||
- `ChatDown`
|
||||
- `PlayoutDown`
|
||||
- `RedisDown`
|
||||
- `PlayoutSlateActive`
|
||||
- `HighChatLatency`
|
||||
- `PrometheusWALFailing`
|
||||
- `HighCPU`
|
||||
- `LowDiskSpace`
|
||||
|
||||
Route warning/critical severities to the team channel and keep runbook links attached to each alert definition.
|
||||
Route warning/critical severities to the operations channel and keep runbook links attached to each alert definition.
|
||||
|
||||
Operational resilience baseline:
|
||||
|
||||
@@ -887,8 +891,8 @@ Document and rehearse this runbook quarterly; recovery that is not exercised is
|
||||
## Glossary
|
||||
|
||||
| Term | Definition |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------ |
|
||||
| HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (`.ts` or fMP4). |
|
||||
| ----------------- | -------------------------------------------------------------------------------------- |
|
||||
| HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (fMP4). |
|
||||
| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
|
||||
| R2 | Cloudflare object storage ($0 egress, ideal origin for cached HLS). |
|
||||
| Edge Cache / POP | Cloudflare points-of-presence that cache and serve content close to viewers. |
|
||||
|
||||
@@ -19,11 +19,11 @@ We require a completely independent 24/7 linear video channel that broadcasts a
|
||||
**Key distinctions**:
|
||||
|
||||
- **Playout**: Continuous scheduled program from playlist.
|
||||
- **Packaging**: FFmpeg → standard HLS (`.m3u8` + `.ts` segments, 4–6s duration).
|
||||
- **Packaging**: FFmpeg → standard HLS (`.m3u8` + `.m4s` segments, 4–6s duration).
|
||||
- **Delivery**: Immutable segments served via Cloudflare edge caching + R2 (zero egress cost).
|
||||
- **Interaction**: Separate lightweight WebSocket service for chat.
|
||||
|
||||
Bandwidth (not storage or compute) is the dominant cost driver. A single viewer at ~4 Mbps consumes ~1.3–1.8 TB/month; 100 concurrent viewers = ~130–180 TB/month. Direct VPS delivery is only viable for PoC/small audiences.
|
||||
Bandwidth (not storage or compute) is the dominant cost driver. A single viewer at ~4 Mbps consumes ~1.3–1.8 TB/month; 100 concurrent viewers = ~130–180 TB/month. Direct VPS delivery is only viable for PoC/small audiences. HLS segments are typically `.m4s` files, with `.m4s` being the default for fMP4 segments.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
@@ -31,7 +31,7 @@ Bandwidth (not storage or compute) is the dominant cost driver. A single viewer
|
||||
2. Full independence and ownership (no video platform lock-in).
|
||||
3. Strong US performance and low latency.
|
||||
4. True/near-true synchronization via live-style HLS (not pure VOD).
|
||||
5. Aggressive immutable caching of `.ts` segments to achieve near-zero egress.
|
||||
5. Aggressive immutable caching of `.m4s` segments to achieve near-zero egress.
|
||||
6. Browser-native playback (`hls.js` + native HLS).
|
||||
7. Lightweight, maintainable stack with modest operational complexity.
|
||||
8. Support for custom multi-room + private chat with persistence and moderation.
|
||||
@@ -58,9 +58,9 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
||||
|
||||
### 2. Storage & Delivery (Cloudflare R2 + CDN)
|
||||
|
||||
- HLS segments (`.ts`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`).
|
||||
- Custom domain (`media.example.com`) fronted by Cloudflare with **Cache Rules**:
|
||||
- `*.ts` (and init `.mp4` if using fMP4 later): `Cache Everything`, immutable, Edge TTL 24h–1yr (`Cache-Control: public, max-age=31536000, immutable`).
|
||||
- HLS segments (`.m4s`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`).
|
||||
- Custom domain (`media.linearvoid.tv`) fronted by Cloudflare with **Cache Rules**:
|
||||
- `*.m4s` (and init `.mp4` if using fMP4 later): `Cache Everything`, immutable, Edge TTL 24h–1yr (`Cache-Control: public, max-age=31536000, immutable`).
|
||||
- `*.m3u8`: Short TTL (1–10s) or `must-revalidate` (`Cache-Control: public, max-age=1..10, must-revalidate`).
|
||||
- Master playlist: TTL ~30s.
|
||||
- Tiered Cache enabled where possible.
|
||||
@@ -90,7 +90,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
||||
|
||||
**Initial Targets**:
|
||||
|
||||
- Protocol: Standard HLS (`.ts` segments). Defer LL-HLS/CMAF/WebRTC.
|
||||
- Protocol: Standard HLS (`.m4s` segments). Defer LL-HLS/CMAF/WebRTC.
|
||||
- Max rendition: 720p30 initially (upgrade to 1080p30 or ABR later).
|
||||
- Latency: 15–35s (acceptable for linear programming/chat).
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ We must explicitly document language/runtime choices per module and the assumpti
|
||||
|
||||
1. All video assets **must** be normalized before being added to any playlist (H.264, AAC, 30 fps constant, 2s keyframes, CRF encoding).
|
||||
2. Deployment is **Docker-first**; services will be containerized.
|
||||
3. Cloudflare is the primary CDN and will be configured with specific Cache Rules (`.ts` immutable long TTL, `.m3u8` short TTL).
|
||||
3. Cloudflare is the primary CDN and will be configured with specific [Cache Rules](../../../infrastructure/cloudflare/cache-rules.md).
|
||||
4. Chat does **not** require end-to-end encryption in v1 (separate ADR if needed).
|
||||
5. Single VPS origin initially; multi-region is a later evolution.
|
||||
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
||||
|
||||
@@ -26,10 +26,10 @@ contributors. See <https://arc42.org>.
|
||||
+-------------+---------------------------+---------------------------+
|
||||
| Role/Name | Contact | Expectations |
|
||||
+=============+===========================+===========================+
|
||||
| * | *\<Contact-1\>* | *\<Expectation-1\>* |
|
||||
| * | _\<Contact-1\>_ | _\<Expectation-1\>_ |
|
||||
| \<Role-1\>* | | |
|
||||
+-------------+---------------------------+---------------------------+
|
||||
| * | *\<Contact-2\>* | *\<Expectation-2\>* |
|
||||
| * | _\<Contact-2\>_ | _\<Expectation-2\>_ |
|
||||
| \<Role-2\>* | | |
|
||||
+-------------+---------------------------+---------------------------+
|
||||
|
||||
@@ -57,41 +57,41 @@ contributors. See <https://arc42.org>.
|
||||
|
||||
## Whitebox Overall System {#_whitebox_overall_system}
|
||||
|
||||
***\<Overview Diagram\>***
|
||||
_**\<Overview Diagram\>**_
|
||||
|
||||
Motivation
|
||||
|
||||
: *\<text explanation\>*
|
||||
: _\<text explanation\>_
|
||||
|
||||
Contained Building Blocks
|
||||
|
||||
: *\<Description of contained building block (black boxes)\>*
|
||||
: _\<Description of contained building block (black boxes)\>_
|
||||
|
||||
Important Interfaces
|
||||
|
||||
: *\<Description of important interfaces\>*
|
||||
: _\<Description of important interfaces\>_
|
||||
|
||||
### \<Name black box 1\> {#_name_black_box_1}
|
||||
|
||||
*\<Purpose/Responsibility\>*
|
||||
_\<Purpose/Responsibility\>_
|
||||
|
||||
*\<Interface(s)\>*
|
||||
_\<Interface(s)\>_
|
||||
|
||||
*\<(Optional) Quality/Performance Characteristics\>*
|
||||
_\<(Optional) Quality/Performance Characteristics\>_
|
||||
|
||||
*\<(Optional) Directory/File Location\>*
|
||||
_\<(Optional) Directory/File Location\>_
|
||||
|
||||
*\<(Optional) Fulfilled Requirements\>*
|
||||
_\<(Optional) Fulfilled Requirements\>_
|
||||
|
||||
*\<(optional) Open Issues/Problems/Risks\>*
|
||||
_\<(optional) Open Issues/Problems/Risks\>_
|
||||
|
||||
### \<Name black box 2\> {#_name_black_box_2}
|
||||
|
||||
*\<black box template\>*
|
||||
_\<black box template\>_
|
||||
|
||||
### \<Name black box n\> {#_name_black_box_n}
|
||||
|
||||
*\<black box template\>*
|
||||
_\<black box template\>_
|
||||
|
||||
### \<Name interface 1\> {#_name_interface_1}
|
||||
|
||||
@@ -101,42 +101,42 @@ Important Interfaces
|
||||
|
||||
## Level 2 {#_level_2}
|
||||
|
||||
### White Box *\<building block 1\>* {#_white_box_building_block_1}
|
||||
### White Box _\<building block 1\>_ {#_white_box_building_block_1}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
### White Box *\<building block 2\>* {#_white_box_building_block_2}
|
||||
### White Box _\<building block 2\>_ {#_white_box_building_block_2}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
...
|
||||
|
||||
### White Box *\<building block m\>* {#_white_box_building_block_m}
|
||||
### White Box _\<building block m\>_ {#_white_box_building_block_m}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
## Level 3 {#_level_3}
|
||||
|
||||
### White Box \<\_building block x.1\_\> {#_white_box_building_block_x_1}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
### White Box \<\_building block x.2\_\> {#_white_box_building_block_x_2}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
### White Box \<\_building block y.1\_\> {#_white_box_building_block_y_1}
|
||||
|
||||
*\<white box template\>*
|
||||
_\<white box template\>_
|
||||
|
||||
# Runtime View {#section-runtime-view}
|
||||
|
||||
## \<Runtime Scenario 1\> {#_runtime_scenario_1}
|
||||
|
||||
- *\<insert runtime diagram or textual description of the scenario\>*
|
||||
- _\<insert runtime diagram or textual description of the scenario\>_
|
||||
|
||||
- *\<insert description of the notable aspects of the interactions
|
||||
between the building block instances depicted in this diagram.\>*
|
||||
- _\<insert description of the notable aspects of the interactions
|
||||
between the building block instances depicted in this diagram.\>_
|
||||
|
||||
## \<Runtime Scenario 2\> {#_runtime_scenario_2}
|
||||
|
||||
@@ -148,51 +148,51 @@ Important Interfaces
|
||||
|
||||
## Infrastructure Level 1 {#_infrastructure_level_1}
|
||||
|
||||
***\<Overview Diagram\>***
|
||||
_**\<Overview Diagram\>**_
|
||||
|
||||
Motivation
|
||||
|
||||
: *\<explanation in text form\>*
|
||||
: _\<explanation in text form\>_
|
||||
|
||||
Quality and/or Performance Features
|
||||
|
||||
: *\<explanation in text form\>*
|
||||
: _\<explanation in text form\>_
|
||||
|
||||
Mapping of Building Blocks to Infrastructure
|
||||
|
||||
: *\<description of the mapping\>*
|
||||
: _\<description of the mapping\>_
|
||||
|
||||
## Infrastructure Level 2 {#_infrastructure_level_2}
|
||||
|
||||
### *\<Infrastructure Element 1\>* {#_infrastructure_element_1}
|
||||
### _\<Infrastructure Element 1\>_ {#_infrastructure_element_1}
|
||||
|
||||
*\<diagram + explanation\>*
|
||||
_\<diagram + explanation\>_
|
||||
|
||||
### *\<Infrastructure Element 2\>* {#_infrastructure_element_2}
|
||||
### _\<Infrastructure Element 2\>_ {#_infrastructure_element_2}
|
||||
|
||||
*\<diagram + explanation\>*
|
||||
_\<diagram + explanation\>_
|
||||
|
||||
...
|
||||
|
||||
### *\<Infrastructure Element n\>* {#_infrastructure_element_n}
|
||||
### _\<Infrastructure Element n\>_ {#_infrastructure_element_n}
|
||||
|
||||
*\<diagram + explanation\>*
|
||||
_\<diagram + explanation\>_
|
||||
|
||||
# Cross-cutting Concepts {#section-concepts}
|
||||
|
||||
## *\<Concept 1\>* {#_concept_1}
|
||||
## _\<Concept 1\>_ {#_concept_1}
|
||||
|
||||
*\<explanation\>*
|
||||
_\<explanation\>_
|
||||
|
||||
## *\<Concept 2\>* {#_concept_2}
|
||||
## _\<Concept 2\>_ {#_concept_2}
|
||||
|
||||
*\<explanation\>*
|
||||
_\<explanation\>_
|
||||
|
||||
...
|
||||
|
||||
## *\<Concept n\>* {#_concept_n}
|
||||
## _\<Concept n\>_ {#_concept_n}
|
||||
|
||||
*\<explanation\>*
|
||||
_\<explanation\>_
|
||||
|
||||
# Architecture Decisions {#section-design-decisions}
|
||||
|
||||
@@ -209,7 +209,7 @@ Mapping of Building Blocks to Infrastructure
|
||||
+----------------------+-----------------------------------------------+
|
||||
| Term | Definition |
|
||||
+======================+===============================================+
|
||||
| *\<Term-1\>* | *\<definition-1\>* |
|
||||
| _\<Term-1\>_ | _\<definition-1\>_ |
|
||||
+----------------------+-----------------------------------------------+
|
||||
| *\<Term-2\>* | *\<definition-2\>* |
|
||||
| _\<Term-2\>_ | _\<definition-2\>_ |
|
||||
+----------------------+-----------------------------------------------+
|
||||
@@ -161,4 +161,69 @@ docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
|
||||
3. Add prevention to docs if actionable
|
||||
4. Update this runbook if new failure mode discovered
|
||||
|
||||
**Recovery targets:** RPO ≤24h, RTO ≤60min. Quarterly DR drill: simulate host loss, restore from backup.
|
||||
## Quarterly Disaster Recovery Drill
|
||||
|
||||
Goal: validate recovery targets under controlled host-loss simulation.
|
||||
|
||||
**Recovery targets:** RPO <= 24h, RTO <= 60min.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Latest backup artifacts available from `scripts/backup-db.sh` and `scripts/backup-weekly.sh`
|
||||
- Operator access to Docker host and DNS controls
|
||||
- Current deployment variables available (`.env`, compose file, image tags)
|
||||
|
||||
### Drill Scope
|
||||
|
||||
- Simulate complete origin loss (all app containers unavailable)
|
||||
- Rebuild stack on clean host
|
||||
- Restore DB and media/playlist mounts
|
||||
- Verify viewer playback, chat, admin auth, monitoring targets
|
||||
|
||||
### Drill Steps
|
||||
|
||||
1. Record drill start timestamp and on-call participants.
|
||||
2. Capture pre-drill baseline:
|
||||
- `curl -fsS http://<origin>/health`
|
||||
- `curl -fsS http://<origin>:3000/health`
|
||||
- `curl -fsS http://<origin>:8081/health`
|
||||
3. Simulate origin outage:
|
||||
- `docker compose -f docker-compose.prod.yml down`
|
||||
4. Provision clean recovery host (or clean test-lab VM) and copy deployment bundle.
|
||||
5. Restore required data paths:
|
||||
- `data/playlist/`
|
||||
- `data/media/`
|
||||
- `data/chat/`
|
||||
- `data/hls/` (optional warm cache)
|
||||
6. Restore database from latest backup dump.
|
||||
7. Start stack:
|
||||
- `docker compose -f docker-compose.prod.yml up -d`
|
||||
8. Validate service health and runtime:
|
||||
- `docker ps --format "table {{.Names}}\t{{.Status}}"`
|
||||
- `curl -fsS http://<origin>/health`
|
||||
- `curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5`
|
||||
- `curl -fsS http://<origin>:9090/-/ready`
|
||||
9. Validate app behavior:
|
||||
- Viewer SPA loads and HLS plays
|
||||
- WebSocket chat connects and message round-trip works
|
||||
- Admin login/session endpoint returns success
|
||||
10. Run backup integrity check:
|
||||
- `scripts/verify-backups.sh`
|
||||
11. Record drill end timestamp and compute measured RTO/RPO.
|
||||
|
||||
### Pass/Fail Criteria
|
||||
|
||||
- Pass if all core services healthy and functional in <= 60 minutes.
|
||||
- Pass if latest recoverable data point is <= 24 hours old.
|
||||
- Fail if any critical path remains degraded (playout, chat, admin auth, monitoring).
|
||||
|
||||
### Evidence Template
|
||||
|
||||
Document each drill in incident tracker with:
|
||||
|
||||
- Date/time window
|
||||
- Environment (test-lab, staging, production)
|
||||
- Backup artifact IDs and timestamps
|
||||
- Measured RTO and RPO
|
||||
- Issues found and remediation owner
|
||||
- Follow-up ticket links
|
||||
@@ -0,0 +1,140 @@
|
||||
# Cloudflare and UptimeRobot Manual Completion Checklist
|
||||
|
||||
Purpose: execute and record all manual dashboard tasks that cannot be automated from repository workflows.
|
||||
|
||||
Recommended execution order:
|
||||
|
||||
1. Bind `media.linearvoid.tv` and confirm the HLS origin is separate from the app origin.
|
||||
2. Enable Pages, Tiered Cache, and cache behavior for the media zone.
|
||||
3. Apply WAF, bot, rate limiting, HTTPS, and HSTS settings.
|
||||
4. Verify monitor targets and alert delivery in UptimeRobot.
|
||||
5. Capture evidence and fill in the sign-off block.
|
||||
|
||||
## Status Legend
|
||||
|
||||
- [ ] Pending
|
||||
- [x] Done
|
||||
|
||||
## 1) Cloudflare Pages Link and Build
|
||||
|
||||
- [ ] Connect repo to Cloudflare Pages project
|
||||
- [ ] Build command set to: `cd frontend && npm run build`
|
||||
- [ ] Output directory set to: `frontend/dist`
|
||||
- [ ] Production domain attached to `linearvoid.tv`
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- Screenshot of Pages deployment settings
|
||||
- Deployment URL and latest deployment timestamp
|
||||
- Confirmed production build output path in the Pages project
|
||||
|
||||
## 2) Tiered Cache Enablement and Validation
|
||||
|
||||
- [ ] Smart Tiered Cache enabled for zone
|
||||
- [ ] Warm request made to hot HLS segment
|
||||
- [ ] Warm request made to live manifest
|
||||
- [ ] `Cf-Cache-Status: HIT` observed on repeated requests
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
||||
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
|
||||
```
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- Headers from first request (expected MISS)
|
||||
- Headers from repeated request (expected HIT)
|
||||
- Screenshot or export showing Smart Tiered Cache enabled
|
||||
|
||||
## 3) WAF and Bot Protection
|
||||
|
||||
- [ ] OWASP Managed Ruleset enabled
|
||||
- [ ] Mode set to Simulate
|
||||
- [ ] Simulate mode reviewed for 48h
|
||||
- [ ] Mode switched to Block after false-positive review
|
||||
- [ ] Bot Fight Mode enabled
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- WAF ruleset status screenshot
|
||||
- Count of blocked/challenged events after cutover
|
||||
- Note on any false positives and the final allow/exception rules applied
|
||||
|
||||
## 4) Rate Limiting Rules
|
||||
|
||||
- [ ] Rule created for `/api/admin/login`
|
||||
- [ ] Rule created for `/api/admin/*`
|
||||
- [ ] Threshold set to `10 requests / 10 seconds / IP`
|
||||
- [ ] Action set to Managed Challenge
|
||||
- [ ] Rule order validated
|
||||
|
||||
Validation approach:
|
||||
|
||||
- Use burst requests from a test client and verify challenge behavior without blocking legitimate admin flow.
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- Rule definitions screenshot
|
||||
- Test result notes (challenge seen / no lockout)
|
||||
- Keep a copy of the test source IP and timestamps used during validation
|
||||
|
||||
## 5) HTTPS and WSS Hardening
|
||||
|
||||
- [ ] Always Use HTTPS enabled
|
||||
- [ ] HSTS enabled with max-age 31536000
|
||||
- [ ] HSTS includeSubDomains enabled
|
||||
- [ ] HSTS preload enabled
|
||||
- [ ] Minimum TLS version set to 1.2+
|
||||
- [ ] SSL mode verified as Full (strict)
|
||||
- [ ] WSS endpoint validated through proxy (`wss://linearvoid.tv/ws`)
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
curl -I https://linearvoid.tv
|
||||
curl -I https://linearvoid.tv/health
|
||||
```
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- Response headers showing HSTS
|
||||
- Successful browser WebSocket connect in devtools
|
||||
- TLS mode screenshot from Cloudflare showing Full (strict)
|
||||
|
||||
## 6) UptimeRobot Monitors
|
||||
|
||||
- [ ] HTTP monitor added for `https://linearvoid.tv/health` (5m)
|
||||
- [ ] Keyword monitor added for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5m)
|
||||
- [ ] Alert trigger set to 2 consecutive failures
|
||||
- [ ] Notification channels configured (email + Discord/Slack webhook)
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- Monitor dashboard screenshot
|
||||
- Test alert delivery confirmation
|
||||
- List of recipients/webhook destinations verified during the alert test
|
||||
|
||||
## 7) Sign-Off
|
||||
|
||||
- [ ] Cloudflare manual tasks complete
|
||||
- [ ] UptimeRobot tasks complete
|
||||
- [ ] TODO items updated to done
|
||||
- [ ] Changelog entry added for completion batch
|
||||
|
||||
Completion summary:
|
||||
|
||||
- Pages build URL:
|
||||
- Cache validation result:
|
||||
- WAF/rate-limit status:
|
||||
- HTTPS/HSTS status:
|
||||
- UptimeRobot monitor status:
|
||||
- Evidence folder or note link:
|
||||
|
||||
Sign-off fields:
|
||||
|
||||
- Owner:
|
||||
- Date:
|
||||
- Environment (test-lab or production):
|
||||
- Notes:
|
||||
@@ -0,0 +1,442 @@
|
||||
# Scaling and Performance Playbook
|
||||
|
||||
Purpose: define repeatable method to measure performance, capture baseline data, and decide scaling actions.
|
||||
|
||||
Scope: planning and execution guide for TODO items under "Scaling & Performance (planning only)".
|
||||
|
||||
## 1) Manifest TTL Tuning Plan
|
||||
|
||||
Goal: tune `.m3u8` TTL to about `0.5x` segment duration while keeping playback stable.
|
||||
|
||||
Inputs:
|
||||
|
||||
- Segment duration (`HLS_SEGMENT_DURATION`)
|
||||
- Current manifest TTL (Cloudflare cache rule)
|
||||
- Cache outcome distribution (`Cf-Cache-Status`)
|
||||
|
||||
Procedure:
|
||||
|
||||
1. Start from current manifest TTL (2s).
|
||||
2. Capture 24h baseline headers from repeated `playlist.m3u8` requests.
|
||||
3. Test candidate TTLs: 1s, 2s, 3s.
|
||||
4. Compare:
|
||||
- rebuffer events from player logs
|
||||
- origin request increase/decrease
|
||||
- HIT ratio changes
|
||||
5. Select lowest TTL that keeps playback stable and origin load acceptable.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- No material increase in rebuffer rate.
|
||||
- Manifest staleness not observable by users.
|
||||
|
||||
## 2) TTFB Baseline Plan
|
||||
|
||||
Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`.
|
||||
|
||||
Suggested regions:
|
||||
|
||||
- US East (Virginia)
|
||||
- US Central
|
||||
- US West (California/Oregon)
|
||||
|
||||
Method:
|
||||
|
||||
1. Use WebPageTest with repeat view enabled.
|
||||
2. Run at least 5 samples per region.
|
||||
3. Record median and p95 TTFB.
|
||||
4. Store results in table (date, region, median, p95, test URL).
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Warm-cache median < 4s in all tested regions.
|
||||
- Outliers documented with likely cause.
|
||||
|
||||
### TTFB Baseline Worksheet
|
||||
|
||||
Use one row per region and keep the raw WebPageTest result URL with the summary so later runs can be compared against the same evidence.
|
||||
|
||||
- US East (Virginia)
|
||||
- Date/time:
|
||||
- Test URL: <https://linearvoid.tv>
|
||||
- WebPageTest result URL:
|
||||
- Median TTFB:
|
||||
- p95 TTFB:
|
||||
- Notes:
|
||||
- US Central
|
||||
- Date/time:
|
||||
- Test URL: <https://linearvoid.tv>
|
||||
- WebPageTest result URL:
|
||||
- Median TTFB:
|
||||
- p95 TTFB:
|
||||
- Notes:
|
||||
- US West (California/Oregon)
|
||||
- Date/time:
|
||||
- Test URL: <https://linearvoid.tv>
|
||||
- WebPageTest result URL:
|
||||
- Median TTFB:
|
||||
- p95 TTFB:
|
||||
- Notes:
|
||||
|
||||
Suggested capture notes:
|
||||
|
||||
- Use repeat view for the warm-cache measurement.
|
||||
- Keep the same browser profile and test URL across regions.
|
||||
- Note any CDN cache-rule changes or deploys that occurred between samples.
|
||||
|
||||
## 3) WebSocket Load-Test Plan
|
||||
|
||||
Goal: characterize chat service behavior across 10 -> 100 -> 500 -> 1000 concurrent clients.
|
||||
|
||||
Tooling option A (Artillery):
|
||||
|
||||
```bash
|
||||
npx artillery run scripts/loadtest/ws-chat.yml
|
||||
```
|
||||
|
||||
Tooling option B (custom Go harness):
|
||||
|
||||
- open N concurrent WebSocket clients
|
||||
- send message every X seconds
|
||||
- measure round-trip latency and failures
|
||||
|
||||
Metrics to capture:
|
||||
|
||||
- p50/p95 message latency
|
||||
- error rate (connect, send, receive)
|
||||
- dropped connections
|
||||
- CPU/memory for `chat`, `redis`, `nginx`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- p95 chat latency <= 1s at target concurrency tier.
|
||||
- error rate <= 1% during steady state.
|
||||
|
||||
### WebSocket Load-Test Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Test URL:
|
||||
- Tool used:
|
||||
- Scenario duration:
|
||||
- Concurrency tiers reached:
|
||||
- p50 message latency:
|
||||
- p95 message latency:
|
||||
- Error rate:
|
||||
- Dropped connections:
|
||||
- CPU/memory notes for chat:
|
||||
- CPU/memory notes for redis:
|
||||
- CPU/memory notes for nginx:
|
||||
- Raw results location:
|
||||
- Conclusion:
|
||||
- Next action:
|
||||
|
||||
## 4) Admin API Benchmark Plan
|
||||
|
||||
Goal: measure admin API p95 and identify slow endpoints.
|
||||
|
||||
Tooling option A (`k6`):
|
||||
|
||||
```bash
|
||||
k6 run scripts/loadtest/admin-api.js
|
||||
```
|
||||
|
||||
Tooling option B (`wrk`):
|
||||
|
||||
```bash
|
||||
wrk -t4 -c100 -d60s http://<host>/api/admin/overview
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `/api/admin/overview`
|
||||
- `/api/admin/analytics`
|
||||
- `/api/admin/chat-log`
|
||||
- `/api/admin/users`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- p95 < 1s for primary admin endpoints.
|
||||
- slow endpoint list with remediation notes.
|
||||
|
||||
### Admin API Benchmark Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Test URL:
|
||||
- Tool used:
|
||||
- Scenario duration:
|
||||
- Concurrency / virtual users:
|
||||
- `/api/admin/overview` p95:
|
||||
- `/api/admin/analytics` p95:
|
||||
- `/api/admin/chat-log` p95:
|
||||
- `/api/admin/users` p95:
|
||||
- Error rate:
|
||||
- Slowest endpoint:
|
||||
- Cache or DB notes:
|
||||
- Raw results location:
|
||||
- Conclusion:
|
||||
- Next action:
|
||||
|
||||
## 5) Cache Hit Ratio Tracking Plan
|
||||
|
||||
Goal: track Cloudflare cache hit ratio and alert when below threshold.
|
||||
|
||||
Implementation outline:
|
||||
|
||||
1. Pull Cloudflare GraphQL analytics on schedule.
|
||||
2. Export to Prometheus-compatible metric path.
|
||||
3. Add Grafana panel with 5m/1h/24h windows.
|
||||
4. Alert when hit ratio < 90% for 15m.
|
||||
|
||||
Reference exporter:
|
||||
|
||||
- `scripts/cache-hit-ratio.js`
|
||||
- `scripts/cache-hit-ratio.ps1` for Windows scheduled tasks / manual runs
|
||||
- Example run: `CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_TAG=... CLOUDFLARE_HOSTNAME=media.linearvoid.tv node scripts/cache-hit-ratio.js`
|
||||
- Windows example: `pwsh -File scripts/cache-hit-ratio.ps1`
|
||||
- Intended output: Prometheus text format on stdout, suitable for cron, a Node wrapper, or a small scrape endpoint.
|
||||
|
||||
Metric target:
|
||||
|
||||
- `cache_hit_ratio >= 0.90`
|
||||
|
||||
### Cache Hit Ratio Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Hostname:
|
||||
- Window minutes:
|
||||
- Cloudflare zone tag:
|
||||
- Export command:
|
||||
- Raw exporter output location:
|
||||
- Hit requests:
|
||||
- Total requests:
|
||||
- Cache hit ratio:
|
||||
- Threshold met:
|
||||
- Notes:
|
||||
|
||||
## 6) Capacity Trigger Rules
|
||||
|
||||
Define operational triggers:
|
||||
|
||||
- Cache miss ratio > 10% for 15m
|
||||
- Chat p95 latency > 1s for 5m
|
||||
- Host CPU > 80% for 10m
|
||||
- Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m
|
||||
|
||||
Action mapping:
|
||||
|
||||
- Level 1: investigate + restart non-critical components
|
||||
- Level 2: scale service resources (CPU/RAM)
|
||||
- Level 3: activate standby planning path
|
||||
|
||||
### Capacity Trigger Review
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Trigger hit:
|
||||
- Threshold value:
|
||||
- Observed value:
|
||||
- Duration above threshold:
|
||||
- Level selected:
|
||||
- Immediate action taken:
|
||||
- Services involved:
|
||||
- Follow-up owner:
|
||||
- Raw metrics / alert link:
|
||||
- Notes:
|
||||
|
||||
## 7) Phase 2 Service Separation Plan
|
||||
|
||||
Objective: separate bottlenecked services while keeping simple ops.
|
||||
|
||||
Boundaries:
|
||||
|
||||
- `playout` isolated first (CPU-heavy)
|
||||
- `chat` + `redis` grouped for latency
|
||||
- `db` isolated for IO stability
|
||||
- monitoring stack optionally separated after sustained load
|
||||
|
||||
Initial sizing guidance:
|
||||
|
||||
- playout host: 4 vCPU / 8 GB
|
||||
- chat host: 2-4 vCPU / 4-8 GB
|
||||
- db host: 2 vCPU / 4 GB + fast disk
|
||||
|
||||
Exit criteria for Phase 2:
|
||||
|
||||
- repeated trigger events over 7 days
|
||||
- capacity saturation despite tuning
|
||||
|
||||
### Phase 2 Boundary Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Trigger history summary:
|
||||
- Repeated trigger count over 7 days:
|
||||
- Primary bottleneck:
|
||||
- Proposed split:
|
||||
- `playout` target host size:
|
||||
- `chat` target host size:
|
||||
- `db` target host size:
|
||||
- Monitoring separation needed:
|
||||
- Data/state migration notes:
|
||||
- Implementation owner:
|
||||
- Review decision:
|
||||
- Next action:
|
||||
|
||||
## 8) Phase 3 Warm Standby Plan
|
||||
|
||||
Objective: second-region resilience with DNS failover.
|
||||
|
||||
Candidate layout:
|
||||
|
||||
- Primary: Hetzner Ashburn (US-East)
|
||||
- Standby: Hetzner Hillsboro (US-West)
|
||||
|
||||
Design:
|
||||
|
||||
- Warm standby stack deployed and health-checked
|
||||
- Redis replication or fallback mode documented
|
||||
- Postgres replication strategy selected (streaming replica or periodic snapshot restore)
|
||||
- Cloudflare DNS failover policy defined (health-based or manual emergency switch)
|
||||
|
||||
Failover drill requirements:
|
||||
|
||||
### Phase 3 Warm Standby Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Primary region:
|
||||
- Standby region:
|
||||
- DNS failover mode:
|
||||
- Redis strategy:
|
||||
- Postgres strategy:
|
||||
- Health check endpoints:
|
||||
- Simulated cutover performed:
|
||||
- Measured RTO:
|
||||
- Measured RPO:
|
||||
- Open risks:
|
||||
- Implementation owner:
|
||||
- Review decision:
|
||||
- Notes:
|
||||
|
||||
## 9) Data Capture Template
|
||||
|
||||
For each benchmark run, capture:
|
||||
|
||||
- Date/time
|
||||
- Environment (test-lab/staging/production)
|
||||
- Commit SHA / image tag
|
||||
- Test scenario and duration
|
||||
- Raw metrics (latency, errors, resource usage)
|
||||
- Conclusion and next action
|
||||
|
||||
## 10) Execution Order
|
||||
|
||||
1. TTFB baseline
|
||||
2. WebSocket load profile
|
||||
3. Admin API benchmark
|
||||
4. Cache ratio panel + alerting
|
||||
5. Trigger tuning
|
||||
6. Phase 2 boundary decision
|
||||
7. Phase 3 standby design review
|
||||
|
||||
## 11) Benchmark Run Sheet
|
||||
|
||||
Use the same worksheet for every baseline or re-test so results stay comparable.
|
||||
|
||||
Preconditions:
|
||||
|
||||
- Confirm the target environment and commit SHA/image tag.
|
||||
- Confirm Cloudflare cache rules are active when testing TTFB or manifest TTL.
|
||||
- Confirm the chat, admin, and playout services are healthy before load testing.
|
||||
|
||||
Run template:
|
||||
|
||||
| Item | Value |
|
||||
| -------------------------- | ----- |
|
||||
| Date/time | |
|
||||
| Environment | |
|
||||
| Commit SHA / image tag | |
|
||||
| Test scenario | |
|
||||
| Concurrency / sample count | |
|
||||
| Command or tool | |
|
||||
| Raw results location | |
|
||||
| Conclusion | |
|
||||
| Next action | |
|
||||
|
||||
Suggested command mapping:
|
||||
|
||||
- TTFB baseline: WebPageTest repeat-view run against `https://linearvoid.tv`.
|
||||
- WebSocket profile: `npx artillery run scripts/loadtest/ws-chat.yml` or the Go harness referenced above.
|
||||
- Admin API benchmark: `k6 run scripts/loadtest/admin-api.js` or `wrk -t4 -c100 -d60s http://<host>/api/admin/overview`.
|
||||
|
||||
## 12) Scaling Decision Gate
|
||||
|
||||
Treat the following as the default review gate after any benchmark batch:
|
||||
|
||||
1. If warm-cache TTFB meets target but cache hit ratio falls below 90%, tune cache behavior before increasing capacity.
|
||||
2. If chat or admin latency exceeds target while CPU and memory remain low, prioritize query or handler optimization.
|
||||
3. If CPU, memory, or Redis saturation appears first, treat the service boundary guidance in Phase 2 as the next action.
|
||||
4. If the same threshold trips on multiple runs across 7 days, open the Phase 2 or Phase 3 planning path instead of tuning in place.
|
||||
|
||||
Decision note:
|
||||
|
||||
- Keep the smallest change that restores the target metric and record the reason in the run sheet.
|
||||
|
||||
## 13) Artifact-to-Worksheet Field Mapping
|
||||
|
||||
Use this section when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
|
||||
|
||||
### WebSocket worksheet mapping
|
||||
|
||||
Artifact sources:
|
||||
|
||||
- `artifacts/<timestamp>/ws-chat-report.json`
|
||||
- `artifacts/<timestamp>/ws-chat-summary.txt`
|
||||
|
||||
Field mapping:
|
||||
|
||||
- Worksheet `Tool used` -> `artillery` (or `run-loadtests.ps1 -Scenario ws`)
|
||||
- Worksheet `Raw results location` -> artifact folder path for this run
|
||||
- Worksheet `Concurrency tiers reached` -> `ws-chat.yml` phases (`warmup-10`, `sustain-100`, `sustain-500`, `sustain-1000`)
|
||||
- Worksheet `Error rate` -> `vusers.failure_rate_percent` from `ws-chat-summary.txt`
|
||||
- Worksheet `Dropped connections` -> derive from `vusers.created - vusers.completed` (use `vusers.failed` as failure signal)
|
||||
- Worksheet `p95 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `p95=...`
|
||||
- Worksheet `p50 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `median=...`
|
||||
|
||||
Notes:
|
||||
|
||||
- The exact `summary.*` metric names in Artillery output can vary by engine/plugins.
|
||||
- If no latency summary field is emitted, record this explicitly and use server-side latency metrics as fallback evidence.
|
||||
|
||||
### Admin API worksheet mapping
|
||||
|
||||
Artifact sources:
|
||||
|
||||
- `artifacts/<timestamp>/admin-api-summary.json`
|
||||
- `artifacts/<timestamp>/admin-api-output.txt`
|
||||
- `artifacts/<timestamp>/admin-api-endpoint-summary.txt`
|
||||
|
||||
Field mapping:
|
||||
|
||||
- Worksheet `Tool used` -> `k6` (or `run-loadtests.ps1 -Scenario admin`)
|
||||
- Worksheet `Raw results location` -> artifact folder path for this run
|
||||
- Worksheet `/api/admin/overview` p95 -> `overview` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `/api/admin/analytics` p95 -> `analytics` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `/api/admin/chat-log` p95 -> `chat_log` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `/api/admin/users` p95 -> `users` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `Error rate` -> `Global failure rate` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `Slowest endpoint` -> endpoint row with highest `P95Ms`
|
||||
|
||||
Notes:
|
||||
|
||||
- Keep the raw `admin-api-summary.json` file for audits and re-parsing.
|
||||
- If an endpoint row is missing, treat as test/data issue and re-run with full endpoint coverage.
|
||||
+103
-15
@@ -1,14 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void Admin</title>
|
||||
<link rel="stylesheet" href="../src/style.css" />
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body class="admin-body">
|
||||
<body class="admin-body">
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-sidebar" aria-label="Primary">
|
||||
<div class="brand-block">
|
||||
@@ -19,15 +18,105 @@
|
||||
<button id="logout-btn" type="button" class="btn btn-secondary btn-block">Sign out</button>
|
||||
|
||||
<nav class="admin-nav" aria-label="Admin sections" role="tablist" aria-orientation="vertical">
|
||||
<button type="button" id="tab-trigger-dashboard" role="tab" class="tab-trigger is-active" data-tab="dashboard" aria-controls="tab-dashboard" aria-selected="true">Dashboard</button>
|
||||
<button type="button" id="tab-trigger-upload" role="tab" class="tab-trigger" data-tab="upload" aria-controls="tab-upload" aria-selected="false">Upload Video</button>
|
||||
<button type="button" id="tab-trigger-playlist" role="tab" class="tab-trigger" data-tab="playlist" aria-controls="tab-playlist" aria-selected="false">Playlist</button>
|
||||
<button type="button" id="tab-trigger-schedule" role="tab" class="tab-trigger" data-tab="schedule" aria-controls="tab-schedule" aria-selected="false">Schedule</button>
|
||||
<button type="button" id="tab-trigger-moderation" role="tab" class="tab-trigger" data-tab="moderation" aria-controls="tab-moderation" aria-selected="false">Moderation</button>
|
||||
<button type="button" id="tab-trigger-chat-log" role="tab" class="tab-trigger" data-tab="chat-log" aria-controls="tab-chat-log" aria-selected="false">Chat Log</button>
|
||||
<button type="button" id="tab-trigger-users" role="tab" class="tab-trigger" data-tab="users" aria-controls="tab-users" aria-selected="false">Users</button>
|
||||
<button type="button" id="tab-trigger-analytics" role="tab" class="tab-trigger" data-tab="analytics" aria-controls="tab-analytics" aria-selected="false">Analytics</button>
|
||||
<button type="button" id="tab-trigger-system" role="tab" class="tab-trigger" data-tab="system" aria-controls="tab-system" aria-selected="false">System</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-dashboard"
|
||||
role="tab"
|
||||
class="tab-trigger is-active"
|
||||
data-tab="dashboard"
|
||||
aria-controls="tab-dashboard"
|
||||
aria-selected="true"
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-upload"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="upload"
|
||||
aria-controls="tab-upload"
|
||||
aria-selected="false"
|
||||
>
|
||||
Upload Video
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-playlist"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="playlist"
|
||||
aria-controls="tab-playlist"
|
||||
aria-selected="false"
|
||||
>
|
||||
Playlist
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-schedule"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="schedule"
|
||||
aria-controls="tab-schedule"
|
||||
aria-selected="false"
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-moderation"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="moderation"
|
||||
aria-controls="tab-moderation"
|
||||
aria-selected="false"
|
||||
>
|
||||
Moderation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-chat-log"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="chat-log"
|
||||
aria-controls="tab-chat-log"
|
||||
aria-selected="false"
|
||||
>
|
||||
Chat Log
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-users"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="users"
|
||||
aria-controls="tab-users"
|
||||
aria-selected="false"
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-analytics"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="analytics"
|
||||
aria-controls="tab-analytics"
|
||||
aria-selected="false"
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="tab-trigger-system"
|
||||
role="tab"
|
||||
class="tab-trigger"
|
||||
data-tab="system"
|
||||
aria-controls="tab-system"
|
||||
aria-selected="false"
|
||||
>
|
||||
System
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -37,6 +126,5 @@
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/admin.ts"></script>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,14 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void Admin Login</title>
|
||||
<link rel="stylesheet" href="../../src/style.css" />
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body class="auth-body">
|
||||
<body class="auth-body">
|
||||
<main class="auth-shell" aria-label="Admin authentication">
|
||||
<section class="auth-card" aria-labelledby="auth-title">
|
||||
<p class="auth-kicker">Linear Void</p>
|
||||
@@ -29,39 +28,38 @@
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('login-form');
|
||||
const username = document.getElementById('username');
|
||||
const password = document.getElementById('password');
|
||||
const submit = document.getElementById('submit');
|
||||
const status = document.getElementById('status');
|
||||
const form = document.getElementById("login-form");
|
||||
const username = document.getElementById("username");
|
||||
const password = document.getElementById("password");
|
||||
const submit = document.getElementById("submit");
|
||||
const status = document.getElementById("status");
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
submit.disabled = true;
|
||||
status.textContent = 'Signing in...';
|
||||
status.className = 'status-line';
|
||||
status.textContent = "Signing in...";
|
||||
status.className = "status-line";
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.value, password: password.value })
|
||||
const response = await fetch("/api/admin/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
window.location.href = '/admin/';
|
||||
window.location.href = "/admin/";
|
||||
} catch (error) {
|
||||
console.error('Failed to sign in', error);
|
||||
status.textContent = 'Login failed. Check your credentials.';
|
||||
status.className = 'status-line status-error';
|
||||
console.error("Failed to sign in", error);
|
||||
status.textContent = "Login failed. Check your credentials.";
|
||||
status.className = "status-line status-error";
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+22
-11
@@ -1,14 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body class="lv-body">
|
||||
<body class="lv-body">
|
||||
<main class="viewer-shell" aria-label="Live player and chat">
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-brand-group">
|
||||
@@ -18,7 +17,16 @@
|
||||
<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>
|
||||
<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">
|
||||
@@ -48,18 +56,21 @@
|
||||
<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()" />
|
||||
<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>
|
||||
<p class="chat-note">Synced with video • Moderation active</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1005
-3
File diff suppressed because it is too large.
Load diff
@@ -1,17 +1,22 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.16"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<p>Basic activity counters from chat history and moderation audit logs.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<select id="analytics-range" class="text-input" style="width:auto">
|
||||
<select id="analytics-range" class="text-input" style="width: auto">
|
||||
<option value="1h">Last hour</option>
|
||||
<option value="6h">Last 6 hours</option>
|
||||
<option value="24h" selected>Last 24 hours</option>
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
<section class="panel-card">
|
||||
<h2>New Action</h2>
|
||||
<form id="moderation-form" class="form-stack">
|
||||
<label>Action
|
||||
<label
|
||||
>Action
|
||||
<select id="mod-action" class="text-input">
|
||||
<option value="mute">mute</option>
|
||||
<option value="ban">ban</option>
|
||||
@@ -21,17 +22,26 @@
|
||||
<option value="timeout">timeout</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Target
|
||||
<label
|
||||
>Target
|
||||
<input id="mod-target" class="text-input" type="text" placeholder="username or message id" required />
|
||||
</label>
|
||||
<label>Moderator
|
||||
<label
|
||||
>Moderator
|
||||
<input id="mod-moderator" class="text-input" type="text" placeholder="admin" />
|
||||
</label>
|
||||
<label>Room
|
||||
<label
|
||||
>Room
|
||||
<input id="mod-room" class="text-input" type="text" placeholder="main" />
|
||||
</label>
|
||||
<label>Reason
|
||||
<textarea id="mod-reason" class="text-input text-area" placeholder="Explain why this action is needed" required></textarea>
|
||||
<label
|
||||
>Reason
|
||||
<textarea
|
||||
id="mod-reason"
|
||||
class="text-input text-area"
|
||||
placeholder="Explain why this action is needed"
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
<button id="mod-submit" type="submit" class="btn btn-primary">Submit action</button>
|
||||
<p id="moderation-status" class="status-line" aria-live="polite">Loading moderation logs...</p>
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<p class="small-muted"><span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime <span id="schedule-total-runtime">00:00:00</span></p>
|
||||
<p class="small-muted">
|
||||
<span id="schedule-matched-entries">0</span>/<span id="schedule-total-entries">0</span> matched • total runtime
|
||||
<span id="schedule-total-runtime">00:00:00</span>
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
@@ -28,7 +31,7 @@
|
||||
<div class="panel-card">
|
||||
<h2>Schedule Overrides</h2>
|
||||
<p>Insert scheduled overrides to play specific videos at specific times.</p>
|
||||
<form id="override-form" class="form-stack" style="max-width:500px">
|
||||
<form id="override-form" class="form-stack" style="max-width: 500px">
|
||||
<label>Media ID <input id="override-media-id" type="number" class="text-input" required /></label>
|
||||
<label>Start Time <input id="override-start" type="datetime-local" class="text-input" required /></label>
|
||||
<label>End Time <input id="override-end" type="datetime-local" class="text-input" required /></label>
|
||||
@@ -36,7 +39,7 @@
|
||||
<button type="submit" class="btn btn-primary">Add Override</button>
|
||||
</form>
|
||||
<p id="override-status" class="status-line" aria-live="polite"></p>
|
||||
<div class="table-wrap" style="margin-top:1rem">
|
||||
<div class="table-wrap" style="margin-top: 1rem">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
<div class="panel-card">
|
||||
<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 Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label>
|
||||
<button type="submit" class="btn btn-primary">Save Config</button>
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
<div id="upload-dropzone" class="upload-dropzone">
|
||||
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
|
||||
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">Drag and Drop or Click to Upload</button>
|
||||
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">
|
||||
Drag and Drop or Click to Upload
|
||||
</button>
|
||||
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
|
||||
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
|
||||
<progress id="media-upload-progress" value="0" max="100"></progress>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+135
-232
@@ -19,18 +19,12 @@ type MediaEntry = {
|
||||
};
|
||||
|
||||
const bodyDataset = document.body.dataset;
|
||||
const moderationLogLimit = Number.parseInt(
|
||||
bodyDataset.adminModerationLogLimit || "",
|
||||
10,
|
||||
);
|
||||
const moderationLogLimit = Number.parseInt(bodyDataset.adminModerationLogLimit || "", 10);
|
||||
|
||||
const adminConfig = {
|
||||
defaultModerator: bodyDataset.adminDefaultModerator || "admin",
|
||||
defaultRoom: bodyDataset.adminDefaultRoom || "main",
|
||||
moderationLogLimit:
|
||||
Number.isFinite(moderationLogLimit) && moderationLogLimit > 0
|
||||
? moderationLogLimit
|
||||
: 50,
|
||||
moderationLogLimit: Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 ? moderationLogLimit : 50,
|
||||
};
|
||||
|
||||
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
@@ -43,16 +37,11 @@ const state = {
|
||||
playlistSaving: false,
|
||||
};
|
||||
|
||||
const uploadProgressWrap = () =>
|
||||
document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
||||
const uploadProgressWrap = () => document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
||||
|
||||
const uploadProgressBar = () =>
|
||||
document.getElementById(
|
||||
"media-upload-progress",
|
||||
) as HTMLProgressElement | null;
|
||||
const uploadProgressBar = () => document.getElementById("media-upload-progress") as HTMLProgressElement | null;
|
||||
|
||||
const uploadProgressText = () =>
|
||||
document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
||||
const uploadProgressText = () => document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
||||
|
||||
const setUploadProgress = (value: number, total: number, label: string) => {
|
||||
const wrap = uploadProgressWrap();
|
||||
@@ -73,11 +62,9 @@ const hideUploadProgress = () => {
|
||||
if (wrap) wrap.hidden = true;
|
||||
};
|
||||
|
||||
const getFileFingerprint = (file: File) =>
|
||||
`${file.name}:${file.size}:${file.lastModified}`;
|
||||
const getFileFingerprint = (file: File) => `${file.name}:${file.size}:${file.lastModified}`;
|
||||
|
||||
const getResumeStorageKey = (fingerprint: string) =>
|
||||
`${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
|
||||
const getResumeStorageKey = (fingerprint: string) => `${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
|
||||
|
||||
const getStoredUploadId = (fingerprint: string) => {
|
||||
try {
|
||||
@@ -185,7 +172,7 @@ async function switchTab(tabName: string) {
|
||||
if (tabName === "system") refreshSystemSummary();
|
||||
}
|
||||
|
||||
async function refreshOverview() {
|
||||
export async function refreshOverview() {
|
||||
try {
|
||||
const response = await fetch("/api/admin/overview");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
@@ -211,7 +198,7 @@ async function refreshOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAnalytics() {
|
||||
export async function refreshAnalytics() {
|
||||
setStatus("analytics-status", "Loading analytics...");
|
||||
try {
|
||||
const response = await fetch("/api/admin/analytics");
|
||||
@@ -221,10 +208,7 @@ async function refreshAnalytics() {
|
||||
setText("analytics-total-messages", String(payload.totalMessages ?? 0));
|
||||
setText("analytics-messages-24h", String(payload.messagesLast24Hours ?? 0));
|
||||
setText("analytics-distinct-rooms", String(payload.distinctRooms ?? 0));
|
||||
setText(
|
||||
"analytics-moderation-actions",
|
||||
String(payload.moderationActions ?? 0),
|
||||
);
|
||||
setText("analytics-moderation-actions", String(payload.moderationActions ?? 0));
|
||||
setText("analytics-generated-at", payload.generatedAt || "unavailable");
|
||||
|
||||
const tbody = document.getElementById("analytics-table-body");
|
||||
@@ -260,9 +244,7 @@ async function refreshPlaylist() {
|
||||
|
||||
const payload = await response.json();
|
||||
const content: string = payload.content || "";
|
||||
const textarea = document.getElementById(
|
||||
"playlist-content",
|
||||
) as HTMLTextAreaElement | null;
|
||||
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
||||
if (textarea) textarea.value = content;
|
||||
renderPlaylistSortable(content);
|
||||
await showPlaylistWarnings(content);
|
||||
@@ -273,13 +255,16 @@ async function refreshPlaylist() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlaylistSortable(content: string) {
|
||||
export function renderPlaylistSortable(content: string) {
|
||||
const list = document.getElementById("playlist-sortable");
|
||||
if (!list) return;
|
||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||
list.innerHTML = lines
|
||||
.map((line, i) => {
|
||||
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
|
||||
const path = line
|
||||
.replace(/^file\s+/, "")
|
||||
.replace(/['"]/g, "")
|
||||
.trim();
|
||||
return `<li class="sortable-item" draggable="true" data-index="${i}">
|
||||
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
||||
<span class="sortable-index">${i + 1}</span>
|
||||
@@ -348,12 +333,17 @@ async function showPlaylistWarnings(content: string) {
|
||||
const mediaPaths = new Set((media.files || []).map((f: { path: string }) => f.path));
|
||||
const nonNorm = (media.files || []).filter((f: { status: string }) => f.status === "needs_normalization");
|
||||
for (const line of lines) {
|
||||
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
|
||||
const path = line
|
||||
.replace(/^file\s+/, "")
|
||||
.replace(/['"]/g, "")
|
||||
.trim();
|
||||
if (path && !mediaPaths.has(path)) msgs.push(`Missing from media library: ${path}`);
|
||||
}
|
||||
if (nonNorm.length > 0) msgs.push(`${nonNorm.length} file(s) need normalization. Run scripts/normalize.sh.`);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (msgs.length > 0) {
|
||||
warnings.innerHTML = msgs.map((m) => `<div class="warning-msg">⚠ ${m}</div>`).join("");
|
||||
warnings.hidden = false;
|
||||
@@ -362,15 +352,13 @@ async function showPlaylistWarnings(content: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlaylist() {
|
||||
export async function savePlaylist() {
|
||||
if (state.playlistSaving) return;
|
||||
state.playlistSaving = true;
|
||||
setStatus("playlist-status", "Saving playlist...");
|
||||
|
||||
try {
|
||||
const textarea = document.getElementById(
|
||||
"playlist-content",
|
||||
) as HTMLTextAreaElement | null;
|
||||
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
||||
const content = textarea?.value || "";
|
||||
|
||||
const response = await fetch("/api/admin/playlist", {
|
||||
@@ -415,10 +403,7 @@ async function refreshSchedule() {
|
||||
});
|
||||
}
|
||||
|
||||
setStatus(
|
||||
"schedule-status",
|
||||
`Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`,
|
||||
);
|
||||
setStatus("schedule-status", `Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`);
|
||||
} catch (error) {
|
||||
console.error("Failed to load schedule", error);
|
||||
setStatus("schedule-status", "Failed to load schedule.", true);
|
||||
@@ -445,7 +430,9 @@ async function refreshOverrides() {
|
||||
tbody.querySelectorAll("[data-delete-override]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => deleteOverride(Number((btn as HTMLElement).dataset.deleteOverride)));
|
||||
});
|
||||
} catch { /* non-critical */ }
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOverride(e: Event) {
|
||||
@@ -460,18 +447,37 @@ async function submitOverride(e: Event) {
|
||||
const resp = await fetch("/api/admin/schedule/overrides", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mediaId: Number(mediaId), startTime: new Date(startTime).toISOString(), endTime: new Date(endTime).toISOString(), reason }),
|
||||
body: JSON.stringify({
|
||||
mediaId: Number(mediaId),
|
||||
startTime: new Date(startTime).toISOString(),
|
||||
endTime: new Date(endTime).toISOString(),
|
||||
reason,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) { const text = await resp.text(); throw new Error(text); }
|
||||
if (status) { status.textContent = "Override added."; status.className = "status-line"; }
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = "Override added.";
|
||||
status.className = "status-line";
|
||||
}
|
||||
refreshOverrides();
|
||||
} catch (err) {
|
||||
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
|
||||
if (status) {
|
||||
status.textContent = `Failed: ${err}`;
|
||||
status.className = "status-line status-error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOverride(id: number) {
|
||||
try { await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" }); refreshOverrides(); } catch { /* ignore */ }
|
||||
try {
|
||||
await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" });
|
||||
refreshOverrides();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSystemSummary() {
|
||||
@@ -540,12 +546,8 @@ function renderMediaRows(files: MediaEntry[]) {
|
||||
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () =>
|
||||
editMedia(file),
|
||||
);
|
||||
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () =>
|
||||
deleteMedia(file),
|
||||
);
|
||||
tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => editMedia(file));
|
||||
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
@@ -612,18 +614,11 @@ async function uploadVideo(file: File) {
|
||||
|
||||
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
|
||||
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
|
||||
const resumeFrom = Math.min(
|
||||
Math.max(alreadyUploaded, 0),
|
||||
effectiveTotalChunks,
|
||||
);
|
||||
const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks);
|
||||
|
||||
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
|
||||
|
||||
for (
|
||||
let chunkIndex = resumeFrom;
|
||||
chunkIndex < effectiveTotalChunks;
|
||||
chunkIndex += 1
|
||||
) {
|
||||
for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) {
|
||||
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
|
||||
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
|
||||
const chunk = file.slice(start, end);
|
||||
@@ -641,23 +636,13 @@ async function uploadVideo(file: File) {
|
||||
throw new Error(await readErrorMessage(chunkResponse));
|
||||
}
|
||||
|
||||
setUploadProgress(
|
||||
chunkIndex + 1,
|
||||
effectiveTotalChunks,
|
||||
"Uploading chunks",
|
||||
);
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
|
||||
);
|
||||
setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks");
|
||||
setStatus("media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`);
|
||||
}
|
||||
|
||||
const completeResponse = await fetch(
|
||||
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
|
||||
{
|
||||
const completeResponse = await fetch(`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`, {
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!completeResponse.ok) {
|
||||
throw new Error(await readErrorMessage(completeResponse));
|
||||
@@ -674,13 +659,8 @@ async function uploadVideo(file: File) {
|
||||
await refreshSchedule();
|
||||
} catch (error) {
|
||||
console.error("Failed to upload media", error);
|
||||
const details =
|
||||
error instanceof Error ? error.message : "Unknown upload error.";
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Upload interrupted. Re-select the same file to resume. (${details})`,
|
||||
true,
|
||||
);
|
||||
const details = error instanceof Error ? error.message : "Unknown upload error.";
|
||||
setStatus("media-status", `Upload interrupted. Re-select the same file to resume. (${details})`, true);
|
||||
} finally {
|
||||
state.mediaUploading = false;
|
||||
setTimeout(() => hideUploadProgress(), 1200);
|
||||
@@ -749,9 +729,7 @@ async function deleteMedia(file: MediaEntry) {
|
||||
async function refreshModerationLogs() {
|
||||
setStatus("moderation-status", "Loading moderation logs...");
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`,
|
||||
);
|
||||
const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
@@ -787,10 +765,8 @@ async function submitModerationAction(event: Event) {
|
||||
const payload = {
|
||||
action: (document.getElementById("mod-action") as HTMLSelectElement).value,
|
||||
target: (document.getElementById("mod-target") as HTMLInputElement).value,
|
||||
moderator: (document.getElementById("mod-moderator") as HTMLInputElement)
|
||||
.value,
|
||||
reason: (document.getElementById("mod-reason") as HTMLTextAreaElement)
|
||||
.value,
|
||||
moderator: (document.getElementById("mod-moderator") as HTMLInputElement).value,
|
||||
reason: (document.getElementById("mod-reason") as HTMLTextAreaElement).value,
|
||||
room: (document.getElementById("mod-room") as HTMLInputElement).value,
|
||||
};
|
||||
|
||||
@@ -865,10 +841,7 @@ async function refreshChatLog() {
|
||||
renderChatLogTable();
|
||||
updateChatLogPagination();
|
||||
updateBulkDeleteButton();
|
||||
setStatus(
|
||||
"chat-log-status",
|
||||
`Loaded ${chatLogState.entries.length} entries.`,
|
||||
);
|
||||
setStatus("chat-log-status", `Loaded ${chatLogState.entries.length} entries.`);
|
||||
} catch (error) {
|
||||
console.error("Failed to load chat log", error);
|
||||
setStatus("chat-log-status", "Failed to load chat log.", true);
|
||||
@@ -908,10 +881,7 @@ function renderChatLogTable() {
|
||||
tr.querySelector('[data-action="chat-ban"]')?.addEventListener("click", () => {
|
||||
if (confirm(`Ban ${entry.username}?`)) userAction("ban", entry.username);
|
||||
});
|
||||
tr.querySelector('[data-action="delete-chat"]')?.addEventListener(
|
||||
"click",
|
||||
() => deleteChatEntry(entry.id),
|
||||
);
|
||||
tr.querySelector('[data-action="delete-chat"]')?.addEventListener("click", () => deleteChatEntry(entry.id));
|
||||
tr.querySelector(".chat-log-checkbox")?.addEventListener("change", (e) => {
|
||||
const cb = e.target as HTMLInputElement;
|
||||
if (cb.checked) {
|
||||
@@ -929,38 +899,27 @@ function renderChatLogTable() {
|
||||
}
|
||||
|
||||
function updateSelectAllCheckbox() {
|
||||
const selectAll = document.getElementById(
|
||||
"chat-log-select-all",
|
||||
) as HTMLInputElement | null;
|
||||
const selectAll = document.getElementById("chat-log-select-all") as HTMLInputElement | null;
|
||||
if (!selectAll) return;
|
||||
const allSelected =
|
||||
chatLogState.entries.length > 0 &&
|
||||
chatLogState.entries.every((e) => chatLogState.selected.has(e.id));
|
||||
chatLogState.entries.length > 0 && chatLogState.entries.every((e) => chatLogState.selected.has(e.id));
|
||||
selectAll.checked = allSelected;
|
||||
}
|
||||
|
||||
function updateBulkDeleteButton() {
|
||||
const btn = document.getElementById(
|
||||
"bulk-delete-chat-log-btn",
|
||||
) as HTMLButtonElement | null;
|
||||
const btn = document.getElementById("bulk-delete-chat-log-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.disabled = chatLogState.selected.size === 0;
|
||||
}
|
||||
|
||||
function updateChatLogPagination() {
|
||||
const prevBtn = document.getElementById(
|
||||
"chat-log-prev-btn",
|
||||
) as HTMLButtonElement | null;
|
||||
const nextBtn = document.getElementById(
|
||||
"chat-log-next-btn",
|
||||
) as HTMLButtonElement | null;
|
||||
const prevBtn = document.getElementById("chat-log-prev-btn") as HTMLButtonElement | null;
|
||||
const nextBtn = document.getElementById("chat-log-next-btn") as HTMLButtonElement | null;
|
||||
const info = document.getElementById("chat-log-page-info");
|
||||
|
||||
if (prevBtn) prevBtn.disabled = !chatLogState.before;
|
||||
if (nextBtn)
|
||||
nextBtn.disabled = chatLogState.entries.length < chatLogState.limit;
|
||||
if (info)
|
||||
info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`;
|
||||
if (nextBtn) nextBtn.disabled = chatLogState.entries.length < chatLogState.limit;
|
||||
if (info) info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`;
|
||||
}
|
||||
|
||||
function chatLogNextPage() {
|
||||
@@ -975,29 +934,17 @@ function chatLogPrevPage() {
|
||||
}
|
||||
|
||||
function chatLogApplyFilter() {
|
||||
chatLogState.room =
|
||||
(document.getElementById("chat-log-filter-room") as HTMLInputElement)
|
||||
?.value || "";
|
||||
chatLogState.username =
|
||||
(document.getElementById("chat-log-filter-username") as HTMLInputElement)
|
||||
?.value || "";
|
||||
chatLogState.text =
|
||||
(document.getElementById("chat-log-filter-text") as HTMLInputElement)
|
||||
?.value || "";
|
||||
chatLogState.room = (document.getElementById("chat-log-filter-room") as HTMLInputElement)?.value || "";
|
||||
chatLogState.username = (document.getElementById("chat-log-filter-username") as HTMLInputElement)?.value || "";
|
||||
chatLogState.text = (document.getElementById("chat-log-filter-text") as HTMLInputElement)?.value || "";
|
||||
chatLogState.before = "";
|
||||
refreshChatLog();
|
||||
}
|
||||
|
||||
function chatLogClearFilter() {
|
||||
const roomEl = document.getElementById(
|
||||
"chat-log-filter-room",
|
||||
) as HTMLInputElement | null;
|
||||
const userEl = document.getElementById(
|
||||
"chat-log-filter-username",
|
||||
) as HTMLInputElement | null;
|
||||
const textEl = document.getElementById(
|
||||
"chat-log-filter-text",
|
||||
) as HTMLInputElement | null;
|
||||
const roomEl = document.getElementById("chat-log-filter-room") as HTMLInputElement | null;
|
||||
const userEl = document.getElementById("chat-log-filter-username") as HTMLInputElement | null;
|
||||
const textEl = document.getElementById("chat-log-filter-text") as HTMLInputElement | null;
|
||||
if (roomEl) roomEl.value = "";
|
||||
if (userEl) userEl.value = "";
|
||||
if (textEl) textEl.value = "";
|
||||
@@ -1009,8 +956,7 @@ function chatLogClearFilter() {
|
||||
}
|
||||
|
||||
async function deleteChatEntry(id: number) {
|
||||
if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`))
|
||||
return;
|
||||
if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`)) return;
|
||||
|
||||
setStatus("chat-log-status", `Deleting message #${id}...`);
|
||||
try {
|
||||
@@ -1030,17 +976,9 @@ async function deleteChatEntry(id: number) {
|
||||
async function bulkDeleteChatLog() {
|
||||
if (chatLogState.selected.size === 0) return;
|
||||
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (!window.confirm(`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`)) return;
|
||||
|
||||
setStatus(
|
||||
"chat-log-status",
|
||||
`Deleting ${chatLogState.selected.size} messages...`,
|
||||
);
|
||||
setStatus("chat-log-status", `Deleting ${chatLogState.selected.size} messages...`);
|
||||
try {
|
||||
const ids = Array.from(chatLogState.selected);
|
||||
const response = await fetch("/api/admin/chat-log/bulk", {
|
||||
@@ -1050,10 +988,7 @@ async function bulkDeleteChatLog() {
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
setStatus(
|
||||
"chat-log-status",
|
||||
`${payload.deleted || ids.length} messages deleted.`,
|
||||
);
|
||||
setStatus("chat-log-status", `${payload.deleted || ids.length} messages deleted.`);
|
||||
await refreshChatLog();
|
||||
await refreshOverview();
|
||||
} catch (error) {
|
||||
@@ -1088,17 +1023,23 @@ async function refreshUsers() {
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbody.querySelectorAll(".user-mute").forEach(b => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-ban").forEach(b => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-unmute").forEach(b => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-unban").forEach(b => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!)));
|
||||
tbody
|
||||
.querySelectorAll(".user-mute")
|
||||
.forEach((b) => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
|
||||
tbody
|
||||
.querySelectorAll(".user-ban")
|
||||
.forEach((b) => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!)));
|
||||
tbody
|
||||
.querySelectorAll(".user-unmute")
|
||||
.forEach((b) => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!)));
|
||||
tbody
|
||||
.querySelectorAll(".user-unban")
|
||||
.forEach((b) => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!)));
|
||||
}
|
||||
|
||||
setStatus(
|
||||
"users-status",
|
||||
payload.redisEnabled
|
||||
? "Active users loaded."
|
||||
: "Redis disabled; no live presence data available.",
|
||||
payload.redisEnabled ? "Active users loaded." : "Redis disabled; no live presence data available.",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load users", error);
|
||||
@@ -1127,7 +1068,9 @@ async function loadConfig() {
|
||||
const cfg = await resp.json();
|
||||
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
||||
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
||||
} catch { /* non-critical */ }
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(e: Event) {
|
||||
@@ -1144,9 +1087,15 @@ async function saveConfig(e: Event) {
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
if (status) { status.textContent = "Config saved."; status.className = "status-line"; }
|
||||
if (status) {
|
||||
status.textContent = "Config saved.";
|
||||
status.className = "status-line";
|
||||
}
|
||||
} catch (err) {
|
||||
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
|
||||
if (status) {
|
||||
status.textContent = `Failed: ${err}`;
|
||||
status.className = "status-line status-error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,9 +1113,7 @@ async function restartService(service: string) {
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>(".tab-trigger")
|
||||
.forEach((button) => {
|
||||
document.querySelectorAll<HTMLButtonElement>(".tab-trigger").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const tab = button.dataset.tab;
|
||||
if (tab) switchTab(tab);
|
||||
@@ -1186,17 +1133,11 @@ function bindEvents() {
|
||||
function bindPageEvents(tabName: string) {
|
||||
switch (tabName) {
|
||||
case "upload":
|
||||
document
|
||||
.getElementById("reload-media-btn")
|
||||
?.addEventListener("click", refreshMediaLibrary);
|
||||
document.getElementById("reload-media-btn")?.addEventListener("click", refreshMediaLibrary);
|
||||
|
||||
const uploadInput = document.getElementById(
|
||||
"video-upload",
|
||||
) as HTMLInputElement | null;
|
||||
const uploadInput = document.getElementById("video-upload") as HTMLInputElement | null;
|
||||
const uploadDropzone = document.getElementById("upload-dropzone");
|
||||
document
|
||||
.getElementById("upload-trigger-btn")
|
||||
?.addEventListener("click", () => uploadInput?.click());
|
||||
document.getElementById("upload-trigger-btn")?.addEventListener("click", () => uploadInput?.click());
|
||||
|
||||
uploadDropzone?.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
@@ -1221,64 +1162,34 @@ function bindPageEvents(tabName: string) {
|
||||
break;
|
||||
|
||||
case "playlist":
|
||||
document
|
||||
.getElementById("reload-playlist-btn")
|
||||
?.addEventListener("click", refreshPlaylist);
|
||||
document
|
||||
.getElementById("save-playlist-btn")
|
||||
?.addEventListener("click", savePlaylist);
|
||||
document.getElementById("reload-playlist-btn")?.addEventListener("click", refreshPlaylist);
|
||||
document.getElementById("save-playlist-btn")?.addEventListener("click", savePlaylist);
|
||||
break;
|
||||
|
||||
case "schedule":
|
||||
document
|
||||
.getElementById("reload-schedule-btn")
|
||||
?.addEventListener("click", refreshSchedule);
|
||||
document
|
||||
.getElementById("override-form")
|
||||
?.addEventListener("submit", submitOverride);
|
||||
document.getElementById("reload-schedule-btn")?.addEventListener("click", refreshSchedule);
|
||||
document.getElementById("override-form")?.addEventListener("submit", submitOverride);
|
||||
refreshOverrides();
|
||||
break;
|
||||
|
||||
case "moderation":
|
||||
document
|
||||
.getElementById("reload-moderation-btn")
|
||||
?.addEventListener("click", refreshModerationLogs);
|
||||
document
|
||||
.getElementById("export-chat-log-btn")
|
||||
?.addEventListener("click", () => {
|
||||
document.getElementById("reload-moderation-btn")?.addEventListener("click", refreshModerationLogs);
|
||||
document.getElementById("export-chat-log-btn")?.addEventListener("click", () => {
|
||||
window.open("/api/admin/chat-log/export", "_blank");
|
||||
});
|
||||
document
|
||||
.getElementById("moderation-form")
|
||||
?.addEventListener("submit", submitModerationAction);
|
||||
(document.getElementById("mod-moderator") as HTMLInputElement).value =
|
||||
adminConfig.defaultModerator;
|
||||
(document.getElementById("mod-room") as HTMLInputElement).value =
|
||||
adminConfig.defaultRoom;
|
||||
document.getElementById("moderation-form")?.addEventListener("submit", submitModerationAction);
|
||||
(document.getElementById("mod-moderator") as HTMLInputElement).value = adminConfig.defaultModerator;
|
||||
(document.getElementById("mod-room") as HTMLInputElement).value = adminConfig.defaultRoom;
|
||||
break;
|
||||
|
||||
case "chat-log":
|
||||
document
|
||||
.getElementById("reload-chat-log-btn")
|
||||
?.addEventListener("click", refreshChatLog);
|
||||
document
|
||||
.getElementById("bulk-delete-chat-log-btn")
|
||||
?.addEventListener("click", bulkDeleteChatLog);
|
||||
document
|
||||
.getElementById("chat-log-filter-btn")
|
||||
?.addEventListener("click", chatLogApplyFilter);
|
||||
document
|
||||
.getElementById("chat-log-clear-filter-btn")
|
||||
?.addEventListener("click", chatLogClearFilter);
|
||||
document
|
||||
.getElementById("chat-log-prev-btn")
|
||||
?.addEventListener("click", chatLogPrevPage);
|
||||
document
|
||||
.getElementById("chat-log-next-btn")
|
||||
?.addEventListener("click", chatLogNextPage);
|
||||
document
|
||||
.getElementById("chat-log-select-all")
|
||||
?.addEventListener("change", (e) => {
|
||||
document.getElementById("reload-chat-log-btn")?.addEventListener("click", refreshChatLog);
|
||||
document.getElementById("bulk-delete-chat-log-btn")?.addEventListener("click", bulkDeleteChatLog);
|
||||
document.getElementById("chat-log-filter-btn")?.addEventListener("click", chatLogApplyFilter);
|
||||
document.getElementById("chat-log-clear-filter-btn")?.addEventListener("click", chatLogClearFilter);
|
||||
document.getElementById("chat-log-prev-btn")?.addEventListener("click", chatLogPrevPage);
|
||||
document.getElementById("chat-log-next-btn")?.addEventListener("click", chatLogNextPage);
|
||||
document.getElementById("chat-log-select-all")?.addEventListener("change", (e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
chatLogState.entries.forEach((entry) => {
|
||||
if (checked) {
|
||||
@@ -1293,27 +1204,19 @@ function bindPageEvents(tabName: string) {
|
||||
break;
|
||||
|
||||
case "analytics":
|
||||
document
|
||||
.getElementById("reload-analytics-btn")
|
||||
?.addEventListener("click", refreshAnalytics);
|
||||
document.getElementById("reload-analytics-btn")?.addEventListener("click", refreshAnalytics);
|
||||
break;
|
||||
|
||||
case "users":
|
||||
document
|
||||
.getElementById("reload-users-btn")
|
||||
?.addEventListener("click", refreshUsers);
|
||||
document.getElementById("reload-users-btn")?.addEventListener("click", refreshUsers);
|
||||
break;
|
||||
|
||||
case "system":
|
||||
document.getElementById("reload-system-btn")?.addEventListener("click", refreshSystemSummary);
|
||||
document.getElementById("config-form")?.addEventListener("submit", saveConfig);
|
||||
document
|
||||
.getElementById("reload-system-btn")
|
||||
?.addEventListener("click", refreshSystemSummary);
|
||||
document
|
||||
.getElementById("config-form")
|
||||
?.addEventListener("submit", saveConfig);
|
||||
document.querySelectorAll(".restart-btn").forEach((btn) =>
|
||||
btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!))
|
||||
);
|
||||
.querySelectorAll(".restart-btn")
|
||||
.forEach((btn) => btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!)));
|
||||
loadConfig();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export function setupCounter(element: HTMLButtonElement) {
|
||||
let counter = 0
|
||||
let counter = 0;
|
||||
const setCounter = (count: number) => {
|
||||
counter = count
|
||||
element.innerHTML = `Count is ${counter}`
|
||||
}
|
||||
element.addEventListener('click', () => setCounter(counter + 1))
|
||||
setCounter(0)
|
||||
counter = count;
|
||||
element.innerHTML = `Count is ${counter}`;
|
||||
};
|
||||
element.addEventListener("click", () => setCounter(counter + 1));
|
||||
setCounter(0);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/// <reference types="vitest" />
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { resetViewerStateForTests, resolveManifestUrl, resolveWebSocketBaseUrl } from "../src/main";
|
||||
|
||||
const hlsInstance = {
|
||||
loadSource: vi.fn(),
|
||||
attachMedia: vi.fn(),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
|
||||
const HlsMock = Object.assign(
|
||||
vi.fn().mockImplementation(() => hlsInstance),
|
||||
{
|
||||
isSupported: vi.fn(() => true),
|
||||
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
|
||||
},
|
||||
);
|
||||
|
||||
// Mock hls.js
|
||||
vi.mock("hls.js", () => ({
|
||||
default: HlsMock,
|
||||
}));
|
||||
|
||||
// Mock WebSocket
|
||||
const websocketInstances: MockWebSocket[] = [];
|
||||
class MockWebSocket {
|
||||
url: string;
|
||||
readyState = 0;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
send = vi.fn();
|
||||
close = vi.fn();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = String(url);
|
||||
websocketInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress console noise
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
let origWebSocket: any;
|
||||
let origFetch: any;
|
||||
|
||||
beforeEach(() => {
|
||||
origWebSocket = (globalThis as any).WebSocket;
|
||||
origFetch = globalThis.fetch;
|
||||
(globalThis as any).WebSocket = MockWebSocket;
|
||||
websocketInstances.length = 0;
|
||||
resetViewerStateForTests();
|
||||
|
||||
// Mock fetch (schedule, etc.) — return empty schedule
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ entries: [], total: 0, generatedAt: "" }),
|
||||
});
|
||||
|
||||
// Set up DOM from index.html
|
||||
document.body.innerHTML = `
|
||||
<main class="viewer-shell">
|
||||
<header class="viewer-header">
|
||||
<button id="chat-toggle" type="button">Hide Chat</button>
|
||||
</header>
|
||||
<section class="viewer-content">
|
||||
<section class="player-stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing">
|
||||
<span id="np-title">Loading...</span>
|
||||
<span id="np-next" class="np-next"></span>
|
||||
</div>
|
||||
</section>
|
||||
<aside id="chat-panel" class="chat-sidebar">
|
||||
<div class="chat-header">
|
||||
<span id="room-name">Main Channel</span>
|
||||
<button type="button" onclick="togglePrivateDM()">New DM</button>
|
||||
</div>
|
||||
<section id="messages" class="chat-messages"></section>
|
||||
<section id="presence-panel" class="presence-panel">
|
||||
<div id="presence-list" class="presence-list"></div>
|
||||
</section>
|
||||
<div class="chat-input-area">
|
||||
<input id="message-input" type="text" placeholder="Type a message..." />
|
||||
<button type="button" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
|
||||
// Stub prompt for DM
|
||||
(globalThis as any).prompt = vi.fn();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).WebSocket = origWebSocket;
|
||||
globalThis.fetch = origFetch;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("player startup", () => {
|
||||
it("starts native HLS playback on loadedmetadata", async () => {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
vi.spyOn(video, "canPlayType").mockReturnValue("probably");
|
||||
const playSpy = vi.spyOn(video, "play").mockResolvedValue(undefined as any);
|
||||
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat());
|
||||
|
||||
expect(video.src).toContain("/hls/playlist.m3u8?stream=");
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
expect(playSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts hls.js playback when native HLS is unavailable", async () => {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
Object.defineProperty(video, "canPlayType", {
|
||||
value: vi.fn(() => ""),
|
||||
configurable: true,
|
||||
});
|
||||
vi.spyOn(video, "play").mockResolvedValue(undefined as any);
|
||||
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
expect(HlsMock.isSupported).toHaveBeenCalled();
|
||||
expect(HlsMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat toggle", () => {
|
||||
it("toggles chat panel visibility", () => {
|
||||
const toggle = document.getElementById("chat-toggle")!;
|
||||
|
||||
expect(toggle.textContent).toBe("Hide Chat");
|
||||
|
||||
window.toggleChatPanel();
|
||||
expect(toggle.textContent).toBe("Show Chat");
|
||||
|
||||
window.toggleChatPanel();
|
||||
expect(toggle.textContent).toBe("Hide Chat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("clears input after sending non-empty text", () => {
|
||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||
input.value = "hello!";
|
||||
|
||||
window.sendMessage();
|
||||
expect(input.value).toBe("hello!");
|
||||
});
|
||||
|
||||
it("does nothing with empty input", () => {
|
||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||
input.value = " ";
|
||||
window.sendMessage();
|
||||
// No error thrown = pass
|
||||
});
|
||||
});
|
||||
|
||||
describe("switchRoom", () => {
|
||||
it("updates room name on switch", () => {
|
||||
window.switchRoom("lobby");
|
||||
const roomName = document.getElementById("room-name")!;
|
||||
expect(roomName.textContent).toBe("Lobby Room");
|
||||
});
|
||||
|
||||
it("shows Main Channel for main room", () => {
|
||||
window.switchRoom("main");
|
||||
const roomName = document.getElementById("room-name")!;
|
||||
expect(roomName.textContent).toBe("Main Channel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("togglePrivateDM", () => {
|
||||
it("prompts for username and switches room", () => {
|
||||
// Stub prompt to return a value this time
|
||||
vi.spyOn(window, "prompt").mockReturnValue("bob");
|
||||
|
||||
const switchRoomSpy = vi.spyOn(window, "switchRoom");
|
||||
window.togglePrivateDM();
|
||||
|
||||
expect(window.prompt).toHaveBeenCalled();
|
||||
expect(switchRoomSpy).not.toHaveBeenCalled(); // username is "viewerNNN" which gets set randomly
|
||||
|
||||
switchRoomSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket reconnect", () => {
|
||||
it("reconnects after onclose", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
window.switchRoom("lobby");
|
||||
expect(websocketInstances).toHaveLength(1);
|
||||
|
||||
websocketInstances[0].onclose?.();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
expect(websocketInstances).toHaveLength(2);
|
||||
expect(websocketInstances[1].url).toContain("room=lobby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL resolution", () => {
|
||||
it("falls back to same-origin manifest and websocket URLs", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_URL", "");
|
||||
vi.stubEnv("VITE_PUBLIC_WS_URL", "");
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "");
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
value: new URL("https://linearvoid.tv/viewer"),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||
expect(resolveWebSocketBaseUrl()).toBe("wss://linearvoid.tv/ws");
|
||||
});
|
||||
|
||||
it("preserves an allowed cross-origin manifest URL", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "true");
|
||||
|
||||
expect(resolveManifestUrl()).toMatch(/^https:\/\/media\.linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||
});
|
||||
|
||||
it("falls back to same-origin manifest when cross-origin is not allowed", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "false");
|
||||
|
||||
expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||
});
|
||||
|
||||
it("uses configured websocket URL when present", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_WS_URL", "wss://chat.linearvoid.tv/ws");
|
||||
|
||||
expect(resolveWebSocketBaseUrl()).toBe("wss://chat.linearvoid.tv/ws");
|
||||
});
|
||||
});
|
||||
|
||||
describe("presence updates", () => {
|
||||
it("adds user to presence list on join system message", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||
|
||||
const presenceList = document.getElementById("presence-list")!;
|
||||
expect(presenceList.textContent).toContain("alice");
|
||||
});
|
||||
|
||||
it("removes user from presence list on leave system message", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
|
||||
|
||||
const presenceList = document.getElementById("presence-list")!;
|
||||
expect(presenceList.textContent).not.toContain("alice");
|
||||
});
|
||||
|
||||
it("renders multiple users sorted", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "zoe joined the room", username: "system" }) });
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||
|
||||
const presenceList = document.getElementById("presence-list")!;
|
||||
const text = presenceList.textContent ?? "";
|
||||
const aliceIdx = text.indexOf("alice");
|
||||
const zoeIdx = text.indexOf("zoe");
|
||||
expect(aliceIdx).toBeLessThan(zoeIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("private DM flow", () => {
|
||||
it("creates dm: room name from sorted usernames", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
vi.spyOn(window, "prompt").mockReturnValue("bob");
|
||||
window.togglePrivateDM();
|
||||
|
||||
const roomName = document.getElementById("room-name")!;
|
||||
// username is "viewer" (reset by tests), bob is target -> sorted = "bob-viewer"
|
||||
// switchRoom capitalizes first char: "Dm:bob-viewer Room"
|
||||
expect(roomName.textContent).toContain("bob-viewer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("message rendering", () => {
|
||||
it("renders system messages", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "Server restarting", username: "system" }) });
|
||||
|
||||
const messages = document.getElementById("messages")!;
|
||||
expect(messages.innerHTML).toContain("Server restarting");
|
||||
expect(messages.querySelector(".message-system")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders self messages as message-self", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({ type: "public", text: "hello world", username: "viewer", timestamp: Date.now() }),
|
||||
});
|
||||
|
||||
const messages = document.getElementById("messages")!;
|
||||
expect(messages.innerHTML).toContain("hello world");
|
||||
expect(messages.querySelector(".message-self")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders other-user messages as message-other", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({ type: "public", text: "hey", username: "alice", timestamp: Date.now() }),
|
||||
});
|
||||
|
||||
const messages = document.getElementById("messages")!;
|
||||
expect(messages.innerHTML).toContain("alice");
|
||||
expect(messages.innerHTML).toContain("hey");
|
||||
expect(messages.querySelector(".message-other")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+21
-34
@@ -18,7 +18,16 @@ let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
let chatVisible = true;
|
||||
const onlineUsers = new Set<string>();
|
||||
|
||||
function resolveWebSocketBaseUrl() {
|
||||
export function resetViewerStateForTests() {
|
||||
hls = null;
|
||||
ws = null;
|
||||
currentRoom = "main";
|
||||
username = "viewer";
|
||||
chatVisible = true;
|
||||
onlineUsers.clear();
|
||||
}
|
||||
|
||||
export function resolveWebSocketBaseUrl() {
|
||||
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
||||
|
||||
if (configuredUrl) {
|
||||
@@ -29,27 +38,19 @@ function resolveWebSocketBaseUrl() {
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
function resolveManifestUrl() {
|
||||
export function resolveManifestUrl() {
|
||||
const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim();
|
||||
const cacheBust = `stream=${Date.now()}`;
|
||||
const sameOriginManifestUrl = new URL(
|
||||
"/hls/playlist.m3u8",
|
||||
window.location.origin,
|
||||
);
|
||||
const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin);
|
||||
sameOriginManifestUrl.searchParams.set("stream", cacheBust);
|
||||
|
||||
const allowCrossOriginManifest =
|
||||
import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() ===
|
||||
"true";
|
||||
const allowCrossOriginManifest = import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === "true";
|
||||
|
||||
if (configuredUrl) {
|
||||
const manifestUrl = new URL(configuredUrl);
|
||||
manifestUrl.searchParams.set("stream", cacheBust);
|
||||
|
||||
if (
|
||||
!allowCrossOriginManifest &&
|
||||
manifestUrl.origin !== window.location.origin
|
||||
) {
|
||||
if (!allowCrossOriginManifest && manifestUrl.origin !== window.location.origin) {
|
||||
return sameOriginManifestUrl.toString();
|
||||
}
|
||||
|
||||
@@ -96,12 +97,7 @@ function connectWebSocket(room: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function addMessage(
|
||||
type: string,
|
||||
text: string,
|
||||
sender = "",
|
||||
timestamp = new Date(),
|
||||
) {
|
||||
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
|
||||
const messages = document.getElementById("messages")!;
|
||||
const div = document.createElement("div");
|
||||
div.className = "message-row";
|
||||
@@ -110,9 +106,7 @@ function addMessage(
|
||||
div.classList.add("message-system-row");
|
||||
div.innerHTML = `<div class="message-system">${text}</div>`;
|
||||
} else {
|
||||
div.classList.add(
|
||||
sender === username ? "message-self-row" : "message-other-row",
|
||||
);
|
||||
div.classList.add(sender === username ? "message-self-row" : "message-other-row");
|
||||
div.innerHTML = `
|
||||
<article class="message-bubble ${sender === username ? "message-self" : "message-other"}">
|
||||
${sender && sender !== username ? `<div class="message-sender">${sender}</div>` : ""}
|
||||
@@ -131,10 +125,7 @@ function renderPresence() {
|
||||
if (!list) return;
|
||||
const sorted = [...onlineUsers].sort();
|
||||
list.innerHTML = sorted
|
||||
.map(
|
||||
(u) =>
|
||||
`<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`,
|
||||
)
|
||||
.map((u) => `<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
@@ -157,7 +148,7 @@ async function refreshSchedule() {
|
||||
}
|
||||
}
|
||||
|
||||
async function initPlayerAndChat() {
|
||||
export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: HlsClass }> = () => import("hls.js")) {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
const manifestUrl = resolveManifestUrl();
|
||||
|
||||
@@ -166,7 +157,7 @@ async function initPlayerAndChat() {
|
||||
video.addEventListener("loadedmetadata", () => video.play());
|
||||
} else {
|
||||
try {
|
||||
const { default: Hls } = await import("hls.js");
|
||||
const { default: Hls } = await loadHlsModule();
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(manifestUrl);
|
||||
@@ -185,9 +176,7 @@ async function initPlayerAndChat() {
|
||||
refreshSchedule();
|
||||
setInterval(refreshSchedule, 30000);
|
||||
|
||||
console.log(
|
||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||
);
|
||||
console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready");
|
||||
}
|
||||
|
||||
function toggleChatPanel() {
|
||||
@@ -220,9 +209,7 @@ function sendMessage() {
|
||||
function switchRoom(room: string) {
|
||||
currentRoom = room;
|
||||
document.getElementById("room-name")!.textContent =
|
||||
room === "main"
|
||||
? "Main Channel"
|
||||
: room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
||||
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
||||
addMessage("system", `Switched to ${room} room`);
|
||||
connectWebSocket(room);
|
||||
}
|
||||
|
||||
+7
-17
@@ -1,8 +1,7 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--font-body:
|
||||
"Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
--font-body: "Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
--font-display: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", Consolas, monospace;
|
||||
|
||||
@@ -118,12 +117,7 @@ body {
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink-0);
|
||||
background: radial-gradient(
|
||||
circle at 20% 10%,
|
||||
var(--bg-grad-a) 0%,
|
||||
var(--bg-grad-b) 50%,
|
||||
var(--bg-grad-c) 100%
|
||||
);
|
||||
background: radial-gradient(circle at 20% 10%, var(--bg-grad-a) 0%, var(--bg-grad-b) 50%, var(--bg-grad-c) 100%);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@@ -262,7 +256,7 @@ select {
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-1);
|
||||
padding: 0.62rem 0.9rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -411,14 +405,14 @@ select {
|
||||
|
||||
.lv-body {
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-shell {
|
||||
width: 100vw;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
@@ -467,11 +461,7 @@ select {
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--brand-contrast);
|
||||
background: linear-gradient(
|
||||
140deg,
|
||||
var(--badge-start) 0%,
|
||||
var(--badge-end) 100%
|
||||
);
|
||||
background: linear-gradient(140deg, var(--badge-start) 0%, var(--badge-end) 100%);
|
||||
}
|
||||
|
||||
.brand-text,
|
||||
@@ -821,7 +811,7 @@ select {
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--panel);
|
||||
padding: var(--space-4);
|
||||
padding: var(--space-2);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -1,25 +1,25 @@
|
||||
# Cloudflare Cache Rules for Linear Void (per ADR-001)
|
||||
|
||||
## Recommended Cache Rules for media.example.com
|
||||
## Recommended Cache Rules for media.linearvoid.tv
|
||||
|
||||
1. **.ts segments (immutable)**
|
||||
- Expression: (http.host eq "media.example.com" and http.request.uri.path contains ".ts")
|
||||
1. **.m4s and .ts segments (immutable)**
|
||||
- Expression: (http.host eq "media.linearvoid.tv" and (http.request.uri.path contains ".ts" or http.request.uri.path contains ".m4s"))
|
||||
- Cache status: Eligible for cache
|
||||
- Edge Cache TTL: 1 month (or `Cache-Control: public, max-age=31536000, immutable` from origin)
|
||||
- Cache Everything: Yes
|
||||
|
||||
2. **.m3u8 manifests (live)**
|
||||
- Expression: (http.host eq "media.example.com" and http.request.uri.path contains ".m3u8")
|
||||
- Expression: (http.host eq "media.linearvoid.tv" and http.request.uri.path contains ".m3u8")
|
||||
- Cache status: Eligible for cache
|
||||
- Edge Cache TTL: 10 seconds
|
||||
- Cache-Control: public, max-age=10, must-revalidate
|
||||
- Edge Cache TTL: 2 seconds
|
||||
- Cache-Control: public, max-age=2, must-revalidate
|
||||
|
||||
3. **Static assets**
|
||||
- Expression: (http.host eq "example.com" and http.request.uri.path contains ".js" or http.request.uri.path contains ".css")
|
||||
- Expression: (http.host eq "linearvoid.tv" and http.request.uri.path contains ".js" or http.request.uri.path contains ".css")
|
||||
- Edge Cache TTL: 1 year (with hash in filename for immutable caching)
|
||||
|
||||
**Tiered Cache**: Enable Smart Tiered Cache for R2 custom domain to reduce origin requests.
|
||||
|
||||
**WAF**: Enable basic rate limiting and bot management for /ws and admin routes.
|
||||
|
||||
These rules ensure high cache hit ratio for .ts segments while keeping live manifests fresh. Monitor cache hit ratio in Cloudflare analytics.
|
||||
These rules ensure high cache hit ratio for .m4s and .ts segments while keeping live manifests fresh. Monitor cache hit ratio in Cloudflare analytics.
|
||||
@@ -6,8 +6,8 @@ addEventListener("fetch", (event) => {
|
||||
async function handleRequest(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Add immutable cache headers for .ts segments
|
||||
if (url.pathname.endsWith(".ts")) {
|
||||
// Add immutable cache headers for .m4s segments
|
||||
if (url.pathname.endsWith(".m4s")) {
|
||||
const response = await fetch(request);
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
|
||||
@@ -34,6 +34,33 @@
|
||||
"title": "Chat Scrape Health",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "up{job=\"chat\"}" }]
|
||||
},
|
||||
{
|
||||
"title": "Cache Hit Ratio",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "linearvoid_cache_hit_ratio"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Redis Memory Usage",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "linearvoid_chat_redis_memory_usage_ratio"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Chat Request Latency p95",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(linearvoid_chat_request_duration_seconds_bucket[5m])) by (le))"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,13 +38,13 @@ groups:
|
||||
description: "Playout has switched to emergency slate. Check playout logs."
|
||||
|
||||
- alert: HighChatLatency
|
||||
expr: rate(linearvoid_chat_messages_total[5m]) < 0.01
|
||||
expr: histogram_quantile(0.95, sum(rate(linearvoid_chat_request_duration_seconds_bucket[5m])) by (le)) > 1
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Unusually low chat message rate"
|
||||
description: "Message rate below 0.01/s for 5 minutes. Check chat service health."
|
||||
summary: "Chat request latency is high"
|
||||
description: "Chat request p95 has been above 1s for 5 minutes. Check chat handlers and dependencies."
|
||||
|
||||
- alert: PrometheusWALFailing
|
||||
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
|
||||
@@ -64,6 +64,24 @@ groups:
|
||||
summary: "High CPU usage"
|
||||
description: "CPU usage above 80% for 10 minutes on {{ $labels.instance }}."
|
||||
|
||||
- alert: LowCacheHitRatio
|
||||
expr: linearvoid_cache_hit_ratio < 0.9
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Cloudflare cache hit ratio is below target"
|
||||
description: "Cache hit ratio has been below 90% for 15 minutes on {{ $labels.hostname }}."
|
||||
|
||||
- alert: HighRedisMemoryUsage
|
||||
expr: linearvoid_chat_redis_memory_usage_ratio > 0.85
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Redis memory usage is above target"
|
||||
description: "Redis memory usage has been above 85% for 10 minutes on {{ $labels.instance }}."
|
||||
|
||||
- alert: LowDiskSpace
|
||||
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
|
||||
for: 5m
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
||||
const zoneTag = process.env.CLOUDFLARE_ZONE_TAG;
|
||||
const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv";
|
||||
const windowMinutes = Number.parseInt(process.env.CLOUDFLARE_CACHE_HIT_RATIO_WINDOW_MINUTES || "60", 10);
|
||||
const endpoint = process.env.CLOUDFLARE_GRAPHQL_ENDPOINT || "https://api.cloudflare.com/client/v4/graphql";
|
||||
|
||||
if (!apiToken || !zoneTag) {
|
||||
console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - windowMinutes * 60 * 1000);
|
||||
|
||||
const iso = (value) => value.toISOString();
|
||||
|
||||
const query = `
|
||||
query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) {
|
||||
viewer {
|
||||
zones(filter: { zoneTag: $zoneTag }) {
|
||||
requests: httpRequestsAdaptiveGroups(
|
||||
limit: 1000
|
||||
filter: {
|
||||
datetime_geq: $start
|
||||
datetime_lt: $end
|
||||
clientRequestHTTPHost: $hostname
|
||||
requestSource: "eyeball"
|
||||
}
|
||||
) {
|
||||
count
|
||||
dimensions {
|
||||
cacheStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: {
|
||||
zoneTag,
|
||||
hostname,
|
||||
start: iso(start),
|
||||
end: iso(end),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.errors) {
|
||||
const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const groups = payload?.data?.viewer?.zones?.[0]?.requests || [];
|
||||
let totalRequests = 0;
|
||||
let hitRequests = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
const count = Number(group?.count || 0);
|
||||
const status = String(group?.dimensions?.cacheStatus || "").toUpperCase();
|
||||
totalRequests += count;
|
||||
if (status === "HIT") {
|
||||
hitRequests += count;
|
||||
}
|
||||
}
|
||||
|
||||
const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0;
|
||||
|
||||
console.log(
|
||||
`# HELP linearvoid_cache_hit_ratio Cloudflare cache hit ratio for the selected hostname and time window.`,
|
||||
);
|
||||
console.log(`# TYPE linearvoid_cache_hit_ratio gauge`);
|
||||
console.log(
|
||||
`linearvoid_cache_hit_ratio{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${ratio.toFixed(6)}`,
|
||||
);
|
||||
console.log(`# HELP linearvoid_cache_hit_requests_total Cloudflare cache HIT requests counted in the time window.`);
|
||||
console.log(`# TYPE linearvoid_cache_hit_requests_total counter`);
|
||||
console.log(
|
||||
`linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`,
|
||||
);
|
||||
console.log(
|
||||
`# HELP linearvoid_cache_total_requests_total Cloudflare total eyeball requests counted in the time window.`,
|
||||
);
|
||||
console.log(`# TYPE linearvoid_cache_total_requests_total counter`);
|
||||
console.log(
|
||||
`linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env pwsh
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js'
|
||||
|
||||
& node $nodeScript @args
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,90 @@
|
||||
# Load Test Scaffolding
|
||||
|
||||
These files back the scaling/performance playbook.
|
||||
|
||||
## WebSocket chat
|
||||
|
||||
Run with an explicit WebSocket URL, for example:
|
||||
|
||||
```bash
|
||||
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml
|
||||
```
|
||||
|
||||
Capture output to an artifact file for the worksheet:
|
||||
|
||||
```bash
|
||||
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
PowerShell equivalent:
|
||||
|
||||
```powershell
|
||||
$env:WS_URL = 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
Summarize key counters, rates, and available percentile stats from a saved Artillery JSON report:
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/summarize-artillery.ps1 -ReportFile artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
## Admin API
|
||||
|
||||
Run with a cookie or session header that can access admin endpoints:
|
||||
|
||||
```bash
|
||||
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run scripts/loadtest/admin-api.js
|
||||
```
|
||||
|
||||
Capture machine-readable metrics and a text summary:
|
||||
|
||||
```bash
|
||||
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js > artifacts/admin-api-output.txt
|
||||
```
|
||||
|
||||
PowerShell equivalent:
|
||||
|
||||
```powershell
|
||||
$env:ADMIN_BASE_URL = 'https://linearvoid.tv'
|
||||
$env:ADMIN_SESSION_COOKIE = 'session=...'
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | Tee-Object -FilePath artifacts/admin-api-output.txt
|
||||
```
|
||||
|
||||
The script emits global and per-endpoint thresholds (overview, analytics, chat-log, users) so p95 and error-rate regressions are visible per route.
|
||||
|
||||
Summarize endpoint p95 and failure rates from a saved k6 JSON summary:
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/summarize-k6.ps1 -SummaryFile artifacts/admin-api-summary.json
|
||||
```
|
||||
|
||||
## PowerShell helper
|
||||
|
||||
Use the helper to run one scenario or both and automatically write outputs into a dated artifact folder:
|
||||
|
||||
- The helper resolves scenario paths from its own script location, so it can be run from any current working directory.
|
||||
- WebSocket runs also emit `ws-chat-summary.txt` with connection/error counters and available summary percentiles.
|
||||
- Admin runs also emit `admin-api-endpoint-summary.txt` with per-endpoint p95/error values.
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario ws -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario admin -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario all -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main' -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Adjust target URLs for test lab or production.
|
||||
- Keep the same commit SHA or image tag when comparing runs.
|
||||
- Record results using the worksheet in `docs/operations/scaling-performance-playbook.md`.
|
||||
- Store raw outputs under an `artifacts/` folder per run date (for example `artifacts/2026-08-09/`).
|
||||
- The repository `.gitignore` excludes benchmark artifacts by default.
|
||||
@@ -0,0 +1,58 @@
|
||||
import http from "k6/http";
|
||||
import { check, sleep } from "k6";
|
||||
|
||||
const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv";
|
||||
const adminCookie = __ENV.ADMIN_SESSION_COOKIE || "";
|
||||
const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {};
|
||||
|
||||
const endpoints = [
|
||||
{ name: "overview", path: "/api/admin/overview" },
|
||||
{ name: "analytics", path: "/api/admin/analytics" },
|
||||
{ name: "chat_log", path: "/api/admin/chat-log" },
|
||||
{ name: "users", path: "/api/admin/users" },
|
||||
];
|
||||
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: "30s", target: 10 },
|
||||
{ duration: "60s", target: 50 },
|
||||
{ duration: "60s", target: 100 },
|
||||
{ duration: "30s", target: 0 },
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ["p(95)<1000"],
|
||||
http_req_failed: ["rate<0.01"],
|
||||
"http_req_duration{endpoint:overview}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:analytics}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:chat_log}": ["p(95)<1000"],
|
||||
"http_req_duration{endpoint:users}": ["p(95)<1000"],
|
||||
"http_req_failed{endpoint:overview}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:analytics}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:chat_log}": ["rate<0.01"],
|
||||
"http_req_failed{endpoint:users}": ["rate<0.01"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const requests = endpoints.map((endpoint) => [
|
||||
"GET",
|
||||
`${baseUrl}${endpoint.path}`,
|
||||
null,
|
||||
{
|
||||
...requestParams,
|
||||
tags: {
|
||||
endpoint: endpoint.name,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const responses = http.batch(requests);
|
||||
|
||||
responses.forEach((response, index) => {
|
||||
const endpointPath = endpoints[index].path;
|
||||
check(response, {
|
||||
[`status is 200 for ${endpointPath}`]: (r) => r.status === 200,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(1);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[ValidateSet('ws', 'admin', 'all')]
|
||||
[string]$Scenario = 'all',
|
||||
[string]$ArtifactRoot = 'artifacts',
|
||||
[string]$WsUrl = $env:WS_URL,
|
||||
[string]$AdminBaseUrl = $env:ADMIN_BASE_URL,
|
||||
[string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..')
|
||||
$wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml'
|
||||
$wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1'
|
||||
$adminScriptPath = Join-Path $scriptDir 'admin-api.js'
|
||||
$adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1'
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) {
|
||||
$artifactBase = $ArtifactRoot
|
||||
} else {
|
||||
$artifactBase = Join-Path $repoRoot $ArtifactRoot
|
||||
}
|
||||
|
||||
if ($Scenario -in @('ws', 'all')) {
|
||||
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||
throw 'npx is required for ws scenario but was not found in PATH.'
|
||||
}
|
||||
}
|
||||
|
||||
if ($Scenario -in @('admin', 'all')) {
|
||||
if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) {
|
||||
throw 'k6 is required for admin scenario but was not found in PATH.'
|
||||
}
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
|
||||
$artifactDir = Join-Path $artifactBase $timestamp
|
||||
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
|
||||
|
||||
function Invoke-WebSocketLoadTest {
|
||||
param(
|
||||
[string]$TargetWsUrl,
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) {
|
||||
throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.'
|
||||
}
|
||||
|
||||
$env:WS_URL = $TargetWsUrl
|
||||
& npx artillery run $wsScenarioPath --output $OutputPath
|
||||
}
|
||||
|
||||
function Invoke-AdminApiLoadTest {
|
||||
param(
|
||||
[string]$BaseUrl,
|
||||
[string]$SessionCookie,
|
||||
[string]$SummaryPath,
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
||||
throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($SessionCookie)) {
|
||||
throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.'
|
||||
}
|
||||
|
||||
$env:ADMIN_BASE_URL = $BaseUrl
|
||||
$env:ADMIN_SESSION_COOKIE = $SessionCookie
|
||||
& k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath
|
||||
}
|
||||
|
||||
if ($Scenario -in @('ws', 'all')) {
|
||||
if (-not (Test-Path $wsSummaryScriptPath)) {
|
||||
throw "Missing summary helper: $wsSummaryScriptPath"
|
||||
}
|
||||
|
||||
$wsOutput = Join-Path $artifactDir 'ws-chat-report.json'
|
||||
$wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt'
|
||||
Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput
|
||||
& pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary
|
||||
}
|
||||
|
||||
if ($Scenario -in @('admin', 'all')) {
|
||||
if (-not (Test-Path $adminSummaryScriptPath)) {
|
||||
throw "Missing summary helper: $adminSummaryScriptPath"
|
||||
}
|
||||
|
||||
$adminSummary = Join-Path $artifactDir 'admin-api-summary.json'
|
||||
$adminOutput = Join-Path $artifactDir 'admin-api-output.txt'
|
||||
$adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt'
|
||||
Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput
|
||||
& pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary
|
||||
}
|
||||
|
||||
Write-Host "Artifacts written to $artifactDir"
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReportFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $ReportFile)) {
|
||||
throw "Report file not found: $ReportFile"
|
||||
}
|
||||
|
||||
$report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100
|
||||
$aggregate = $report.aggregate
|
||||
|
||||
if (-not $aggregate) {
|
||||
throw 'Artillery report is missing aggregate data.'
|
||||
}
|
||||
|
||||
function Get-PropertyValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[object]$Object,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if (-not $Object) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$property = $Object.PSObject.Properties[$Name]
|
||||
if ($property) {
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Format-StatLine {
|
||||
param(
|
||||
[string]$Prefix,
|
||||
[object]$Stats
|
||||
)
|
||||
|
||||
if (-not $Stats) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$parts = @()
|
||||
foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) {
|
||||
$value = Get-PropertyValue -Object $Stats -Name $name
|
||||
if ($null -ne $value) {
|
||||
$parts += "$name=$value"
|
||||
}
|
||||
}
|
||||
|
||||
if ($parts.Count -eq 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return "$Prefix: $($parts -join ', ')"
|
||||
}
|
||||
|
||||
$counters = Get-PropertyValue -Object $aggregate -Name 'counters'
|
||||
$rates = Get-PropertyValue -Object $aggregate -Name 'rates'
|
||||
$summaries = Get-PropertyValue -Object $aggregate -Name 'summaries'
|
||||
|
||||
$created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0)
|
||||
$failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0)
|
||||
$completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0)
|
||||
$errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 }
|
||||
|
||||
Write-Output '=== Artillery WebSocket Summary ==='
|
||||
Write-Output "source: $ReportFile"
|
||||
|
||||
$timestamp = Get-PropertyValue -Object $report -Name 'timestamp'
|
||||
if ($timestamp) {
|
||||
Write-Output "timestamp: $timestamp"
|
||||
}
|
||||
|
||||
if ($null -ne $created) {
|
||||
Write-Output "vusers.created: $created"
|
||||
}
|
||||
if ($null -ne $completed) {
|
||||
Write-Output "vusers.completed: $completed"
|
||||
}
|
||||
if ($null -ne $failed) {
|
||||
Write-Output "vusers.failed: $failed"
|
||||
}
|
||||
Write-Output "vusers.failure_rate_percent: $errorRate"
|
||||
|
||||
if ($counters) {
|
||||
foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) {
|
||||
$value = Get-PropertyValue -Object $counters -Name $metric
|
||||
if ($null -ne $value) {
|
||||
Write-Output "$metric: $value"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($rates) {
|
||||
foreach ($property in $rates.PSObject.Properties) {
|
||||
Write-Output "rate.$($property.Name): $($property.Value)"
|
||||
}
|
||||
}
|
||||
|
||||
if ($summaries) {
|
||||
foreach ($property in $summaries.PSObject.Properties) {
|
||||
$line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value
|
||||
if ($line) {
|
||||
Write-Output $line
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env pwsh
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SummaryFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $SummaryFile)) {
|
||||
throw "Summary file not found: $SummaryFile"
|
||||
}
|
||||
|
||||
$summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json
|
||||
$metrics = $summary.metrics
|
||||
|
||||
if (-not $metrics) {
|
||||
throw 'Invalid k6 summary file: missing metrics object.'
|
||||
}
|
||||
|
||||
$endpoints = @('overview', 'analytics', 'chat_log', 'users')
|
||||
|
||||
function Get-MetricValue {
|
||||
param(
|
||||
[object]$Metric,
|
||||
[string]$Key
|
||||
)
|
||||
|
||||
if (-not $Metric) {
|
||||
return $null
|
||||
}
|
||||
if (-not $Metric.values) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$value = $Metric.values.$Key
|
||||
if ($null -ne $value) {
|
||||
return [double]$value
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)'
|
||||
$globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate'
|
||||
|
||||
Write-Output "k6 Summary: $SummaryFile"
|
||||
if ($null -ne $globalDuration) {
|
||||
Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration)
|
||||
}
|
||||
if ($null -ne $globalFailRate) {
|
||||
Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate)
|
||||
}
|
||||
Write-Output ''
|
||||
|
||||
$rows = foreach ($endpoint in $endpoints) {
|
||||
$durationMetricName = "http_req_duration{endpoint:$endpoint}"
|
||||
$failMetricName = "http_req_failed{endpoint:$endpoint}"
|
||||
|
||||
$durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)'
|
||||
$failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate'
|
||||
|
||||
[PSCustomObject]@{
|
||||
Endpoint = $endpoint
|
||||
P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null }
|
||||
FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null }
|
||||
}
|
||||
}
|
||||
|
||||
$rows | Format-Table -AutoSize | Out-String | Write-Output
|
||||
@@ -0,0 +1,28 @@
|
||||
config:
|
||||
target: "{{ $processEnvironment.WS_URL }}"
|
||||
phases:
|
||||
- duration: 60
|
||||
arrivalRate: 10
|
||||
name: warmup-10
|
||||
- duration: 120
|
||||
arrivalRate: 100
|
||||
name: sustain-100
|
||||
- duration: 180
|
||||
arrivalRate: 500
|
||||
name: sustain-500
|
||||
- duration: 240
|
||||
arrivalRate: 1000
|
||||
name: sustain-1000
|
||||
engines:
|
||||
ws: {}
|
||||
scenarios:
|
||||
- engine: ws
|
||||
name: chat-presence-and-send
|
||||
flow:
|
||||
- think: 1
|
||||
- send:
|
||||
text: '{"type":"ping","text":"loadtest"}'
|
||||
- think: 1
|
||||
- send:
|
||||
text: '{"type":"ping","text":"loadtest-2"}'
|
||||
- think: 1
|
||||
Reference in new issue
Block a user