Refactor load testing scripts to support Bash and PowerShell
CI / test (push) Successful in 2m50s
CI / docker (push) Successful in 5m12s

- Updated README.md to reflect changes in load testing commands and added usage examples for Bash scripts.
- Converted PowerShell load testing scripts to Bash equivalents for better cross-platform compatibility.
- Added new Bash scripts: run-loadtests.sh, summarize-artillery.sh, and summarize-k6.sh to mirror existing PowerShell functionality.
- Updated admin-api.js and ws-chat.yml for consistency and improved performance metrics reporting.
- Enhanced error handling and output formatting in summary scripts for better readability.
This commit is contained in:
zwitschi committed 2026-09-02 19:47:34 +02:00
1 parent 9f60087a4c
commit 33b6296c5e
82 files changed
+19682 -21133

No files matched your search

+55 -55
View File
@@ -1,55 +1,55 @@
# EditorConfig is awesome: https://editorconfig.org # EditorConfig is awesome: https://editorconfig.org
root = true root = true
# ------------------------------------------------- # -------------------------------------------------
# General settings – apply to all files # General settings – apply to all files
# ------------------------------------------------- # -------------------------------------------------
[*] [*]
charset = utf-8 charset = utf-8
end_of_line = lf end_of_line = lf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
# ------------------------------------------------- # -------------------------------------------------
# Markdown (*.md) # Markdown (*.md)
# ------------------------------------------------- # -------------------------------------------------
[*.md] [*.md]
max_line_length = off # Markdown lines can be long max_line_length = off # Markdown lines can be long
trim_trailing_whitespace = false # Preserve intentional spaces trim_trailing_whitespace = false # Preserve intentional spaces
# ------------------------------------------------- # -------------------------------------------------
# Go (*.go) # Go (*.go)
# ------------------------------------------------- # -------------------------------------------------
[*.go] [*.go]
indent_style = tab indent_style = tab
indent_size = 4 indent_size = 4
trim_trailing_whitespace = true trim_trailing_whitespace = true
max_line_length = 100 max_line_length = 100
# ------------------------------------------------- # -------------------------------------------------
# JavaScript & TypeScript (*.js, *.jsx, *.ts, *.tsx) # JavaScript & TypeScript (*.js, *.jsx, *.ts, *.tsx)
# ------------------------------------------------- # -------------------------------------------------
[*.{js,jsx,ts,tsx}] [*.{js,jsx,ts,tsx}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
max_line_length = 120 max_line_length = 120
# ------------------------------------------------- # -------------------------------------------------
# HTML (*.html, *.htm) # HTML (*.html, *.htm)
# ------------------------------------------------- # -------------------------------------------------
[*.{html,htm}] [*.{html,htm}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
max_line_length = 120 max_line_length = 120
trim_trailing_whitespace = true trim_trailing_whitespace = true
# ------------------------------------------------- # -------------------------------------------------
# CSS & SCSS (*.css, *.scss) # CSS & SCSS (*.css, *.scss)
# ------------------------------------------------- # -------------------------------------------------
[*.{css,scss}] [*.{css,scss}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
max_line_length = 120 max_line_length = 120
trim_trailing_whitespace = true trim_trailing_whitespace = true
+72 -72
View File
@@ -1,73 +1,73 @@
# Environment variables for Linear Void # Environment variables for Linear Void
# Copy to .env and fill in real values for local/development use. # Copy to .env and fill in real values for local/development use.
# Production secrets should be managed via Docker secrets, Cloudflare, or Hetzner environment. # Production secrets should be managed via Docker secrets, Cloudflare, or Hetzner environment.
# Cloudflare R2 (S3-compatible) # Cloudflare R2 (S3-compatible)
R2_ACCESS_KEY_ID=your-r2-access-key R2_ACCESS_KEY_ID=your-r2-access-key
R2_SECRET_ACCESS_KEY=your-r2-secret-key R2_SECRET_ACCESS_KEY=your-r2-secret-key
R2_BUCKET_NAME=linearvoid-media R2_BUCKET_NAME=linearvoid-media
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
R2_REGION=auto R2_REGION=auto
R2_KEEP_RUNS=2 R2_KEEP_RUNS=2
R2_MAX_RETENTION_BYTES=10737418240 R2_MAX_RETENTION_BYTES=10737418240
# Cloudflare (for custom domain, cache rules, Workers if used) # Cloudflare (for custom domain, cache rules, Workers if used)
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
CLOUDFLARE_ACCOUNT_ID=your-account-id CLOUDFLARE_ACCOUNT_ID=your-account-id
MEDIA_DOMAIN=media.linearvoid.tv MEDIA_DOMAIN=media.linearvoid.tv
# Public frontend URLs (Vite reads these from the repository root via frontend/vite.config.ts) # Public frontend URLs (Vite reads these from the repository root via frontend/vite.config.ts)
# Preferred local setup in this repo uses the real public domains through Cloudflare/Nginx Proxy Manager. # Preferred local setup in this repo uses the real public domains through Cloudflare/Nginx Proxy Manager.
VITE_PUBLIC_APP_URL=https://linearvoid.tv VITE_PUBLIC_APP_URL=https://linearvoid.tv
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8 VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=admin
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main VITE_PUBLIC_ADMIN_DEFAULT_ROOM=main
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50 VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me ADMIN_PASSWORD=change-me
ADMIN_SESSION_TTL_HOURS=12 ADMIN_SESSION_TTL_HOURS=12
# Optional direct-local fallback when bypassing the public proxy: # Optional direct-local fallback when bypassing the public proxy:
# VITE_PUBLIC_APP_URL=http://localhost # VITE_PUBLIC_APP_URL=http://localhost
# VITE_PUBLIC_WS_URL=ws://localhost/ws # VITE_PUBLIC_WS_URL=ws://localhost/ws
# VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8 # VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8
# Gitea Container Registry (for self-hosted Docker images) # Gitea Container Registry (for self-hosted Docker images)
CI_REGISTRY_URL=git.allucanget.biz CI_REGISTRY_URL=git.allucanget.biz
CI_REGISTRY_USER=your-gitea-username CI_REGISTRY_USER=your-gitea-username
CI_REGISTRY_TOKEN=your-gitea-token CI_REGISTRY_TOKEN=your-gitea-token
CI_REGISTRY_NAMESPACE=zwitschi/linearvoid CI_REGISTRY_NAMESPACE=zwitschi/linearvoid
# Gitea API access (optional, for CI/release automation) # Gitea API access (optional, for CI/release automation)
CI_SERVER_URL=https://git.allucanget.biz CI_SERVER_URL=https://git.allucanget.biz
# Chat / Application Database (compose-safe PostgreSQL by default, SQLite optional) # Chat / Application Database (compose-safe PostgreSQL by default, SQLite optional)
DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
# Or for SQLite-only local development: # Or for SQLite-only local development:
# DATABASE_URL=sqlite://./data/chat.db # DATABASE_URL=sqlite://./data/chat.db
# Redis (for presence, rate limiting, pub/sub) # Redis (for presence, rate limiting, pub/sub)
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
# Service Ports (for local docker-compose) # Service Ports (for local docker-compose)
PLAYOUT_PORT=8080 PLAYOUT_PORT=8080
CHAT_PORT=3000 CHAT_PORT=3000
NGINX_PORT=80 NGINX_PORT=80
# Playout configuration # Playout configuration
PLAYLIST_PATH=/app/playlist/playlist.txt PLAYLIST_PATH=/app/playlist/playlist.txt
HLS_SEGMENT_DURATION=2 HLS_SEGMENT_DURATION=2
HLS_WINDOW_SIZE=12 HLS_WINDOW_SIZE=12
# Shared runtime config (written by chat admin via /api/admin/config, read by playout). # Shared runtime config (written by chat admin via /api/admin/config, read by playout).
# Both resolve to the same host file through the ./data/playlist volume. # Both resolve to the same host file through the ./data/playlist volume.
CONFIG_PATH=/app/playlist/config.json CONFIG_PATH=/app/playlist/config.json
PLAYOUT_CONFIG_PATH=/app/playlist/config.json PLAYOUT_CONFIG_PATH=/app/playlist/config.json
# Logging / Monitoring # Logging / Monitoring
LOG_LEVEL=info LOG_LEVEL=info
PROMETHEUS_PORT=9090 PROMETHEUS_PORT=9090
GRAFANA_PORT=3001 GRAFANA_PORT=3001
# Development flags # Development flags
NODE_ENV=development NODE_ENV=development
GO_ENV=development GO_ENV=development
+288 -288
View File
@@ -1,288 +1,288 @@
# CD workflow — TEST LAB DEPLOYMENT # CD workflow — TEST LAB DEPLOYMENT
# Deploys to test lab host 192.168.88.40:2375. # Deploys to test lab host 192.168.88.40:2375.
# Production Hetzner target TBD — switch DOCKER_HOST when provisioned. # Production Hetzner target TBD — switch DOCKER_HOST when provisioned.
# See DEPLOYMENT.md Production Deployment section for cutover plan. # See DEPLOYMENT.md Production Deployment section for cutover plan.
name: CD name: CD
on: on:
workflow_run: workflow_run:
workflows: [CI] workflows: [CI]
types: [completed] types: [completed]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
deploy: deploy:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: [ubuntu-latest] runs-on: [ubuntu-latest]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to registry on remote host - name: Login to registry on remote host
env: env:
DOCKER_HOST: tcp://192.168.88.40:2375 DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
echo "${{ secrets.CI_REGISTRY_TOKEN }}" | docker login git.allucanget.biz -u "${{ secrets.CI_REGISTRY_USER }}" --password-stdin echo "${{ secrets.CI_REGISTRY_TOKEN }}" | docker login git.allucanget.biz -u "${{ secrets.CI_REGISTRY_USER }}" --password-stdin
- name: Prepare environment - name: Prepare environment
run: | run: |
cat > .env << 'EOF' cat > .env << 'EOF'
# Database & Redis # Database & Redis
DATABASE_URL="${{ secrets.DATABASE_URL }}" DATABASE_URL="${{ secrets.DATABASE_URL }}"
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
# Playout # Playout
PLAYLIST_PATH=/app/playlist/playlist.txt PLAYLIST_PATH=/app/playlist/playlist.txt
HLS_SEGMENT_DURATION=2 HLS_SEGMENT_DURATION=2
HLS_WINDOW_SIZE=12 HLS_WINDOW_SIZE=12
LOG_LEVEL=info LOG_LEVEL=info
# Cloudflare R2 (required for HLS segment sync) # Cloudflare R2 (required for HLS segment sync)
R2_ACCESS_KEY_ID="${{ secrets.R2_ACCESS_KEY_ID }}" R2_ACCESS_KEY_ID="${{ secrets.R2_ACCESS_KEY_ID }}"
R2_SECRET_ACCESS_KEY="${{ secrets.R2_SECRET_ACCESS_KEY }}" R2_SECRET_ACCESS_KEY="${{ secrets.R2_SECRET_ACCESS_KEY }}"
R2_BUCKET_NAME="${{ secrets.R2_BUCKET_NAME }}" R2_BUCKET_NAME="${{ secrets.R2_BUCKET_NAME }}"
R2_ENDPOINT="${{ secrets.R2_ENDPOINT }}" R2_ENDPOINT="${{ secrets.R2_ENDPOINT }}"
R2_REGION=auto R2_REGION=auto
# Cloudflare # Cloudflare
CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}" CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}"
CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
MEDIA_DOMAIN=${{ secrets.MEDIA_DOMAIN }} MEDIA_DOMAIN=${{ secrets.MEDIA_DOMAIN }}
# Frontend public URLs # Frontend public URLs
VITE_PUBLIC_APP_URL=${{ secrets.VITE_PUBLIC_APP_URL }} VITE_PUBLIC_APP_URL=${{ secrets.VITE_PUBLIC_APP_URL }}
VITE_PUBLIC_WS_URL=${{ secrets.VITE_PUBLIC_WS_URL }} VITE_PUBLIC_WS_URL=${{ secrets.VITE_PUBLIC_WS_URL }}
VITE_PUBLIC_HLS_URL=${{ secrets.VITE_PUBLIC_HLS_URL }} VITE_PUBLIC_HLS_URL=${{ secrets.VITE_PUBLIC_HLS_URL }}
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }} VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }}
VITE_PUBLIC_ADMIN_DEFAULT_ROOM=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }} VITE_PUBLIC_ADMIN_DEFAULT_ROOM=${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }}
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50 VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT=50
# Admin auth # Admin auth
ADMIN_USERNAME="${{ secrets.ADMIN_USERNAME }}" ADMIN_USERNAME="${{ secrets.ADMIN_USERNAME }}"
ADMIN_PASSWORD="${{ secrets.ADMIN_PASSWORD }}" ADMIN_PASSWORD="${{ secrets.ADMIN_PASSWORD }}"
ADMIN_SESSION_TTL_HOURS=12 ADMIN_SESSION_TTL_HOURS=12
# Nginx # Nginx
NGINX_PORT=80 NGINX_PORT=80
EOF EOF
- name: Seed Prometheus config on remote host - name: Seed Prometheus config on remote host
env: env:
DOCKER_HOST: tcp://192.168.88.40:2375 DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
docker run --rm \ docker run --rm \
-v linearvoid_prometheus-config:/etc/prometheus \ -v linearvoid_prometheus-config:/etc/prometheus \
alpine:latest sh -c ' alpine:latest sh -c '
cat > /etc/prometheus/prometheus.yml << "PROMEOF" cat > /etc/prometheus/prometheus.yml << "PROMEOF"
global: global:
scrape_interval: 15s scrape_interval: 15s
evaluation_interval: 15s evaluation_interval: 15s
rule_files: rule_files:
- "/etc/prometheus/alerts.yml" - "/etc/prometheus/alerts.yml"
scrape_configs: scrape_configs:
- job_name: "chat" - job_name: "chat"
static_configs: static_configs:
- targets: ["chat:3000"] - targets: ["chat:3000"]
- job_name: "playout" - job_name: "playout"
static_configs: static_configs:
- targets: ["playout:8081"] - targets: ["playout:8081"]
- job_name: "nginx" - job_name: "nginx"
static_configs: static_configs:
- targets: ["nginx-exporter:9113"] - targets: ["nginx-exporter:9113"]
- job_name: "redis" - job_name: "redis"
static_configs: static_configs:
- targets: ["redis-exporter:9121"] - targets: ["redis-exporter:9121"]
- job_name: "postgres" - job_name: "postgres"
static_configs: static_configs:
- targets: ["postgres-exporter:9187"] - targets: ["postgres-exporter:9187"]
- job_name: "node" - job_name: "node"
static_configs: static_configs:
- targets: ["node-exporter:9100"] - targets: ["node-exporter:9100"]
- job_name: "prometheus" - job_name: "prometheus"
static_configs: static_configs:
- targets: ["localhost:9090"] - targets: ["localhost:9090"]
PROMEOF PROMEOF
cat > /etc/prometheus/alerts.yml << "ALERTSEOF" cat > /etc/prometheus/alerts.yml << "ALERTSEOF"
groups: groups:
- name: linearvoid - name: linearvoid
rules: rules:
- alert: ChatDown - alert: ChatDown
expr: up{job="chat"} == 0 expr: up{job="chat"} == 0
for: 2m for: 2m
labels: labels:
severity: critical severity: critical
annotations: annotations:
summary: "Chat service is down" summary: "Chat service is down"
- alert: PlayoutDown - alert: PlayoutDown
expr: up{job="playout"} == 0 expr: up{job="playout"} == 0
for: 2m for: 2m
labels: labels:
severity: critical severity: critical
annotations: annotations:
summary: "Playout service is down" summary: "Playout service is down"
- alert: RedisDown - alert: RedisDown
expr: linearvoid_chat_redis_up == 0 expr: linearvoid_chat_redis_up == 0
for: 2m for: 2m
labels: labels:
severity: warning severity: warning
annotations: annotations:
summary: "Redis is unreachable" summary: "Redis is unreachable"
- alert: PlayoutSlateActive - alert: PlayoutSlateActive
expr: linearvoid_playout_mode == 1 expr: linearvoid_playout_mode == 1
for: 1m for: 1m
labels: labels:
severity: warning severity: warning
annotations: annotations:
summary: "Playout in slate fallback" summary: "Playout in slate fallback"
- alert: HighChatLatency - alert: HighChatLatency
expr: rate(linearvoid_chat_messages_total[5m]) < 0.01 expr: rate(linearvoid_chat_messages_total[5m]) < 0.01
for: 5m for: 5m
labels: labels:
severity: warning severity: warning
annotations: annotations:
summary: "Unusually low chat message rate" summary: "Unusually low chat message rate"
- alert: PrometheusWALFailing - alert: PrometheusWALFailing
expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0 expr: increase(prometheus_tsdb_wal_writes_failed_total[5m]) > 0
for: 5m for: 5m
labels: labels:
severity: warning severity: warning
annotations: annotations:
summary: "Prometheus WAL writes failing" summary: "Prometheus WAL writes failing"
- alert: HighCPU - alert: HighCPU
expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80 expr: 100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
for: 10m for: 10m
labels: labels:
severity: warning severity: warning
annotations: annotations:
summary: "High CPU usage" summary: "High CPU usage"
- alert: LowDiskSpace - alert: LowDiskSpace
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10 expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10
for: 5m for: 5m
labels: labels:
severity: warning severity: warning
annotations: annotations:
summary: "Low disk space" summary: "Low disk space"
ALERTSEOF ALERTSEOF
' '
- name: Seed Grafana provisioning on remote host - name: Seed Grafana provisioning on remote host
env: env:
DOCKER_HOST: tcp://192.168.88.40:2375 DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
docker run --rm \ docker run --rm \
-v linearvoid_grafana-provisioning:/etc/grafana/provisioning \ -v linearvoid_grafana-provisioning:/etc/grafana/provisioning \
alpine:latest sh -c ' alpine:latest sh -c '
mkdir -p /etc/grafana/provisioning/datasources /etc/grafana/provisioning/dashboards mkdir -p /etc/grafana/provisioning/datasources /etc/grafana/provisioning/dashboards
cat > /etc/grafana/provisioning/datasources/datasource.yml << "DSEOF" cat > /etc/grafana/provisioning/datasources/datasource.yml << "DSEOF"
apiVersion: 1 apiVersion: 1
datasources: datasources:
- name: Prometheus - name: Prometheus
type: prometheus type: prometheus
access: proxy access: proxy
url: http://prometheus:9090 url: http://prometheus:9090
isDefault: true isDefault: true
editable: true editable: true
DSEOF DSEOF
cat > /etc/grafana/provisioning/dashboards/dashboard.yml << "PROVEOF" cat > /etc/grafana/provisioning/dashboards/dashboard.yml << "PROVEOF"
apiVersion: 1 apiVersion: 1
providers: providers:
- name: "default" - name: "default"
orgId: 1 orgId: 1
folder: "" folder: ""
type: file type: file
disableDeletion: false disableDeletion: false
updateIntervalSeconds: 10 updateIntervalSeconds: 10
allowUiUpdates: true allowUiUpdates: true
options: options:
path: /etc/grafana/provisioning/dashboards path: /etc/grafana/provisioning/dashboards
PROVEOF PROVEOF
cat > /etc/grafana/provisioning/dashboards/linearvoid-app.json << "DBEOF" cat > /etc/grafana/provisioning/dashboards/linearvoid-app.json << "DBEOF"
{ {
"title": "Linear Void Monitoring", "title": "Linear Void Monitoring",
"panels": [ "panels": [
{ {
"title": "Concurrent Viewers", "title": "Concurrent Viewers",
"type": "timeseries", "type": "timeseries",
"targets": [{ "expr": "linearvoid_chat_connected_clients" }] "targets": [{ "expr": "linearvoid_chat_connected_clients" }]
}, },
{ {
"title": "Stored Messages", "title": "Stored Messages",
"type": "stat", "type": "stat",
"targets": [{ "expr": "linearvoid_chat_messages_total" }] "targets": [{ "expr": "linearvoid_chat_messages_total" }]
}, },
{ {
"title": "Redis Reachable", "title": "Redis Reachable",
"type": "stat", "type": "stat",
"targets": [{ "expr": "linearvoid_chat_redis_up" }] "targets": [{ "expr": "linearvoid_chat_redis_up" }]
}, },
{ {
"title": "Playout Mode", "title": "Playout Mode",
"type": "stat", "type": "stat",
"targets": [{ "expr": "linearvoid_playout_mode" }] "targets": [{ "expr": "linearvoid_playout_mode" }]
} }
] ]
} }
DBEOF DBEOF
' '
- name: Deploy with docker-compose - name: Deploy with docker-compose
env: env:
DOCKER_HOST: tcp://192.168.88.40:2375 DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate docker compose -f docker-compose.prod.yml up -d --remove-orphans --force-recreate
- name: Health check - name: Health check
env: env:
DOCKER_HOST: tcp://192.168.88.40:2375 DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
# Wait for services to stabilize # Wait for services to stabilize
sleep 30 sleep 30
# Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST) # Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST)
echo "Checking chat health..." echo "Checking chat health..."
if ! docker exec linearvoid-chat curl -sf http://localhost:3000/health; then if ! docker exec linearvoid-chat curl -sf http://localhost:3000/health; then
echo "Chat health check failed" echo "Chat health check failed"
docker logs linearvoid-chat --tail 50 docker logs linearvoid-chat --tail 50
exit 1 exit 1
fi fi
# Check nginx health # Check nginx health
echo "Checking nginx health..." echo "Checking nginx health..."
if ! docker exec linearvoid-nginx curl -sf http://localhost/health; then if ! docker exec linearvoid-nginx curl -sf http://localhost/health; then
echo "Nginx health check failed" echo "Nginx health check failed"
docker logs linearvoid-nginx --tail 50 docker logs linearvoid-nginx --tail 50
exit 1 exit 1
fi fi
# Retry playout health check since it may still be initializing # Retry playout health check since it may still be initializing
echo "Checking playout health..." echo "Checking playout health..."
for i in $(seq 1 6); do for i in $(seq 1 6); do
if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then if docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
echo "Playout is healthy" echo "Playout is healthy"
break break
fi fi
echo "Playout not ready yet (attempt $i/6), waiting 20s..." echo "Playout not ready yet (attempt $i/6), waiting 20s..."
sleep 20 sleep 20
done done
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
echo "Playout failed to become healthy after 6 attempts" echo "Playout failed to become healthy after 6 attempts"
docker logs linearvoid-playout --tail 50 docker logs linearvoid-playout --tail 50
exit 1 exit 1
fi fi
echo "All services healthy" echo "All services healthy"
- name: Cleanup old images - name: Cleanup old images
env: env:
DOCKER_HOST: tcp://192.168.88.40:2375 DOCKER_HOST: tcp://192.168.88.40:2375
run: | run: |
docker image prune -f --filter "until=24h" docker image prune -f --filter "until=24h"
+147 -147
View File
@@ -1,147 +1,147 @@
name: CI name: CI
on: on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
jobs: jobs:
test: test:
runs-on: [ubuntu-latest] runs-on: [ubuntu-latest]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "22"
- name: Install Frontend Dependencies - name: Install Frontend Dependencies
run: cd frontend && npm ci run: cd frontend && npm ci
- name: Lint Frontend - name: Lint Frontend
run: cd frontend && npm run lint run: cd frontend && npm run lint
- name: Test Frontend with Coverage - name: Test Frontend with Coverage
run: cd frontend && npm run test:coverage run: cd frontend && npm run test:coverage
- name: Build Frontend - name: Build Frontend
run: cd frontend && npm run build run: cd frontend && npm run build
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.25" go-version: "1.25"
env: env:
GOTOOLCHAIN: local GOTOOLCHAIN: local
- name: Download Go modules - name: Download Go modules
run: cd backend/chat && GOTOOLCHAIN=local go mod download run: cd backend/chat && GOTOOLCHAIN=local go mod download
- name: Validate Go - name: Validate Go
run: cd backend/chat && GOTOOLCHAIN=local go vet ./... run: cd backend/chat && GOTOOLCHAIN=local go vet ./...
- name: Run Tests - name: Run Tests
run: cd backend/chat && GOTOOLCHAIN=local go test ./... -v -cover -coverprofile=coverage.out -race run: cd backend/chat && GOTOOLCHAIN=local go test ./... -v -cover -coverprofile=coverage.out -race
- name: Check Coverage - name: Check Coverage
run: | run: |
cd backend/chat cd backend/chat
COVERAGE=$(GOTOOLCHAIN=local go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') COVERAGE=$(GOTOOLCHAIN=local go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "Coverage: $COVERAGE%" echo "Coverage: $COVERAGE%"
if [ "$(echo "$COVERAGE < 50" | bc)" -eq 1 ]; then if [ "$(echo "$COVERAGE < 50" | bc)" -eq 1 ]; then
echo "Coverage $COVERAGE% below 50% threshold" echo "Coverage $COVERAGE% below 50% threshold"
exit 1 exit 1
fi fi
docker: docker:
needs: test needs: test
runs-on: [ubuntu-latest] runs-on: [ubuntu-latest]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry - name: Log in to Gitea Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.allucanget.biz registry: git.allucanget.biz
username: ${{ secrets.CI_REGISTRY_USER }} username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.CI_REGISTRY_TOKEN }} password: ${{ secrets.CI_REGISTRY_TOKEN }}
- name: Extract metadata for Playout - name: Extract metadata for Playout
id: meta-playout id: meta-playout
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: git.allucanget.biz/zwitschi/linearvoid/playout images: git.allucanget.biz/zwitschi/linearvoid/playout
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Playout image - name: Build and push Playout image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ./backend/playout context: ./backend/playout
file: ./backend/playout/Dockerfile file: ./backend/playout/Dockerfile
push: true push: true
tags: ${{ steps.meta-playout.outputs.tags }} tags: ${{ steps.meta-playout.outputs.tags }}
labels: ${{ steps.meta-playout.outputs.labels }} labels: ${{ steps.meta-playout.outputs.labels }}
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:playout,mode=max
- name: Extract metadata for Chat - name: Extract metadata for Chat
id: meta-chat id: meta-chat
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: git.allucanget.biz/zwitschi/linearvoid/chat images: git.allucanget.biz/zwitschi/linearvoid/chat
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Chat image - name: Build and push Chat image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ./backend/chat context: ./backend/chat
push: true push: true
tags: ${{ steps.meta-chat.outputs.tags }} tags: ${{ steps.meta-chat.outputs.tags }}
labels: ${{ steps.meta-chat.outputs.labels }} labels: ${{ steps.meta-chat.outputs.labels }}
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:chat,mode=max
- name: Extract metadata for Nginx - name: Extract metadata for Nginx
id: meta-nginx id: meta-nginx
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: git.allucanget.biz/zwitschi/linearvoid/nginx images: git.allucanget.biz/zwitschi/linearvoid/nginx
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Nginx image - name: Build and push Nginx image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: infrastructure/nginx/Dockerfile file: infrastructure/nginx/Dockerfile
push: true push: true
tags: ${{ steps.meta-nginx.outputs.tags }} tags: ${{ steps.meta-nginx.outputs.tags }}
labels: ${{ steps.meta-nginx.outputs.labels }} labels: ${{ steps.meta-nginx.outputs.labels }}
cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx cache-from: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx
cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max cache-to: type=registry,ref=git.allucanget.biz/zwitschi/linearvoid/buildcache:nginx,mode=max
+154 -154
View File
@@ -1,154 +1,154 @@
# MEMORY # MEMORY
## 2026-08-07 ## 2026-08-07
- Completed runtime validation section from TODO. - Completed runtime validation section from TODO.
- Runtime video normalization tests executed in running `linearvoid-playout` container using ffmpeg/ffprobe. - Runtime video normalization tests executed in running `linearvoid-playout` container using ffmpeg/ffprobe.
- Verified normalized outputs for MOV, MKV, VFR, and 5.1 inputs: `h264`, `yuv420p`, `30/1`, `aac`, `48000`, `2 channels`. - Verified normalized outputs for MOV, MKV, VFR, and 5.1 inputs: `h264`, `yuv420p`, `30/1`, `aac`, `48000`, `2 channels`.
- Concat continuity check passed with `CONCAT_OK`; output duration `25.344667` seconds. - Concat continuity check passed with `CONCAT_OK`; output duration `25.344667` seconds.
- Responsive viewport test on live viewer (`390x844`, `412x915`) detected 8px horizontal overflow from `100vw` usage in viewer container styles. - Responsive viewport test on live viewer (`390x844`, `412x915`) detected 8px horizontal overflow from `100vw` usage in viewer container styles.
- Fixed overflow in source by changing `.lv-body` and `.viewer-shell` width from `100vw` to `100%` in `frontend/src/style.css`. - Fixed overflow in source by changing `.lv-body` and `.viewer-shell` width from `100vw` to `100%` in `frontend/src/style.css`.
- Redis restart test: - Redis restart test:
- Admin session endpoint returned 200 before and after Redis restart. - Admin session endpoint returned 200 before and after Redis restart.
- Presence key survived Redis restart in test-lab configuration. - Presence key survived Redis restart in test-lab configuration.
- Redis health returned to `healthy` post-restart. - Redis health returned to `healthy` post-restart.
- Files changed: - Files changed:
- `.github/instructions/TODO.md` - `.github/instructions/TODO.md`
- `.github/instructions/DONE.md` - `.github/instructions/DONE.md`
- `frontend/src/style.css` - `frontend/src/style.css`
- `CHANGELOG.md` - `CHANGELOG.md`
## 2026-08-07 (continued) ## 2026-08-07 (continued)
- Completed documentation half of recovery runbook TODO item. - Completed documentation half of recovery runbook TODO item.
- Expanded `docs/incident-runbook.md` with quarterly DR drill: prerequisites, outage simulation, restore validation, pass/fail criteria, and evidence template. - Expanded `docs/incident-runbook.md` with quarterly DR drill: prerequisites, outage simulation, restore validation, pass/fail criteria, and evidence template.
- Split TODO item into: - Split TODO item into:
- documented drill (`[x]`) - documented drill (`[x]`)
- manual rehearsal execution (`[ ]`) - manual rehearsal execution (`[ ]`)
- Updated `CHANGELOG.md` with incident runbook drill documentation entry. - Updated `CHANGELOG.md` with incident runbook drill documentation entry.
## 2026-08-07 (continued 2) ## 2026-08-07 (continued 2)
- Added manual operations checklist `docs/operations/cloudflare-uptimerobot-checklist.md` for Cloudflare + UptimeRobot task execution and evidence capture. - Added manual operations checklist `docs/operations/cloudflare-uptimerobot-checklist.md` for Cloudflare + UptimeRobot task execution and evidence capture.
- Linked checklist from `SETUP.md` Cloudflare section and TODO manual sections. - Linked checklist from `SETUP.md` Cloudflare section and TODO manual sections.
- Did not mark manual dashboard items complete; checklist is preparation only. - Did not mark manual dashboard items complete; checklist is preparation only.
## 2026-08-07 (continued 3) ## 2026-08-07 (continued 3)
- Added `docs/operations/scaling-performance-playbook.md` covering manifest TTL tuning, TTFB baseline method, WebSocket/API benchmarking approach, cache ratio tracking, capacity triggers, and Phase 2/3 plans. - Added `docs/operations/scaling-performance-playbook.md` covering manifest TTL tuning, TTFB baseline method, WebSocket/API benchmarking approach, cache ratio tracking, capacity triggers, and Phase 2/3 plans.
- Updated TODO and BACKLOG to mark playbook creation done while keeping execution/measurement tasks pending. - Updated TODO and BACKLOG to mark playbook creation done while keeping execution/measurement tasks pending.
- Linked scaling playbook in `README.md` resources. - Linked scaling playbook in `README.md` resources.
## 2026-08-09 ## 2026-08-09
- Added WebSocket benchmark summary helper `scripts/loadtest/summarize-artillery.ps1`. - Added WebSocket benchmark summary helper `scripts/loadtest/summarize-artillery.ps1`.
- Updated `scripts/loadtest/run-loadtests.ps1` to auto-generate `ws-chat-summary.txt` for `ws` and `all` scenarios. - Updated `scripts/loadtest/run-loadtests.ps1` to auto-generate `ws-chat-summary.txt` for `ws` and `all` scenarios.
- Updated `scripts/loadtest/summarize-k6.ps1` to produce pipeline-friendly output and integrated automatic `admin-api-endpoint-summary.txt` generation. - Updated `scripts/loadtest/summarize-k6.ps1` to produce pipeline-friendly output and integrated automatic `admin-api-endpoint-summary.txt` generation.
- Expanded `scripts/loadtest/README.md` with PowerShell-first artifact capture and helper usage for both ws and admin scenarios. - Expanded `scripts/loadtest/README.md` with PowerShell-first artifact capture and helper usage for both ws and admin scenarios.
- Updated `.gitignore` to ignore loadtest artifacts (`artifacts/` and `scripts/loadtest/artifacts/`). - Updated `.gitignore` to ignore loadtest artifacts (`artifacts/` and `scripts/loadtest/artifacts/`).
- Added artifact-to-worksheet field mapping section in `docs/operations/scaling-performance-playbook.md` so ws/admin worksheet entries map directly to generated summary files. - Added artifact-to-worksheet field mapping section in `docs/operations/scaling-performance-playbook.md` so ws/admin worksheet entries map directly to generated summary files.
- Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while live benchmark execution remains pending. - Updated `docs/DEVELOPMENT.md` and `.github/instructions/TODO.md` to mark ws/admin benchmark scaffolding complete while live benchmark execution remains pending.
- Added `CHANGELOG.md` entry `1.0.10` summarizing loadtest helper automation and playbook mapping updates. - Added `CHANGELOG.md` entry `1.0.10` summarizing loadtest helper automation and playbook mapping updates.
## 2026-08-09 (continued) ## 2026-08-09 (continued)
- Completed Phase 1 (test fixtures): documented ffmpeg fixture-generation commands in `playlist_test.go` header comments (valid_720p30, invalid_h265, invalid_24fps). - Completed Phase 1 (test fixtures): documented ffmpeg fixture-generation commands in `playlist_test.go` header comments (valid_720p30, invalid_h265, invalid_24fps).
- Completed Phase 4 (R2 integration): - Completed Phase 4 (R2 integration):
- Enhanced `SyncToR2` in `playlist.go` to set CacheControl + ContentType based on file extension: - Enhanced `SyncToR2` in `playlist.go` to set CacheControl + ContentType based on file extension:
- `.m4s`, `.ts`, `.mp4`, `.m4a` -> `public, max-age=31536000, immutable` + `video/MP2T` - `.m4s`, `.ts`, `.mp4`, `.m4a` -> `public, max-age=31536000, immutable` + `video/MP2T`
- `.m3u8` -> `public, max-age=2` + `application/vnd.apple.mpegurl` - `.m3u8` -> `public, max-age=2` + `application/vnd.apple.mpegurl`
- unknown extension -> no cache headers - unknown extension -> no cache headers
- Added three tests: `TestSyncToR2` (segment upload with cache verification), `TestSyncToR2_ManifestCacheHeaders` (manifest upload), `TestSyncToR2_NoCacheHeadersForUnknownExt` (unknown extension). - Added three tests: `TestSyncToR2` (segment upload with cache verification), `TestSyncToR2_ManifestCacheHeaders` (manifest upload), `TestSyncToR2_NoCacheHeadersForUnknownExt` (unknown extension).
- All 17 playout tests pass (16 pass, 1 pre-existing skip). - All 17 playout tests pass (16 pass, 1 pre-existing skip).
- Updated TODO.md: Phase 1 marked complete, Phase 4 partially complete (edge cache hit/miss remains manual). - Updated TODO.md: Phase 1 marked complete, Phase 4 partially complete (edge cache hit/miss remains manual).
- Updated DEVELOPMENT.md: Test Fixtures marked complete, R2 Integration Test marked partially complete. - Updated DEVELOPMENT.md: Test Fixtures marked complete, R2 Integration Test marked partially complete.
## 2026-08-09 (continued 2) ## 2026-08-09 (continued 2)
- Completed Phase 2 chat tests in `frontend/src/main.test.ts`: - Completed Phase 2 chat tests in `frontend/src/main.test.ts`:
- Presence updates: join/leave system messages add and remove users from presence list, sorted rendering. - Presence updates: join/leave system messages add and remove users from presence list, sorted rendering.
- Private DM flow: togglePrivateDM creates correct dm: room name from sorted usernames. - Private DM flow: togglePrivateDM creates correct dm: room name from sorted usernames.
- Message rendering: system messages, self messages (message-self), and other-user messages (message-other) all render correctly. - Message rendering: system messages, self messages (message-self), and other-user messages (message-other) all render correctly.
- Total frontend tests: 27 passing (2 test files). - Total frontend tests: 27 passing (2 test files).
- Marked Phase 4 "Document stubs/mocks" done (putObject injection pattern). - Marked Phase 4 "Document stubs/mocks" done (putObject injection pattern).
- Updated TODO.md: Phase 2 chat tests checked, Phase 4 stubs checked. - Updated TODO.md: Phase 2 chat tests checked, Phase 4 stubs checked.
- Updated DEVELOPMENT.md: User Frontend Tests marked partially complete, R2 stubs checked. - Updated DEVELOPMENT.md: User Frontend Tests marked partially complete, R2 stubs checked.
- Updated CHANGELOG.md with Phase 2 presence/DM/message tests. - Updated CHANGELOG.md with Phase 2 presence/DM/message tests.
## 2026-08-09 (continued 3) ## 2026-08-09 (continued 3)
- Completed Phase 3 admin frontend tests in `frontend/src/admin.test.ts`: - Completed Phase 3 admin frontend tests in `frontend/src/admin.test.ts`:
- Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts`. - Exported `savePlaylist` and `renderPlaylistSortable` from `admin.ts`.
- Added playlist save test (verifies PUT /api/admin/playlist with JSON body). - Added playlist save test (verifies PUT /api/admin/playlist with JSON body).
- Added playlist save error handling test (verifies failure status). - Added playlist save error handling test (verifies failure status).
- Added sortable rendering test (verifies index, paths, remove buttons, comment/blank line skip). - Added sortable rendering test (verifies index, paths, remove buttons, comment/blank line skip).
- Total frontend tests: 31 passing (2 test files). - Total frontend tests: 31 passing (2 test files).
- Updated TODO.md: Phase 3 playlist/dashboard/API integration all checked. - Updated TODO.md: Phase 3 playlist/dashboard/API integration all checked.
- Updated DEVELOPMENT.md: Admin Frontend Tests marked partially complete. - Updated DEVELOPMENT.md: Admin Frontend Tests marked partially complete.
## 2026-08-10 (continued 2) ## 2026-08-10 (continued 2)
- Implemented STREAMING.md production-readiness gaps from plan `.hermes/plans/2026-08-10_164500-production-readiness.md`: - Implemented STREAMING.md production-readiness gaps from plan `.hermes/plans/2026-08-10_164500-production-readiness.md`:
- **Task 1-2**: Added `runID` (ISO timestamp format) and monotonic `segSequence` for globally unique segment naming (`r-{ISOTIME}/seg-{seq}.m4s`). Replaced `strftime` pattern with run ID-based path. - **Task 1-2**: Added `runID` (ISO timestamp format) and monotonic `segSequence` for globally unique segment naming (`r-{ISOTIME}/seg-{seq}.m4s`). Replaced `strftime` pattern with run ID-based path.
- **Task 2**: Added `watchAndPublishSegments` using `fsnotify` for real-time per-segment R2 upload (~7s delay). Previously `syncLatestToR2()` only ran after FFmpeg exit. - **Task 2**: Added `watchAndPublishSegments` using `fsnotify` for real-time per-segment R2 upload (~7s delay). Previously `syncLatestToR2()` only ran after FFmpeg exit.
- **Task 3**: Added `publishManifest` with atomic `CopyObject` (temp key → final key) to prevent manifest referencing unconfirmed segments. - **Task 3**: Added `publishManifest` with atomic `CopyObject` (temp key → final key) to prevent manifest referencing unconfirmed segments.
- **Task 4**: Updated `manifestCacheControl` from `"public, max-age=2"` to `"public, max-age=1, s-maxage=2, must-revalidate"` per STREAMING.md. - **Task 4**: Updated `manifestCacheControl` from `"public, max-age=2"` to `"public, max-age=1, s-maxage=2, must-revalidate"` per STREAMING.md.
- **Task 5**: Added `hls.on(Hls.Events.ERROR)` handler in `main.ts` with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay (spinner + text). Added `showReconnecting`/`hideReconnecting` functions. - **Task 5**: Added `hls.on(Hls.Events.ERROR)` handler in `main.ts` with bounded retries (5x), exponential backoff (2s→32s), reconnecting overlay (spinner + text). Added `showReconnecting`/`hideReconnecting` functions.
- **Task 6**: Enriched `HealthHandler` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`. Added "unhealthy" status when R2 publish is stale (>12s). - **Task 6**: Enriched `HealthHandler` with `run_id`, `active_item`, `playlist_revision`, `last_segment_seq`, `last_r2_pub`, `consecutive_failures`. Added "unhealthy" status when R2 publish is stale (>12s).
- Files changed: `backend/playout/src/playlist.go`, `playlist_test.go`, `go.mod`, `frontend/src/main.ts`, `main.test.ts`, `style.css`, `index.html`. - Files changed: `backend/playout/src/playlist.go`, `playlist_test.go`, `go.mod`, `frontend/src/main.ts`, `main.test.ts`, `style.css`, `index.html`.
- Updated `.github/instructions/TODO.md`, `BACKLOG.md`, `MEMORY.md`, `DONE.md`. - Updated `.github/instructions/TODO.md`, `BACKLOG.md`, `MEMORY.md`, `DONE.md`.
- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 config, infrastructure). - Remaining: CORS on media.linearvoid.tv (Cloudflare R2 config, infrastructure).
## 2026-08-10 (continued 3) ## 2026-08-10 (continued 3)
- Completed Phase 2 (viewer) + Phase 3 (admin) responsive/accessibility tests: - Completed Phase 2 (viewer) + Phase 3 (admin) responsive/accessibility tests:
- 5 viewer accessibility tests: video controls/attributes, chat panel aria-labels, chat toggle aria-expanded, message input label, reconnecting overlay aria-live/role. - 5 viewer accessibility tests: video controls/attributes, chat panel aria-labels, chat toggle aria-expanded, message input label, reconnecting overlay aria-live/role.
- 3 viewer responsive tests: chat-collapsed toggle, CSS grid layout detection, now-playing aria-live. - 3 viewer responsive tests: chat-collapsed toggle, CSS grid layout detection, now-playing aria-live.
- 3 admin accessibility tests: nav aria-label, active tab aria-current, nav button focusability. - 3 admin accessibility tests: nav aria-label, active tab aria-current, nav button focusability.
- 2 admin responsive tests: tab content visibility, essential tab presence. - 2 admin responsive tests: tab content visibility, essential tab presence.
- Total: 45 frontend tests passing (2 test files). - Total: 45 frontend tests passing (2 test files).
- Added coverage reporting: `vitest.config.ts` with v8 provider, thresholds at 20/15/15/20 (statements/branches/functions/lines). - Added coverage reporting: `vitest.config.ts` with v8 provider, thresholds at 20/15/15/20 (statements/branches/functions/lines).
- Added `test:coverage` script to `package.json`. - Added `test:coverage` script to `package.json`.
- Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `workers-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` to `package.json`. - Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `workers-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` to `package.json`.
- Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence. - Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence.
- All Go vet, Go tests, frontend test:coverage passing. - All Go vet, Go tests, frontend test:coverage passing.
- CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code). - CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code).
## 2026-08-10 (continued 4) ## 2026-08-10 (continued 4)
- Verified all test suites pass after streaming pipeline implementation: - Verified all test suites pass after streaming pipeline implementation:
- Playout: `go mod tidy` (added fsnotify v1.8.0) → all tests pass - Playout: `go mod tidy` (added fsnotify v1.8.0) → all tests pass
- Chat: 7 packages pass (go vet + go test) - Chat: 7 packages pass (go vet + go test)
- Frontend: 45 tests pass (2 files), test:coverage meets thresholds (22.92% / 18.44% / 19.01% / 25.42%) - Frontend: 45 tests pass (2 files), test:coverage meets thresholds (22.92% / 18.44% / 19.01% / 25.42%)
- Edge cache validation script `scripts/validate-edge-cache.sh` exists. - Edge cache validation script `scripts/validate-edge-cache.sh` exists.
- Remaining: CORS on media.linearvoid.tv (Cloudflare R2 bucket CORS config, not a code change). Phases 5-7 are manual/infrastructure only (Cloudflare dashboard, Hetzner provisioning, live benchmarks). - Remaining: CORS on media.linearvoid.tv (Cloudflare R2 bucket CORS config, not a code change). Phases 5-7 are manual/infrastructure only (Cloudflare dashboard, Hetzner provisioning, live benchmarks).
## 2026-08-11 ## 2026-08-11
- Consolidated all development tasks from `DEVELOPMENT.md` and `.github/instructions/TODO.md` into `.github/instructions/BACKLOG.md` as the single source of truth. `DEVELOPMENT.md` now obsolete and removed. `TODO.md` references `BACKLOG.md` and `DONE.md`. - Consolidated all development tasks from `DEVELOPMENT.md` and `.github/instructions/TODO.md` into `.github/instructions/BACKLOG.md` as the single source of truth. `DEVELOPMENT.md` now obsolete and removed. `TODO.md` references `BACKLOG.md` and `DONE.md`.
- Fixed R2 endpoint validation in playout service (`backend/playout/src/playlist.go`): - Fixed R2 endpoint validation in playout service (`backend/playout/src/playlist.go`):
- Added `isR2Configured()` checking all four required env vars (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`). - Added `isR2Configured()` checking all four required env vars (`R2_ENDPOINT`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`).
- `initR2Client()` now returns a clear error if R2 is not configured instead of creating an S3 client with an empty endpoint. - `initR2Client()` now returns a clear error if R2 is not configured instead of creating an S3 client with an empty endpoint.
- Segment watcher (`watchAndPublishSegments`), `syncLatestToR2()`, and `publishManifest()` are now guarded by `r2Client != nil` — no error spam when R2 is unavailable. - Segment watcher (`watchAndPublishSegments`), `syncLatestToR2()`, and `publishManifest()` are now guarded by `r2Client != nil` — no error spam when R2 is unavailable.
- Playout gracefully degrades: local HLS via nginx works without R2. - Playout gracefully degrades: local HLS via nginx works without R2.
- Updated `.gitea/workflows/cd.yml` "Prepare environment" step to include all R2* variables sourced from Gitea secrets (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN*\*`), Cloudflare variables (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`), and frontend public URL variables. - Updated `.gitea/workflows/cd.yml` "Prepare environment" step to include all R2* variables sourced from Gitea secrets (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN*\*`), Cloudflare variables (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`), and frontend public URL variables.
- Updated `docs/DEPLOYMENT.md` "Environment File" section: replaced vague "Cloudflare/R2 variables" bullet with explicit variable lists and added a new "Gitea Secrets" table documenting all 15 required secrets. - Updated `docs/DEPLOYMENT.md` "Environment File" section: replaced vague "Cloudflare/R2 variables" bullet with explicit variable lists and added a new "Gitea Secrets" table documenting all 15 required secrets.
- All playout tests pass (20 tests), `go vet` clean. - All playout tests pass (20 tests), `go vet` clean.
## 2026-08-13 ## 2026-08-13
- Completed all remaining enhancement tasks (only manual/infrastructure phases left): - Completed all remaining enhancement tasks (only manual/infrastructure phases left):
- **Playout**: automatic media normalization on upload (`media.NormalizeVideo`, `*.normalized.mp4`, statuses `ready`/`needs_normalization`/`normalizing`/`normalization_failed`, `POST /api/admin/media/{id}/normalize`); R2 segment retention (`cleanupOldRuns`, keep last `R2_KEEP_RUNS` runs, `R2_MAX_RETENTION_BYTES` cap); seamless looping (playlist SHA-256 fingerprint + `-start_number`); single `start-playout.sh` entrypoint + `-validate` mode; loop/cleanup metrics. - **Playout**: automatic media normalization on upload (`media.NormalizeVideo`, `*.normalized.mp4`, statuses `ready`/`needs_normalization`/`normalizing`/`normalization_failed`, `POST /api/admin/media/{id}/normalize`); R2 segment retention (`cleanupOldRuns`, keep last `R2_KEEP_RUNS` runs, `R2_MAX_RETENTION_BYTES` cap); seamless looping (playlist SHA-256 fingerprint + `-start_number`); single `start-playout.sh` entrypoint + `-validate` mode; loop/cleanup metrics.
- **Application configuration**: thread-safe JSON runtime config store (`backend/chat/src/config/runtime.go`) shared with playout via `/app/playlist/config.json`; `GET/PUT /api/admin/config`; admin UI config form. - **Application configuration**: thread-safe JSON runtime config store (`backend/chat/src/config/runtime.go`) shared with playout via `/app/playlist/config.json`; `GET/PUT /api/admin/config`; admin UI config form.
- **Matomo analytics**: public `GET /api/matomo` + viewer pageview snippet in `index.html`. - **Matomo analytics**: public `GET /api/matomo` + viewer pageview snippet in `index.html`.
- **Chat user accounts**: register/login/logout/session endpoints, bcrypt hashing, `linearvoid_user_session` cookie, viewer sign-in UI. - **Chat user accounts**: register/login/logout/session endpoints, bcrypt hashing, `linearvoid_user_session` cookie, viewer sign-in UI.
- **DM history & search**: `GET /api/dm/history` (paginated) + `GET /api/dm/search` + DM search UI. - **DM history & search**: `GET /api/dm/history` (paginated) + `GET /api/dm/search` + DM search UI.
- **Chat UI**: typing indicator, user-list toggle, media status badges, "Add to playlist" gated to `ready`. - **Chat UI**: typing indicator, user-list toggle, media status badges, "Add to playlist" gated to `ready`.
- **Prometheus TSDB retention**: `--storage.tsdb.retention.time=30d` + `--storage.tsdb.retention.size=15GB` in both compose files; alert rule count corrected 8 → 10. - **Prometheus TSDB retention**: `--storage.tsdb.retention.time=30d` + `--storage.tsdb.retention.size=15GB` in both compose files; alert rule count corrected 8 → 10.
- Updated `docs/architecture/README.md` to reflect all `[Unreleased]` changes (normalization, looping, retention, runtime config, Matomo, user accounts, DM history/search, new API endpoints, module tables). - Updated `docs/architecture/README.md` to reflect all `[Unreleased]` changes (normalization, looping, retention, runtime config, Matomo, user accounts, DM history/search, new API endpoints, module tables).
- Released **1.0.13**: promoted changelog, bumped version, tagged and pushed. - Released **1.0.13**: promoted changelog, bumped version, tagged and pushed.
- Extended frontend test coverage: added `counter.test.ts`, reconnecting/typing/auth/websocket tests, admin failure + status-badge tests → 56 tests; statements 19.89% → 28.58% (CI coverage threshold passing). - Extended frontend test coverage: added `counter.test.ts`, reconnecting/typing/auth/websocket tests, admin failure + status-badge tests → 56 tests; statements 19.89% → 28.58% (CI coverage threshold passing).
- Fixed Go test data race in `TestHandleAdminMediaByID_Normalize` (waits for async normalization goroutine) + shared in-memory SQLite via `SetMaxOpenConns(1)`. - Fixed Go test data race in `TestHandleAdminMediaByID_Normalize` (waits for async normalization goroutine) + shared in-memory SQLite via `SetMaxOpenConns(1)`.
- Fixed viewer crash: `index.html` was missing the `#messages` chat container (stray `</section>`); restored structure + made `addMessage` defensive. - Fixed viewer crash: `index.html` was missing the `#messages` chat container (stray `</section>`); restored structure + made `addMessage` defensive.
- Set `Doto` as the body font (`--font-body`) and added Google Fonts links to admin + login pages. - Set `Doto` as the body font (`--font-body`) and added Google Fonts links to admin + login pages.
- Files changed: across `backend/chat`, `backend/playout`, `frontend`, `docs`, `docker-compose*.yml`, `.github/instructions/*`. - Files changed: across `backend/chat`, `backend/playout`, `frontend`, `docs`, `docker-compose*.yml`, `.github/instructions/*`.
+136 -136
View File
@@ -1,136 +1,136 @@
# BACKLOG # BACKLOG
> All current work is planned in [TODO](./TODO.md) (2026-08-13). This file lists backlog items; new unplanned work is added here. > All current work is planned in [TODO](./TODO.md) (2026-08-13). This file lists backlog items; new unplanned work is added here.
## Setup phases remaining ## Setup phases remaining
### Phase 5: complete manual Cloudflare and external-service setup ### Phase 5: complete manual Cloudflare and external-service setup
- [ ] Apply Cloudflare Workers, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard - [ ] Apply Cloudflare Workers, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
- [ ] Cloudflare Workers: link custom domain and cut over frontend hosting - [ ] Cloudflare Workers: link custom domain and cut over frontend hosting
- [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier - [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier
- [ ] WAF: enable OWASP Managed Ruleset (Simulate → review 48h → Block), enable Bot Fight Mode - [ ] WAF: enable OWASP Managed Ruleset (Simulate → review 48h → Block), enable Bot Fight Mode
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action - [ ] 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; enforce at edge and origin - [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2; enforce at edge and origin
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval) - [ ] 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) - [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5min)
- [ ] UptimeRobot: alert on 2 consecutive failures via email + Discord/Slack webhook - [ ] UptimeRobot: alert on 2 consecutive failures via email + Discord/Slack webhook
- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md` - [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`
### Phase 6: rehearse production deployment end to end ### Phase 6: rehearse production deployment end to end
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04 - [ ] 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-only - [ ] Install Docker Engine + Compose plugin; open firewall: 80/443 public, 2375 runner-only
- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs) - [ ] 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 - [ ] 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) - [ ] 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 - [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP
- [ ] Verify all 11 containers healthy: `/health`, `/hls/playlist.m3u8`, WebSocket - [ ] Verify all 11 containers healthy: `/health`, `/hls/playlist.m3u8`, WebSocket
- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict); test HTTPS + WSS - [ ] 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 - [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL - [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host - [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds - [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
### Phase 7: baseline scaling and performance ### Phase 7: baseline scaling and performance
- [ ] Execute WebSocket load tests — ramp 10→100→500→1000 connections; measure p95 latency + errors - [ ] Execute WebSocket load tests — ramp 10→100→500→1000 connections; measure p95 latency + errors
- [ ] Execute admin API benchmarks — `wrk`/`k6` against `/api/admin/*`; target p95 <1s - [ ] Execute admin API benchmarks — `wrk`/`k6` against `/api/admin/*`; target p95 <1s
- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions (WebPageTest 3+ regions, <4s warm cache) - [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions (WebPageTest 3+ regions, <4s warm cache)
- [ ] Tune manifest TTL to 0.5× segment duration; validate `Cf-Cache-Status` distribution - [ ] Tune manifest TTL to 0.5× segment duration; validate `Cf-Cache-Status` distribution
- [ ] Track cache-hit ratio and R2 ops in production (Cloudflare GraphQL → Grafana panel, alert <90%) - [ ] Track cache-hit ratio and R2 ops in production (Cloudflare GraphQL → Grafana panel, alert <90%)
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min - [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
- [ ] Plan Phase 2 service separation (playout CPU, chat latency, DB state, monitoring) - [ ] Plan Phase 2 service separation (playout CPU, chat latency, DB state, monitoring)
- [ ] Plan Phase 3 multi-origin resilience (second Hetzner location, warm standby, DNS failover, DB/Redis replication) - [ ] Plan Phase 3 multi-origin resilience (second Hetzner location, warm standby, DNS failover, DB/Redis replication)
### Phase 8: close out remaining test and validation gaps ### Phase 8: close out remaining test and validation gaps
- [ ] Admin frontend: user moderation tools tests (mute/ban/report, view moderation logs) - [ ] Admin frontend: user moderation tools tests (mute/ban/report, view moderation logs)
- [ ] Frontend: coverage for all critical user + admin components and interactions - [ ] Frontend: coverage for all critical user + admin components and interactions
- [ ] Frontend: document test cases and expected outcomes - [ ] Frontend: document test cases and expected outcomes
- [ ] Backend R2: test retrieval of segments/manifests via Cloudflare edge - [ ] Backend R2: test retrieval of segments/manifests via Cloudflare edge
- [ ] Backend R2: validate edge cache hit/miss behavior with repeat requests (`scripts/validate-edge-cache.sh`) - [ ] Backend R2: validate edge cache hit/miss behavior with repeat requests (`scripts/validate-edge-cache.sh`)
### Playout ### Playout
- [ ] Configure an R2 lifecycle rule on the `hls/` prefix as defense-in-depth (manual) - [ ] Configure an R2 lifecycle rule on the `hls/` prefix as defense-in-depth (manual)
- [ ] Full-loop E2E (2-file playlist, assert `Looping...` with no `CRASHED` across the boundary) — manual via `scripts/test-playout-e2e.sh` - [ ] Full-loop E2E (2-file playlist, assert `Looping...` with no `CRASHED` across the boundary) — manual via `scripts/test-playout-e2e.sh`
## Cloudflare Configuration ## Cloudflare Configuration
### DNS & Workers ### DNS & Workers
- [ ] Cloudflare Workers: link custom domain (manual) - [ ] Cloudflare Workers: link custom domain (manual)
- [ ] Create Cloudflare Worker and cut over frontend hosting (manual) - [ ] Create Cloudflare Worker and cut over frontend hosting (manual)
### Security & TLS ### Security & TLS
- [ ] Enable Tiered Cache, WAF, rate limiting (manual) - [ ] Enable Tiered Cache, WAF, rate limiting (manual)
- [ ] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier - [ ] 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 - [ ] 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 - [ ] 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: 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 - [ ] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS
## Monitoring & Observability ## Monitoring & Observability
### External Monitoring ### External Monitoring
- [ ] Set up UptimeRobot external monitoring - [ ] Set up UptimeRobot external monitoring
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval) - [ ] 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) - [ ] 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 - [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
## External Services ## External Services
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md` > Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval) - [ ] 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) - [ ] 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 - [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
## Production Deployment ## Production Deployment
### Host Provisioning (Hetzner — not provisioned) ### Host Provisioning (Hetzner — not provisioned)
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04 - [ ] 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) - [ ] 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) - [ ] 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 - [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories
### CI/CD & Secrets ### CI/CD & Secrets
- [ ] Add `CI_PROD_*` secrets to Gitea: DOCKER_HOST, R2 keys, admin password, DB password, Grafana password - [ ] 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 - [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP
### Smoke Test & Cutover ### Smoke Test & Cutover
- [ ] Verify all 11 containers start healthy, check `/health`, `/hls/playlist.m3u8`, WebSocket - [ ] 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 - [ ] 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 - [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
### Production Operations ### Production Operations
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL - [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host - [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds - [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
## Scaling & Performance ## Scaling & Performance
### Load Testing ### Load Testing
- [ ] Execute WebSocket load tests with real infrastructure — ramp 10→100→500→1000 connections, measure p95 latency + errors - [ ] Execute WebSocket load tests with real infrastructure — ramp 10→100→500→1000 connections, measure p95 latency + errors
- [ ] Execute admin API benchmarks with real infrastructure — `wrk`/`k6` against `/api/admin/*`, target p95 <1s - [ ] Execute admin API benchmarks with real infrastructure — `wrk`/`k6` against `/api/admin/*`, target p95 <1s
### Cache & TTFB Optimization ### Cache & TTFB Optimization
- [ ] Optimize segment immutability and manifest TTL for cache efficiency - [ ] Optimize segment immutability and manifest TTL for cache efficiency
- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration - [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration
- [ ] Measure playback TTFB from multiple US regions — WebPageTest from 3+ regions, target <4s warm cache - [ ] Measure playback TTFB from multiple US regions — WebPageTest from 3+ regions, target <4s warm cache
- [ ] Track cache hit ratio and R2 ops in production — Cloudflare GraphQL → Grafana panel, alert <90% - [ ] Track cache hit ratio and R2 ops in production — Cloudflare GraphQL → Grafana panel, alert <90%
### Capacity & Future Planning ### Capacity & Future Planning
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min - [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
- [ ] Plan Phase 2 service separation — document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources - [ ] Plan Phase 2 service separation — document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources
- [ ] Plan Phase 3 multi-origin resilience — research second Hetzner location, design warm standby + DNS failover + DB/Redis replication - [ ] Plan Phase 3 multi-origin resilience — research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
+57 -57
View File
@@ -1,57 +1,57 @@
# TODO # TODO
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md). > Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
## Chat sidebar ## Chat sidebar
- [x] remove search bar from chat sidebar (not needed for single-room) - [x] remove search bar from chat sidebar (not needed for single-room)
- [x] remove "user" toggle button from chat sidebar (hide for now, add back when user interface is stable) - [x] remove "user" toggle button from chat sidebar (hide for now, add back when user interface is stable)
- [x] make login/registration form a modal and only toggle it when user is not logged in and clicks "Login" or "Register" - [x] make login/registration form a modal and only toggle it when user is not logged in and clicks "Login" or "Register"
- [x] when user is logged in, do not show login/registration form - [x] when user is logged in, do not show login/registration form
- [x] make sure each message is only rendered once in the chat log (no duplicates on reconnect or initial load) - [x] make sure each message is only rendered once in the chat log (no duplicates on reconnect or initial load)
### Plan ### Plan
Files: `frontend/index.html`, `frontend/src/main.ts`, `frontend/src/style.css`. Files: `frontend/index.html`, `frontend/src/main.ts`, `frontend/src/style.css`.
1. **Remove search bar** (single-room) 1. **Remove search bar** (single-room)
- `index.html`: delete the `<div id="dm-search-row">` block. - `index.html`: delete the `<div id="dm-search-row">` block.
- `main.ts`: remove `searchDM()` and the `dm-search-btn` listener; drop the `#dm-search-row` show/hide logic in `switchRoom()` (the `searchRow.hidden = !isDM` lines). Keep `currentDMTarget` only if the "New DM" flow stays. - `main.ts`: remove `searchDM()` and the `dm-search-btn` listener; drop the `#dm-search-row` show/hide logic in `switchRoom()` (the `searchRow.hidden = !isDM` lines). Keep `currentDMTarget` only if the "New DM" flow stays.
2. **Remove "Users" toggle button** 2. **Remove "Users" toggle button**
- `index.html`: delete the `<button id="users-toggle-btn">` button. - `index.html`: delete the `<button id="users-toggle-btn">` button.
- `main.ts`: remove `toggleUserList()` and the `users-toggle-btn` listener; drop the now-unused `usersVisible` variable. - `main.ts`: remove `toggleUserList()` and the `users-toggle-btn` listener; drop the now-unused `usersVisible` variable.
3. **Login/registration form as a modal** 3. **Login/registration form as a modal**
- `index.html`: wrap the `#auth-panel` form in a modal structure (`.auth-modal` + backdrop) with a close button. - `index.html`: wrap the `#auth-panel` form in a modal structure (`.auth-modal` + backdrop) with a close button.
- `style.css`: add `.auth-modal` / `.auth-modal-backdrop` styles. - `style.css`: add `.auth-modal` / `.auth-modal-backdrop` styles.
- `main.ts`: add `openAuthModal()` / `closeAuthModal()`; change the `auth-toggle-btn` handler to open the modal (only when not authenticated) instead of toggling `#auth-panel` inline; wire a close/backdrop-click to `closeAuthModal()`. - `main.ts`: add `openAuthModal()` / `closeAuthModal()`; change the `auth-toggle-btn` handler to open the modal (only when not authenticated) instead of toggling `#auth-panel` inline; wire a close/backdrop-click to `closeAuthModal()`.
4. **Hide form when logged in** 4. **Hide form when logged in**
- `main.ts` `updateAuthUI()`: when `authenticated` → hide the "Sign in" button and call `closeAuthModal()`; show the user label + "Sign out" button. When not authenticated → show "Sign in" and hide "Sign out". - `main.ts` `updateAuthUI()`: when `authenticated` → hide the "Sign in" button and call `closeAuthModal()`; show the user label + "Sign out" button. When not authenticated → show "Sign in" and hide "Sign out".
5. **No duplicate messages** 5. **No duplicate messages**
- `main.ts`: add `clearMessages()` (empties `#messages`) and call it at the top of `connectWebSocket()` so each (re)connect renders history fresh instead of appending. - `main.ts`: add `clearMessages()` (empties `#messages`) and call it at the top of `connectWebSocket()` so each (re)connect renders history fresh instead of appending.
- Fix the initial double-connect: make the initial `connectWebSocket("main")` wait for `refreshUserSession()` (or connect once with the resolved username) so history isn't pushed twice on load. - Fix the initial double-connect: make the initial `connectWebSocket("main")` wait for `refreshUserSession()` (or connect once with the resolved username) so history isn't pushed twice on load.
## Frontend Tests ## Frontend Tests
### User Frontend ### User Frontend
- [ ] Test coverage for all critical frontend components and interactions - [ ] Test coverage for all critical frontend components and interactions
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline - [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
- [ ] Document test cases and expected outcomes - [ ] Document test cases and expected outcomes
### Admin Frontend ### Admin Frontend
- [ ] Test coverage for all critical admin UI components and interactions - [ ] Test coverage for all critical admin UI components and interactions
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline - [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
- [ ] Document test cases and expected outcomes - [ ] Document test cases and expected outcomes
## Backend Integration Tests ## Backend Integration Tests
### Cloudflare R2 ### Cloudflare R2
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge - [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
- [ ] Validate cache hit/miss behavior and performance metrics - [ ] Validate cache hit/miss behavior and performance metrics
- [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created - [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created
+94 -94
View File
@@ -1,95 +1,95 @@
# ============================================= # =============================================
# Linear Void .gitignore # Linear Void .gitignore
# ============================================= # =============================================
# Environment & Secrets # Environment & Secrets
.env .env
.env.local .env.local
.env.*.local .env.*.local
# Test coverage # Test coverage
coverage coverage
coverage.out coverage.out
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
# Benchmark artifacts # Benchmark artifacts
artifacts/ artifacts/
scripts/loadtest/artifacts/ scripts/loadtest/artifacts/
# Runtime data # Runtime data
pids pids
*.pid *.pid
*.seed *.seed
*.pid.lock *.pid.lock
# Dependency directories # Dependency directories
node_modules/ node_modules/
jspm_packages/ jspm_packages/
# Build outputs # Build outputs
dist/ dist/
build/ build/
out/ out/
*.min.js *.min.js
# Go build artifacts # Go build artifacts
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll
*.so *.so
*.dylib *.dylib
# FFmpeg temporary files # FFmpeg temporary files
*.ts.tmp *.ts.tmp
*.m3u8.tmp *.m3u8.tmp
*.m4s.tmp *.m4s.tmp
hls/ hls/
temp/ temp/
# Database & local data # Database & local data
*.db *.db
data/ data/
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
# Demo data (tracked in backend/playout/demodata/ for out-of-the-box playout) # Demo data (tracked in backend/playout/demodata/ for out-of-the-box playout)
!backend/playout/demodata/ !backend/playout/demodata/
# Docker # Docker
.dockerignore .dockerignore
**/.dockerignore **/.dockerignore
# Editor & IDE # Editor & IDE
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
*~ *~
# OS generated files # OS generated files
.DS_Store .DS_Store
.DS_Store? .DS_Store?
._* ._*
.Spotlight-V100 .Spotlight-V100
.Trashes .Trashes
ehthumbs.db ehthumbs.db
Thumbs.db Thumbs.db
# Terraform / IaC state # Terraform / IaC state
*.tfstate *.tfstate
*.tfstate.backup *.tfstate.backup
.terraform/ .terraform/
# Temporary architecture artifacts # Temporary architecture artifacts
**/*.tmp **/*.tmp
**/*.bak **/*.bak
# Note: .github/instructions/ is partially ignored but AGENTS.md and core docs are committed # Note: .github/instructions/ is partially ignored but AGENTS.md and core docs are committed
+95 -95
View File
@@ -1,95 +1,95 @@
# ============================================= # =============================================
# Linear Void .prettierignore # Linear Void .prettierignore
# ============================================= # =============================================
# Dependencies # Dependencies
node_modules/ node_modules/
# Build outputs # Build outputs
dist/ dist/
build/ build/
out/ out/
*.min.js *.min.js
# Test coverage # Test coverage
coverage/ coverage/
coverage.out coverage.out
# Go source (handled by gofmt, not prettier) # Go source (handled by gofmt, not prettier)
*.go *.go
go.mod go.mod
go.sum go.sum
# Binary / compiled # Binary / compiled
*.exe *.exe
*.dll *.dll
*.so *.so
*.dylib *.dylib
# Media files # Media files
*.m4s *.m4s
*.m3u8 *.m3u8
*.ts.tmp # FFmpeg temp, not TypeScript *.ts.tmp # FFmpeg temp, not TypeScript
*.m3u8.tmp *.m3u8.tmp
*.png *.png
*.jpg *.jpg
*.jpeg *.jpeg
*.gif *.gif
*.svg *.svg
*.ico *.ico
*.webp *.webp
*.mp4 *.mp4
*.webm *.webm
*.mp3 *.mp3
*.ogg *.ogg
# Runtime data # Runtime data
data/ data/
pids pids
*.pid *.pid
*.seed *.seed
*.pid.lock *.pid.lock
# Databases # Databases
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
# Lockfile (auto-generated, don't reformat) # Lockfile (auto-generated, don't reformat)
package-lock.json package-lock.json
# Shell scripts (prettier doesn't handle sh) # Shell scripts (prettier doesn't handle sh)
*.sh *.sh
# Environment & secrets # Environment & secrets
.env .env
.env.* .env.*
!.env.example !.env.example
# Logs # Logs
*.log *.log
# Editor & IDE # Editor & IDE
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Git # Git
.git/ .git/
# Terraform / IaC # Terraform / IaC
*.tfstate *.tfstate
*.tfstate.backup *.tfstate.backup
.terraform/ .terraform/
# Temp & backup files # Temp & backup files
**/*.tmp **/*.tmp
**/*.bak **/*.bak
# Docker # Docker
.dockerignore .dockerignore
+2
View File
@@ -6,10 +6,12 @@
### Added ### Added
- **Bash equivalents of Windows scripts**: Added `scripts/cache-hit-ratio.sh`, `scripts/check-env.sh`, `scripts/check-tokens.sh`, and `scripts/loadtest/run-loadtests.sh`, `scripts/loadtest/summarize-artillery.sh`, `scripts/loadtest/summarize-k6.sh` — Linux/macOS ports of the existing PowerShell scripts (the `.ps1` files are retained).
- **Transient presence flash**: Join/leave announcements now appear as a 3-second flash at the top of the chat panel (`#chat-flash`) with a fade-out, instead of permanent chat rows. Presence indicators still update from the same events. - **Transient presence flash**: Join/leave announcements now appear as a 3-second flash at the top of the chat panel (`#chat-flash`) with a fade-out, instead of permanent chat rows. Presence indicators still update from the same events.
### Changed ### Changed
- **Docs condensed and de-Windows'd**: Removed Windows/PowerShell-specific instructions from the docs and condensed the longest documents into tighter, bash-first guides — `SETUP.md`, `DEPLOYMENT.md`, `API.md`, `MONITORING.md`, `STREAMING.md`, `COOLIFY.md`, `incident-runbook.md`, `operations/scaling-performance-playbook.md`, `architecture/README.md`, and the Prometheus metrics inventory. `scripts/loadtest/README.md` now documents the bash load-test helpers.
- **System messages no longer persisted**: The chat backend no longer stores `system`-type messages; chat history and the admin chat-log table only contain real chat messages (moderation actions remain in the audit log). - **System messages no longer persisted**: The chat backend no longer stores `system`-type messages; chat history and the admin chat-log table only contain real chat messages (moderation actions remain in the audit log).
- **Reconnect grace period**: Disconnects schedule a delayed "left" announcement (5s); a reconnect within 5s cancels it and skips "joined", eliminating join/leave spam on brief reconnects. - **Reconnect grace period**: Disconnects schedule a delayed "left" announcement (5s); a reconnect within 5s cancels it and skips "joined", eliminating join/leave spam on brief reconnects.
- **HLS segment duration 2s**: Default `HLS_SEGMENT_DURATION` changed from 6 to 2 and `HLS_WINDOW_SIZE` from 24 to 12 (2s × 12 = 24s live window), reducing end-to-end latency; the 2s GOP was already in place. - **HLS segment duration 2s**: Default `HLS_SEGMENT_DURATION` changed from 6 to 2 and `HLS_WINDOW_SIZE` from 24 to 12 (2s × 12 = 24s live window), reducing end-to-end latency; the 2s GOP was already in place.
+7 -7
View File
@@ -1,7 +1,7 @@
Copyright © 2026 linearvoid.tv (Corey Pellizzi, Georg Sinn) Copyright © 2026 linearvoid.tv (Corey Pellizzi, Georg Sinn)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+5 -3
View File
@@ -213,11 +213,13 @@ One-off and CI-friendly scripts for video preparation, R2 sync, backups, and pla
- loadtest/ - loadtest/
- [admin-api.js](scripts/loadtest/admin-api.js) — admin API load test script - [admin-api.js](scripts/loadtest/admin-api.js) — admin API load test script
- [run-loadtests.ps1](scripts/loadtest/run-loadtests.ps1) — PowerShell script to run load tests - [run-loadtests.sh](scripts/loadtest/run-loadtests.sh) — bash script to run ws and/or admin load tests
- [summarize-artillery.ps1](scripts/loadtest/summarize-artillery.ps1) — PowerShell script to summarize Artillery load test results - [summarize-artillery.sh](scripts/loadtest/summarize-artillery.sh) — summarize Artillery JSON results
- [summarize-k6.ps1](scripts/loadtest/summarize-k6.ps1) — PowerShell script to summarize k6 load test results - [summarize-k6.sh](scripts/loadtest/summarize-k6.sh) — summarize k6 JSON results
- [ws-chat.yml](scripts/loadtest/ws-chat.yml) — WebSocket chat load test configuration - [ws-chat.yml](scripts/loadtest/ws-chat.yml) — WebSocket chat load test configuration
PowerShell equivalents (`*.ps1`) of the three load-test scripts are kept alongside them.
## Getting Started ## Getting Started
> **Note:** The `docker compose up` below targets local development. The test lab (192.168.88.40) receives automated CD deploys. Production Hetzner VPS is not yet provisioned — see `DEPLOYMENT.md`. > **Note:** The `docker compose up` below targets local development. The test lab (192.168.88.40) receives automated CD deploys. Production Hetzner VPS is not yet provisioned — see `DEPLOYMENT.md`.
+16 -16
View File
@@ -1,17 +1,17 @@
FROM golang:1.25-alpine AS builder FROM golang:1.25-alpine AS builder
RUN apk add --no-cache gcc musl-dev RUN apk add --no-cache gcc musl-dev
WORKDIR /app WORKDIR /app
COPY go.mod ./ COPY go.mod ./
COPY src/ ./src/ COPY src/ ./src/
RUN go mod tidy RUN go mod tidy
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
FROM alpine:3.20 FROM alpine:3.20
RUN apk --no-cache add ca-certificates curl ffmpeg RUN apk --no-cache add ca-certificates curl ffmpeg
WORKDIR /app WORKDIR /app
COPY --from=builder /chat-server /usr/local/bin/chat-server COPY --from=builder /chat-server /usr/local/bin/chat-server
EXPOSE 3000 EXPOSE 3000
CMD ["/usr/local/bin/chat-server"] CMD ["/usr/local/bin/chat-server"]
+206 -206
View File
@@ -1,207 +1,207 @@
package auth package auth
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
// SessionStore manages admin authentication sessions. // SessionStore manages admin authentication sessions.
type SessionStore struct { type SessionStore struct {
mu sync.Mutex mu sync.Mutex
sessions map[string]time.Time sessions map[string]time.Time
} }
// NewSessionStore creates a new session store. // NewSessionStore creates a new session store.
func NewSessionStore() *SessionStore { func NewSessionStore() *SessionStore {
return &SessionStore{ return &SessionStore{
sessions: make(map[string]time.Time), sessions: make(map[string]time.Time),
} }
} }
// GenerateToken creates a cryptographically random 64-char hex session token. // GenerateToken creates a cryptographically random 64-char hex session token.
func GenerateToken() (string, error) { func GenerateToken() (string, error) {
buf := make([]byte, 32) buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil { if _, err := rand.Read(buf); err != nil {
return "", err return "", err
} }
return hex.EncodeToString(buf), nil return hex.EncodeToString(buf), nil
} }
// Validate checks if a session token is valid and not expired. // Validate checks if a session token is valid and not expired.
// Returns true if valid, false otherwise. Expired sessions are cleaned up. // Returns true if valid, false otherwise. Expired sessions are cleaned up.
func (s *SessionStore) Validate(token string) bool { func (s *SessionStore) Validate(token string) bool {
if token == "" { if token == "" {
return false return false
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
expiresAt, ok := s.sessions[token] expiresAt, ok := s.sessions[token]
if !ok { if !ok {
return false return false
} }
if time.Now().After(expiresAt) { if time.Now().After(expiresAt) {
delete(s.sessions, token) delete(s.sessions, token)
return false return false
} }
return true return true
} }
// Add stores a session token with expiry. // Add stores a session token with expiry.
func (s *SessionStore) Add(token string, expiresAt time.Time) { func (s *SessionStore) Add(token string, expiresAt time.Time) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.sessions[token] = expiresAt s.sessions[token] = expiresAt
} }
// Remove deletes a session token. // Remove deletes a session token.
func (s *SessionStore) Remove(token string) { func (s *SessionStore) Remove(token string) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
delete(s.sessions, token) delete(s.sessions, token)
} }
// CountActive returns the number of unexpired sessions. // CountActive returns the number of unexpired sessions.
func (s *SessionStore) CountActive() int { func (s *SessionStore) CountActive() int {
now := time.Now() now := time.Now()
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
for token, expiresAt := range s.sessions { for token, expiresAt := range s.sessions {
if now.After(expiresAt) { if now.After(expiresAt) {
delete(s.sessions, token) delete(s.sessions, token)
} }
} }
return len(s.sessions) return len(s.sessions)
} }
// CookieSecure determines if the session cookie should have Secure flag. // CookieSecure determines if the session cookie should have Secure flag.
func CookieSecure(r *http.Request) bool { func CookieSecure(r *http.Request) bool {
if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" { if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
return strings.EqualFold(forwardedProto, "https") return strings.EqualFold(forwardedProto, "https")
} }
return r.TLS != nil return r.TLS != nil
} }
// SameSiteForUserCookie returns the SameSite policy for the viewer session cookie. // SameSiteForUserCookie returns the SameSite policy for the viewer session cookie.
// Over HTTPS it returns SameSite=None so the cookie is sent cross-site from the // Over HTTPS it returns SameSite=None so the cookie is sent cross-site from the
// Workers-served SPA; over plain HTTP (local dev) it falls back to Lax. // Workers-served SPA; over plain HTTP (local dev) it falls back to Lax.
func SameSiteForUserCookie(r *http.Request) http.SameSite { func SameSiteForUserCookie(r *http.Request) http.SameSite {
if CookieSecure(r) { if CookieSecure(r) {
return http.SameSiteNoneMode return http.SameSiteNoneMode
} }
return http.SameSiteLaxMode return http.SameSiteLaxMode
} }
// SetCookie writes a session cookie with the given SameSite policy. // SetCookie writes a session cookie with the given SameSite policy.
// SameSite=None is only valid together with Secure, which is enforced here. // SameSite=None is only valid together with Secure, which is enforced here.
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time, sameSite http.SameSite) { func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time, sameSite http.SameSite) {
secure := CookieSecure(r) secure := CookieSecure(r)
if sameSite == http.SameSiteNoneMode { if sameSite == http.SameSiteNoneMode {
secure = true secure = true
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: cookieName, Name: cookieName,
Value: token, Value: token,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
SameSite: sameSite, SameSite: sameSite,
Secure: secure, Secure: secure,
Expires: expiresAt, Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()), MaxAge: int(time.Until(expiresAt).Seconds()),
}) })
} }
// ClearCookie removes a session cookie with the given SameSite policy. // ClearCookie removes a session cookie with the given SameSite policy.
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string, sameSite http.SameSite) { func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string, sameSite http.SameSite) {
secure := CookieSecure(r) secure := CookieSecure(r)
if sameSite == http.SameSiteNoneMode { if sameSite == http.SameSiteNoneMode {
secure = true secure = true
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: cookieName, Name: cookieName,
Value: "", Value: "",
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
SameSite: sameSite, SameSite: sameSite,
Secure: secure, Secure: secure,
Expires: time.Unix(0, 0), Expires: time.Unix(0, 0),
MaxAge: -1, MaxAge: -1,
}) })
} }
// GetTokenFromCookie extracts the session token from the request cookie. // GetTokenFromCookie extracts the session token from the request cookie.
func GetTokenFromCookie(r *http.Request, cookieName string) (string, bool) { func GetTokenFromCookie(r *http.Request, cookieName string) (string, bool) {
cookie, err := r.Cookie(cookieName) cookie, err := r.Cookie(cookieName)
if err != nil || cookie.Value == "" { if err != nil || cookie.Value == "" {
return "", false return "", false
} }
return cookie.Value, true return cookie.Value, true
} }
// RequireAdmin returns middleware that checks for a valid admin session. // RequireAdmin returns middleware that checks for a valid admin session.
func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc) http.HandlerFunc { func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc { return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
token, ok := GetTokenFromCookie(r, cookieName) token, ok := GetTokenFromCookie(r, cookieName)
if !ok || !store.Validate(token) { if !ok || !store.Validate(token) {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
return return
} }
next(w, r) next(w, r)
} }
} }
} }
// UserSessionStore maps user session tokens to usernames with expiry. // UserSessionStore maps user session tokens to usernames with expiry.
type UserSessionStore struct { type UserSessionStore struct {
mu sync.Mutex mu sync.Mutex
sessions map[string]userSession sessions map[string]userSession
} }
type userSession struct { type userSession struct {
Username string Username string
ExpiresAt time.Time ExpiresAt time.Time
} }
// NewUserSessionStore creates a new user session store. // NewUserSessionStore creates a new user session store.
func NewUserSessionStore() *UserSessionStore { func NewUserSessionStore() *UserSessionStore {
return &UserSessionStore{sessions: make(map[string]userSession)} return &UserSessionStore{sessions: make(map[string]userSession)}
} }
// Add stores a user session token with username and expiry. // Add stores a user session token with username and expiry.
func (s *UserSessionStore) Add(token, username string, expiresAt time.Time) { func (s *UserSessionStore) Add(token, username string, expiresAt time.Time) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.sessions[token] = userSession{Username: username, ExpiresAt: expiresAt} s.sessions[token] = userSession{Username: username, ExpiresAt: expiresAt}
} }
// Username returns the username for a valid, unexpired token. // Username returns the username for a valid, unexpired token.
func (s *UserSessionStore) Username(token string) (string, bool) { func (s *UserSessionStore) Username(token string) (string, bool) {
if token == "" { if token == "" {
return "", false return "", false
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
sess, ok := s.sessions[token] sess, ok := s.sessions[token]
if !ok { if !ok {
return "", false return "", false
} }
if time.Now().After(sess.ExpiresAt) { if time.Now().After(sess.ExpiresAt) {
delete(s.sessions, token) delete(s.sessions, token)
return "", false return "", false
} }
return sess.Username, true return sess.Username, true
} }
// Remove deletes a user session token. // Remove deletes a user session token.
func (s *UserSessionStore) Remove(token string) { func (s *UserSessionStore) Remove(token string) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
delete(s.sessions, token) delete(s.sessions, token)
} }
+308 -308
View File
@@ -1,309 +1,309 @@
package auth package auth
import ( import (
"crypto/tls" "crypto/tls"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time" "time"
) )
func TestGenerateToken(t *testing.T) { func TestGenerateToken(t *testing.T) {
token, err := GenerateToken() token, err := GenerateToken()
if err != nil { if err != nil {
t.Fatalf("GenerateToken() error = %v", err) t.Fatalf("GenerateToken() error = %v", err)
} }
if len(token) != 64 { if len(token) != 64 {
t.Errorf("GenerateToken() length = %d, want 64", len(token)) t.Errorf("GenerateToken() length = %d, want 64", len(token))
} }
// Uniqueness check // Uniqueness check
tokens := make(map[string]bool) tokens := make(map[string]bool)
for i := 0; i < 1000; i++ { for i := 0; i < 1000; i++ {
tok, err := GenerateToken() tok, err := GenerateToken()
if err != nil { if err != nil {
t.Fatalf("GenerateToken() iteration %d error = %v", i, err) t.Fatalf("GenerateToken() iteration %d error = %v", i, err)
} }
if tokens[tok] { if tokens[tok] {
t.Errorf("GenerateToken() produced duplicate token at iteration %d", i) t.Errorf("GenerateToken() produced duplicate token at iteration %d", i)
} }
tokens[tok] = true tokens[tok] = true
} }
} }
func TestSessionStore_Validate(t *testing.T) { func TestSessionStore_Validate(t *testing.T) {
store := NewSessionStore() store := NewSessionStore()
// Empty token // Empty token
if store.Validate("") { if store.Validate("") {
t.Error("Validate(\"\") = true, want false") t.Error("Validate(\"\") = true, want false")
} }
// Missing token // Missing token
if store.Validate("nonexistent") { if store.Validate("nonexistent") {
t.Error("Validate(\"nonexistent\") = true, want false") t.Error("Validate(\"nonexistent\") = true, want false")
} }
// Valid token // Valid token
token, _ := GenerateToken() token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour)) store.Add(token, time.Now().Add(1*time.Hour))
if !store.Validate(token) { if !store.Validate(token) {
t.Error("Validate(valid) = false, want true") t.Error("Validate(valid) = false, want true")
} }
// Expired token // Expired token
expiredToken, _ := GenerateToken() expiredToken, _ := GenerateToken()
store.Add(expiredToken, time.Now().Add(-1*time.Hour)) store.Add(expiredToken, time.Now().Add(-1*time.Hour))
if store.Validate(expiredToken) { if store.Validate(expiredToken) {
t.Error("Validate(expired) = true, want false") t.Error("Validate(expired) = true, want false")
} }
// Expired token should be cleaned up // Expired token should be cleaned up
if store.Validate(expiredToken) { if store.Validate(expiredToken) {
t.Error("Validate(expired) second call = true, want false (should be cleaned)") t.Error("Validate(expired) second call = true, want false (should be cleaned)")
} }
} }
func TestSessionStore_AddRemove(t *testing.T) { func TestSessionStore_AddRemove(t *testing.T) {
store := NewSessionStore() store := NewSessionStore()
token, _ := GenerateToken() token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour)) store.Add(token, time.Now().Add(1*time.Hour))
if !store.Validate(token) { if !store.Validate(token) {
t.Error("Validate after Add = false, want true") t.Error("Validate after Add = false, want true")
} }
store.Remove(token) store.Remove(token)
if store.Validate(token) { if store.Validate(token) {
t.Error("Validate after Remove = true, want false") t.Error("Validate after Remove = true, want false")
} }
} }
func TestSessionStore_CountActive(t *testing.T) { func TestSessionStore_CountActive(t *testing.T) {
store := NewSessionStore() store := NewSessionStore()
if count := store.CountActive(); count != 0 { if count := store.CountActive(); count != 0 {
t.Errorf("CountActive() = %d, want 0", count) t.Errorf("CountActive() = %d, want 0", count)
} }
t1, _ := GenerateToken() t1, _ := GenerateToken()
t2, _ := GenerateToken() t2, _ := GenerateToken()
t3, _ := GenerateToken() t3, _ := GenerateToken()
store.Add(t1, time.Now().Add(1*time.Hour)) store.Add(t1, time.Now().Add(1*time.Hour))
store.Add(t2, 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 store.Add(t3, time.Now().Add(-1*time.Hour)) // expired
if count := store.CountActive(); count != 2 { if count := store.CountActive(); count != 2 {
t.Errorf("CountActive() = %d, want 2 (expired should be cleaned)", count) t.Errorf("CountActive() = %d, want 2 (expired should be cleaned)", count)
} }
} }
func TestCookieSecure(t *testing.T) { func TestCookieSecure(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
forwardedProto string forwardedProto string
tls bool tls bool
expected bool expected bool
}{ }{
{"https forwarded", "https", false, true}, {"https forwarded", "https", false, true},
{"http forwarded", "http", false, false}, {"http forwarded", "http", false, false},
{"empty forwarded, no TLS", "", false, false}, {"empty forwarded, no TLS", "", false, false},
{"empty forwarded, TLS", "", true, true}, {"empty forwarded, TLS", "", true, true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
if tt.forwardedProto != "" { if tt.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto) req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
} }
if tt.tls { if tt.tls {
req.TLS = &tls.ConnectionState{} req.TLS = &tls.ConnectionState{}
} }
if got := CookieSecure(req); got != tt.expected { if got := CookieSecure(req); got != tt.expected {
t.Errorf("CookieSecure() = %v, want %v", got, tt.expected) t.Errorf("CookieSecure() = %v, want %v", got, tt.expected)
} }
}) })
} }
} }
func TestSetCookie(t *testing.T) { func TestSetCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder() w := httptest.NewRecorder()
expiresAt := time.Now().Add(1 * time.Hour) expiresAt := time.Now().Add(1 * time.Hour)
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteStrictMode) SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteStrictMode)
cookies := w.Result().Cookies() cookies := w.Result().Cookies()
if len(cookies) != 1 { if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies)) t.Fatalf("expected 1 cookie, got %d", len(cookies))
} }
c := cookies[0] c := cookies[0]
if c.Name != "test_cookie" { if c.Name != "test_cookie" {
t.Errorf("cookie name = %q, want test_cookie", c.Name) t.Errorf("cookie name = %q, want test_cookie", c.Name)
} }
if c.Value != "abc123" { if c.Value != "abc123" {
t.Errorf("cookie value = %q, want abc123", c.Value) t.Errorf("cookie value = %q, want abc123", c.Value)
} }
if !c.HttpOnly { if !c.HttpOnly {
t.Error("cookie HttpOnly = false, want true") t.Error("cookie HttpOnly = false, want true")
} }
if c.SameSite != http.SameSiteStrictMode { if c.SameSite != http.SameSiteStrictMode {
t.Errorf("cookie SameSite = %v, want Strict", c.SameSite) t.Errorf("cookie SameSite = %v, want Strict", c.SameSite)
} }
if !c.Secure { if !c.Secure {
t.Error("cookie Secure = false, want true (X-Forwarded-Proto: https)") t.Error("cookie Secure = false, want true (X-Forwarded-Proto: https)")
} }
if c.Path != "/" { if c.Path != "/" {
t.Errorf("cookie Path = %q, want /", c.Path) t.Errorf("cookie Path = %q, want /", c.Path)
} }
} }
func TestClearCookie(t *testing.T) { func TestClearCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ClearCookie(w, req, "test_cookie", http.SameSiteStrictMode) ClearCookie(w, req, "test_cookie", http.SameSiteStrictMode)
cookies := w.Result().Cookies() cookies := w.Result().Cookies()
if len(cookies) != 1 { if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies)) t.Fatalf("expected 1 cookie, got %d", len(cookies))
} }
c := cookies[0] c := cookies[0]
if c.Name != "test_cookie" { if c.Name != "test_cookie" {
t.Errorf("cookie name = %q, want test_cookie", c.Name) t.Errorf("cookie name = %q, want test_cookie", c.Name)
} }
if c.Value != "" { if c.Value != "" {
t.Errorf("cookie value = %q, want empty", c.Value) t.Errorf("cookie value = %q, want empty", c.Value)
} }
if c.MaxAge != -1 { if c.MaxAge != -1 {
t.Errorf("cookie MaxAge = %d, want -1", c.MaxAge) t.Errorf("cookie MaxAge = %d, want -1", c.MaxAge)
} }
} }
func TestSameSiteForUserCookie(t *testing.T) { func TestSameSiteForUserCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Proto", "https")
if got := SameSiteForUserCookie(req); got != http.SameSiteNoneMode { if got := SameSiteForUserCookie(req); got != http.SameSiteNoneMode {
t.Errorf("SameSiteForUserCookie(https) = %v, want None", got) t.Errorf("SameSiteForUserCookie(https) = %v, want None", got)
} }
req2 := httptest.NewRequest("GET", "/", nil) req2 := httptest.NewRequest("GET", "/", nil)
if got := SameSiteForUserCookie(req2); got != http.SameSiteLaxMode { if got := SameSiteForUserCookie(req2); got != http.SameSiteLaxMode {
t.Errorf("SameSiteForUserCookie(http) = %v, want Lax", got) t.Errorf("SameSiteForUserCookie(http) = %v, want Lax", got)
} }
} }
func TestSetCookie_NoneForcesSecure(t *testing.T) { func TestSetCookie_NoneForcesSecure(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
expiresAt := time.Now().Add(1 * time.Hour) expiresAt := time.Now().Add(1 * time.Hour)
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteNoneMode) SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteNoneMode)
cookies := w.Result().Cookies() cookies := w.Result().Cookies()
if len(cookies) != 1 { if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies)) t.Fatalf("expected 1 cookie, got %d", len(cookies))
} }
c := cookies[0] c := cookies[0]
if c.SameSite != http.SameSiteNoneMode { if c.SameSite != http.SameSiteNoneMode {
t.Errorf("cookie SameSite = %v, want None", c.SameSite) t.Errorf("cookie SameSite = %v, want None", c.SameSite)
} }
if !c.Secure { if !c.Secure {
t.Error("cookie Secure = false, want true (SameSite=None requires Secure)") t.Error("cookie Secure = false, want true (SameSite=None requires Secure)")
} }
} }
func TestGetTokenFromCookie(t *testing.T) { func TestGetTokenFromCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"}) req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
token, ok := GetTokenFromCookie(req, "session") token, ok := GetTokenFromCookie(req, "session")
if !ok { if !ok {
t.Error("GetTokenFromCookie() ok = false, want true") t.Error("GetTokenFromCookie() ok = false, want true")
} }
if token != "token123" { if token != "token123" {
t.Errorf("GetTokenFromCookie() = %q, want token123", token) t.Errorf("GetTokenFromCookie() = %q, want token123", token)
} }
// Missing cookie // Missing cookie
_, ok = GetTokenFromCookie(req, "nonexistent") _, ok = GetTokenFromCookie(req, "nonexistent")
if ok { if ok {
t.Error("GetTokenFromCookie() for missing cookie ok = true, want false") t.Error("GetTokenFromCookie() for missing cookie ok = true, want false")
} }
// Empty value // Empty value
req2 := httptest.NewRequest("GET", "/", nil) req2 := httptest.NewRequest("GET", "/", nil)
req2.AddCookie(&http.Cookie{Name: "session", Value: ""}) req2.AddCookie(&http.Cookie{Name: "session", Value: ""})
_, ok = GetTokenFromCookie(req2, "session") _, ok = GetTokenFromCookie(req2, "session")
if ok { if ok {
t.Error("GetTokenFromCookie() for empty value ok = true, want false") t.Error("GetTokenFromCookie() for empty value ok = true, want false")
} }
} }
func TestRequireAdmin(t *testing.T) { func TestRequireAdmin(t *testing.T) {
store := NewSessionStore() store := NewSessionStore()
token, _ := GenerateToken() token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour)) store.Add(token, time.Now().Add(1*time.Hour))
handler := RequireAdmin(store, "session")(func(w http.ResponseWriter, r *http.Request) { handler := RequireAdmin(store, "session")(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("ok")) w.Write([]byte("ok"))
}) })
// Valid session // Valid session
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: token}) req.AddCookie(&http.Cookie{Name: "session", Value: token})
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler(w, req) handler(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code) t.Errorf("status = %d, want 200", w.Code)
} }
// No cookie // No cookie
req2 := httptest.NewRequest("GET", "/", nil) req2 := httptest.NewRequest("GET", "/", nil)
w2 := httptest.NewRecorder() w2 := httptest.NewRecorder()
handler(w2, req2) handler(w2, req2)
if w2.Code != http.StatusUnauthorized { if w2.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w2.Code) t.Errorf("status = %d, want 401", w2.Code)
} }
// Invalid token // Invalid token
req3 := httptest.NewRequest("GET", "/", nil) req3 := httptest.NewRequest("GET", "/", nil)
req3.AddCookie(&http.Cookie{Name: "session", Value: "invalid"}) req3.AddCookie(&http.Cookie{Name: "session", Value: "invalid"})
w3 := httptest.NewRecorder() w3 := httptest.NewRecorder()
handler(w3, req3) handler(w3, req3)
if w3.Code != http.StatusUnauthorized { if w3.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w3.Code) t.Errorf("status = %d, want 401", w3.Code)
} }
} }
func TestUserSessionStore(t *testing.T) { func TestUserSessionStore(t *testing.T) {
store := NewUserSessionStore() store := NewUserSessionStore()
if _, ok := store.Username(""); ok { if _, ok := store.Username(""); ok {
t.Error("Username(\"\") = true, want false") t.Error("Username(\"\") = true, want false")
} }
token, _ := GenerateToken() token, _ := GenerateToken()
store.Add(token, "alice", time.Now().Add(1*time.Hour)) store.Add(token, "alice", time.Now().Add(1*time.Hour))
username, ok := store.Username(token) username, ok := store.Username(token)
if !ok || username != "alice" { if !ok || username != "alice" {
t.Errorf("Username() = %q, %v; want alice, true", username, ok) t.Errorf("Username() = %q, %v; want alice, true", username, ok)
} }
// Expired session // Expired session
expired, _ := GenerateToken() expired, _ := GenerateToken()
store.Add(expired, "bob", time.Now().Add(-1*time.Hour)) store.Add(expired, "bob", time.Now().Add(-1*time.Hour))
if _, ok := store.Username(expired); ok { if _, ok := store.Username(expired); ok {
t.Error("Username(expired) = true, want false") t.Error("Username(expired) = true, want false")
} }
// Remove // Remove
store.Remove(token) store.Remove(token)
if _, ok := store.Username(token); ok { if _, ok := store.Username(token); ok {
t.Error("Username after Remove = true, want false") t.Error("Username after Remove = true, want false")
} }
} }
+131 -131
View File
@@ -1,132 +1,132 @@
package config package config
import ( import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
) )
// Defaults and constants // Defaults and constants
const ( const (
AdminSessionCookieName = "linearvoid_admin_session" AdminSessionCookieName = "linearvoid_admin_session"
MaxAdminUploadBytes int64 = 2 * 1024 * 1024 * 1024 MaxAdminUploadBytes int64 = 2 * 1024 * 1024 * 1024
DefaultAdminChunkSize int64 = 8 * 1024 * 1024 DefaultAdminChunkSize int64 = 8 * 1024 * 1024
MaxAdminChunkSize int64 = 32 * 1024 * 1024 MaxAdminChunkSize int64 = 32 * 1024 * 1024
DefaultRateLimitMax = 10 DefaultRateLimitMax = 10
DefaultRateLimitWindow = 10 * time.Second DefaultRateLimitWindow = 10 * time.Second
DefaultAdminSessionTTL = 12 * time.Hour DefaultAdminSessionTTL = 12 * time.Hour
DefaultUserSessionTTL = 30 * 24 * time.Hour DefaultUserSessionTTL = 30 * 24 * time.Hour
DefaultChatPort = "3000" DefaultChatPort = "3000"
) )
// PlaylistPath returns the playlist file path from env or default. // PlaylistPath returns the playlist file path from env or default.
func PlaylistPath() string { func PlaylistPath() string {
p := strings.TrimSpace(os.Getenv("PLAYLIST_PATH")) p := strings.TrimSpace(os.Getenv("PLAYLIST_PATH"))
if p == "" { if p == "" {
return "/app/playlist/playlist.txt" return "/app/playlist/playlist.txt"
} }
return p return p
} }
// MediaDir returns the media directory from env or default. // MediaDir returns the media directory from env or default.
func MediaDir() string { func MediaDir() string {
d := strings.TrimSpace(os.Getenv("MEDIA_DIR")) d := strings.TrimSpace(os.Getenv("MEDIA_DIR"))
if d == "" { if d == "" {
return "/app/media" return "/app/media"
} }
return d return d
} }
// AdminUsername returns the admin username from env or default. // AdminUsername returns the admin username from env or default.
func AdminUsername() string { func AdminUsername() string {
u := strings.TrimSpace(os.Getenv("ADMIN_USERNAME")) u := strings.TrimSpace(os.Getenv("ADMIN_USERNAME"))
if u == "" { if u == "" {
return "admin" return "admin"
} }
return u return u
} }
// AdminPassword returns the admin password from env or default. // AdminPassword returns the admin password from env or default.
func AdminPassword() string { func AdminPassword() string {
p := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD")) p := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD"))
if p == "" { if p == "" {
return "admin" return "admin"
} }
return p return p
} }
// AdminSessionTTL returns the admin session TTL from env or default. // AdminSessionTTL returns the admin session TTL from env or default.
func AdminSessionTTL() time.Duration { func AdminSessionTTL() time.Duration {
raw := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS")) raw := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS"))
if raw == "" { if raw == "" {
return DefaultAdminSessionTTL return DefaultAdminSessionTTL
} }
hours, err := strconv.Atoi(raw) hours, err := strconv.Atoi(raw)
if err != nil || hours <= 0 { if err != nil || hours <= 0 {
return DefaultAdminSessionTTL return DefaultAdminSessionTTL
} }
return time.Duration(hours) * time.Hour return time.Duration(hours) * time.Hour
} }
// UserSessionTTL returns the user session TTL from env or default (30 days). // UserSessionTTL returns the user session TTL from env or default (30 days).
func UserSessionTTL() time.Duration { func UserSessionTTL() time.Duration {
raw := strings.TrimSpace(os.Getenv("USER_SESSION_TTL_HOURS")) raw := strings.TrimSpace(os.Getenv("USER_SESSION_TTL_HOURS"))
if raw == "" { if raw == "" {
return DefaultUserSessionTTL return DefaultUserSessionTTL
} }
hours, err := strconv.Atoi(raw) hours, err := strconv.Atoi(raw)
if err != nil || hours <= 0 { if err != nil || hours <= 0 {
return DefaultUserSessionTTL return DefaultUserSessionTTL
} }
return time.Duration(hours) * time.Hour return time.Duration(hours) * time.Hour
} }
// RateLimitMax returns the chat rate limit max messages per window. // RateLimitMax returns the chat rate limit max messages per window.
func RateLimitMax() int { func RateLimitMax() int {
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX")) raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
if raw == "" { if raw == "" {
return DefaultRateLimitMax return DefaultRateLimitMax
} }
v, err := strconv.Atoi(raw) v, err := strconv.Atoi(raw)
if err != nil || v <= 0 { if err != nil || v <= 0 {
return DefaultRateLimitMax return DefaultRateLimitMax
} }
return v return v
} }
// RateLimitWindow returns the chat rate limit window duration. // RateLimitWindow returns the chat rate limit window duration.
func RateLimitWindow() time.Duration { func RateLimitWindow() time.Duration {
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_WINDOW_S")) raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_WINDOW_S"))
if raw == "" { if raw == "" {
return DefaultRateLimitWindow return DefaultRateLimitWindow
} }
v, err := strconv.Atoi(raw) v, err := strconv.Atoi(raw)
if err != nil || v <= 0 { if err != nil || v <= 0 {
return DefaultRateLimitWindow return DefaultRateLimitWindow
} }
return time.Duration(v) * time.Second return time.Duration(v) * time.Second
} }
// ChatPort returns the chat service port from env or default. // ChatPort returns the chat service port from env or default.
func ChatPort() string { func ChatPort() string {
p := strings.TrimSpace(os.Getenv("CHAT_PORT")) p := strings.TrimSpace(os.Getenv("CHAT_PORT"))
if p == "" { if p == "" {
return DefaultChatPort return DefaultChatPort
} }
return p return p
} }
// RedisAddress returns the Redis address from env or default. // RedisAddress returns the Redis address from env or default.
func RedisAddress() string { func RedisAddress() string {
a := strings.TrimSpace(os.Getenv("REDIS_URL")) a := strings.TrimSpace(os.Getenv("REDIS_URL"))
if a == "" { if a == "" {
return "redis:6379" return "redis:6379"
} }
return a return a
} }
// DatabaseURL returns the database URL from env (empty = SQLite default). // DatabaseURL returns the database URL from env (empty = SQLite default).
func DatabaseURL() string { func DatabaseURL() string {
return strings.TrimSpace(os.Getenv("DATABASE_URL")) return strings.TrimSpace(os.Getenv("DATABASE_URL"))
} }
+266 -266
View File
@@ -1,267 +1,267 @@
package config package config
import ( import (
"os" "os"
"testing" "testing"
"time" "time"
) )
func TestPlaylistPath(t *testing.T) { func TestPlaylistPath(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
env string env string
expected string expected string
}{ }{
{"default", "", "/app/playlist/playlist.txt"}, {"default", "", "/app/playlist/playlist.txt"},
{"custom", "/custom/playlist.txt", "/custom/playlist.txt"}, {"custom", "/custom/playlist.txt", "/custom/playlist.txt"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.env != "" { if tt.env != "" {
os.Setenv("PLAYLIST_PATH", tt.env) os.Setenv("PLAYLIST_PATH", tt.env)
defer os.Unsetenv("PLAYLIST_PATH") defer os.Unsetenv("PLAYLIST_PATH")
} else { } else {
os.Unsetenv("PLAYLIST_PATH") os.Unsetenv("PLAYLIST_PATH")
} }
if got := PlaylistPath(); got != tt.expected { if got := PlaylistPath(); got != tt.expected {
t.Errorf("PlaylistPath() = %q, want %q", got, tt.expected) t.Errorf("PlaylistPath() = %q, want %q", got, tt.expected)
} }
}) })
} }
} }
func TestMediaDir(t *testing.T) { func TestMediaDir(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
env string env string
expected string expected string
}{ }{
{"default", "", "/app/media"}, {"default", "", "/app/media"},
{"custom", "/data/media", "/data/media"}, {"custom", "/data/media", "/data/media"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.env != "" { if tt.env != "" {
os.Setenv("MEDIA_DIR", tt.env) os.Setenv("MEDIA_DIR", tt.env)
defer os.Unsetenv("MEDIA_DIR") defer os.Unsetenv("MEDIA_DIR")
} else { } else {
os.Unsetenv("MEDIA_DIR") os.Unsetenv("MEDIA_DIR")
} }
if got := MediaDir(); got != tt.expected { if got := MediaDir(); got != tt.expected {
t.Errorf("MediaDir() = %q, want %q", got, tt.expected) t.Errorf("MediaDir() = %q, want %q", got, tt.expected)
} }
}) })
} }
} }
func TestAdminUsername(t *testing.T) { func TestAdminUsername(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
env string env string
expected string expected string
}{ }{
{"default", "", "admin"}, {"default", "", "admin"},
{"custom", "superadmin", "superadmin"}, {"custom", "superadmin", "superadmin"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.env != "" { if tt.env != "" {
os.Setenv("ADMIN_USERNAME", tt.env) os.Setenv("ADMIN_USERNAME", tt.env)
defer os.Unsetenv("ADMIN_USERNAME") defer os.Unsetenv("ADMIN_USERNAME")
} else { } else {
os.Unsetenv("ADMIN_USERNAME") os.Unsetenv("ADMIN_USERNAME")
} }
if got := AdminUsername(); got != tt.expected { if got := AdminUsername(); got != tt.expected {
t.Errorf("AdminUsername() = %q, want %q", got, tt.expected) t.Errorf("AdminUsername() = %q, want %q", got, tt.expected)
} }
}) })
} }
} }
func TestAdminPassword(t *testing.T) { func TestAdminPassword(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
env string env string
expected string expected string
}{ }{
{"default", "", "admin"}, {"default", "", "admin"},
{"custom", "secret123", "secret123"}, {"custom", "secret123", "secret123"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.env != "" { if tt.env != "" {
os.Setenv("ADMIN_PASSWORD", tt.env) os.Setenv("ADMIN_PASSWORD", tt.env)
defer os.Unsetenv("ADMIN_PASSWORD") defer os.Unsetenv("ADMIN_PASSWORD")
} else { } else {
os.Unsetenv("ADMIN_PASSWORD") os.Unsetenv("ADMIN_PASSWORD")
} }
if got := AdminPassword(); got != tt.expected { if got := AdminPassword(); got != tt.expected {
t.Errorf("AdminPassword() = %q, want %q", got, tt.expected) t.Errorf("AdminPassword() = %q, want %q", got, tt.expected)
} }
}) })
} }
} }
func TestAdminSessionTTL(t *testing.T) { func TestAdminSessionTTL(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
env string env string
expected time.Duration expected time.Duration
}{ }{
{"default", "", 12 * time.Hour}, {"default", "", 12 * time.Hour},
{"custom", "24", 24 * time.Hour}, {"custom", "24", 24 * time.Hour},
{"zero", "0", 12 * time.Hour}, {"zero", "0", 12 * time.Hour},
{"negative", "-5", 12 * time.Hour}, {"negative", "-5", 12 * time.Hour},
{"invalid", "abc", 12 * time.Hour}, {"invalid", "abc", 12 * time.Hour},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.env != "" { if tt.env != "" {
os.Setenv("ADMIN_SESSION_TTL_HOURS", tt.env) os.Setenv("ADMIN_SESSION_TTL_HOURS", tt.env)
defer os.Unsetenv("ADMIN_SESSION_TTL_HOURS") defer os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
} else { } else {
os.Unsetenv("ADMIN_SESSION_TTL_HOURS") os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
} }
if got := AdminSessionTTL(); got != tt.expected { if got := AdminSessionTTL(); got != tt.expected {
t.Errorf("AdminSessionTTL() = %v, want %v", got, tt.expected) t.Errorf("AdminSessionTTL() = %v, want %v", got, tt.expected)
} }
}) })
} }
} }
func TestRateLimitMax(t *testing.T) { func TestRateLimitMax(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
env string env string
expected int expected int
}{ }{
{"default", "", 10}, {"default", "", 10},
{"custom", "20", 20}, {"custom", "20", 20},
{"zero", "0", 10}, {"zero", "0", 10},
{"negative", "-5", 10}, {"negative", "-5", 10},
{"invalid", "abc", 10}, {"invalid", "abc", 10},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.env != "" { if tt.env != "" {
os.Setenv("CHAT_RATE_LIMIT_MAX", tt.env) os.Setenv("CHAT_RATE_LIMIT_MAX", tt.env)
defer os.Unsetenv("CHAT_RATE_LIMIT_MAX") defer os.Unsetenv("CHAT_RATE_LIMIT_MAX")
} else { } else {
os.Unsetenv("CHAT_RATE_LIMIT_MAX") os.Unsetenv("CHAT_RATE_LIMIT_MAX")
} }
if got := RateLimitMax(); got != tt.expected { if got := RateLimitMax(); got != tt.expected {
t.Errorf("RateLimitMax() = %d, want %d", got, tt.expected) t.Errorf("RateLimitMax() = %d, want %d", got, tt.expected)
} }
}) })
} }
} }
func TestRateLimitWindow(t *testing.T) { func TestRateLimitWindow(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
env string env string
expected time.Duration expected time.Duration
}{ }{
{"default", "", 10 * time.Second}, {"default", "", 10 * time.Second},
{"custom", "30", 30 * time.Second}, {"custom", "30", 30 * time.Second},
{"zero", "0", 10 * time.Second}, {"zero", "0", 10 * time.Second},
{"negative", "-5", 10 * time.Second}, {"negative", "-5", 10 * time.Second},
{"invalid", "abc", 10 * time.Second}, {"invalid", "abc", 10 * time.Second},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.env != "" { if tt.env != "" {
os.Setenv("CHAT_RATE_LIMIT_WINDOW_S", tt.env) os.Setenv("CHAT_RATE_LIMIT_WINDOW_S", tt.env)
defer os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S") defer os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
} else { } else {
os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S") os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
} }
if got := RateLimitWindow(); got != tt.expected { if got := RateLimitWindow(); got != tt.expected {
t.Errorf("RateLimitWindow() = %v, want %v", got, tt.expected) t.Errorf("RateLimitWindow() = %v, want %v", got, tt.expected)
} }
}) })
} }
} }
func TestChatPort(t *testing.T) { func TestChatPort(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
env string env string
expected string expected string
}{ }{
{"default", "", "3000"}, {"default", "", "3000"},
{"custom", "8080", "8080"}, {"custom", "8080", "8080"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.env != "" { if tt.env != "" {
os.Setenv("CHAT_PORT", tt.env) os.Setenv("CHAT_PORT", tt.env)
defer os.Unsetenv("CHAT_PORT") defer os.Unsetenv("CHAT_PORT")
} else { } else {
os.Unsetenv("CHAT_PORT") os.Unsetenv("CHAT_PORT")
} }
if got := ChatPort(); got != tt.expected { if got := ChatPort(); got != tt.expected {
t.Errorf("ChatPort() = %q, want %q", got, tt.expected) t.Errorf("ChatPort() = %q, want %q", got, tt.expected)
} }
}) })
} }
} }
func TestRedisAddress(t *testing.T) { func TestRedisAddress(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
env string env string
expected string expected string
}{ }{
{"default", "", "redis:6379"}, {"default", "", "redis:6379"},
{"custom", "redis.example.com:6380", "redis.example.com:6380"}, {"custom", "redis.example.com:6380", "redis.example.com:6380"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.env != "" { if tt.env != "" {
os.Setenv("REDIS_URL", tt.env) os.Setenv("REDIS_URL", tt.env)
defer os.Unsetenv("REDIS_URL") defer os.Unsetenv("REDIS_URL")
} else { } else {
os.Unsetenv("REDIS_URL") os.Unsetenv("REDIS_URL")
} }
if got := RedisAddress(); got != tt.expected { if got := RedisAddress(); got != tt.expected {
t.Errorf("RedisAddress() = %q, want %q", got, tt.expected) t.Errorf("RedisAddress() = %q, want %q", got, tt.expected)
} }
}) })
} }
} }
func TestDatabaseURL(t *testing.T) { func TestDatabaseURL(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
env string env string
expected string expected string
}{ }{
{"default", "", ""}, {"default", "", ""},
{"sqlite", "sqlite://test.db", "sqlite://test.db"}, {"sqlite", "sqlite://test.db", "sqlite://test.db"},
{"postgres", "postgres://user:pass@localhost/db", "postgres://user:pass@localhost/db"}, {"postgres", "postgres://user:pass@localhost/db", "postgres://user:pass@localhost/db"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.env != "" { if tt.env != "" {
os.Setenv("DATABASE_URL", tt.env) os.Setenv("DATABASE_URL", tt.env)
defer os.Unsetenv("DATABASE_URL") defer os.Unsetenv("DATABASE_URL")
} else { } else {
os.Unsetenv("DATABASE_URL") os.Unsetenv("DATABASE_URL")
} }
if got := DatabaseURL(); got != tt.expected { if got := DatabaseURL(); got != tt.expected {
t.Errorf("DatabaseURL() = %q, want %q", got, tt.expected) t.Errorf("DatabaseURL() = %q, want %q", got, tt.expected)
} }
}) })
} }
} }
+221 -221
View File
@@ -1,221 +1,221 @@
package config package config
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
) )
// Runtime configuration keys stored in the runtime config file. // Runtime configuration keys stored in the runtime config file.
const ( const (
keyMatomoURL = "matomo_url" keyMatomoURL = "matomo_url"
keyMatomoSiteID = "matomo_site_id" keyMatomoSiteID = "matomo_site_id"
keyHLSSegmentDur = "hls_segment_duration" keyHLSSegmentDur = "hls_segment_duration"
keyHLSWindowSize = "hls_window_size" keyHLSWindowSize = "hls_window_size"
) )
// Default HLS values used when neither env nor persisted config is set. // Default HLS values used when neither env nor persisted config is set.
const ( const (
defaultHLSSegmentDuration = 2 defaultHLSSegmentDuration = 2
defaultHLSWindowSize = 12 defaultHLSWindowSize = 12
) )
var ( var (
runtimeMu sync.RWMutex runtimeMu sync.RWMutex
runtimeStore = map[string]string{} runtimeStore = map[string]string{}
) )
// ConfigPath returns the runtime config JSON file path from env or default. // ConfigPath returns the runtime config JSON file path from env or default.
// The default lives in the shared playlist volume so the playout service can // The default lives in the shared playlist volume so the playout service can
// read the same file (chat has read-write, playout read-only). // read the same file (chat has read-write, playout read-only).
func ConfigPath() string { func ConfigPath() string {
p := strings.TrimSpace(os.Getenv("CONFIG_PATH")) p := strings.TrimSpace(os.Getenv("CONFIG_PATH"))
if p == "" { if p == "" {
return "/app/playlist/config.json" return "/app/playlist/config.json"
} }
return p return p
} }
// GetRuntime returns a value from the runtime store and whether it is present. // GetRuntime returns a value from the runtime store and whether it is present.
func GetRuntime(key string) (string, bool) { func GetRuntime(key string) (string, bool) {
runtimeMu.RLock() runtimeMu.RLock()
defer runtimeMu.RUnlock() defer runtimeMu.RUnlock()
v, ok := runtimeStore[key] v, ok := runtimeStore[key]
return v, ok return v, ok
} }
// SetRuntime stores a value in the runtime store (in-memory only). // SetRuntime stores a value in the runtime store (in-memory only).
func SetRuntime(key, value string) { func SetRuntime(key, value string) {
runtimeMu.Lock() runtimeMu.Lock()
defer runtimeMu.Unlock() defer runtimeMu.Unlock()
runtimeStore[key] = value runtimeStore[key] = value
} }
// LoadRuntimeConfig loads the persisted runtime config from disk. If the file does // LoadRuntimeConfig loads the persisted runtime config from disk. If the file does
// not exist, it seeds the store from environment variables. // not exist, it seeds the store from environment variables.
func LoadRuntimeConfig() error { func LoadRuntimeConfig() error {
data, err := os.ReadFile(ConfigPath()) data, err := os.ReadFile(ConfigPath())
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
runtimeMu.Lock() runtimeMu.Lock()
runtimeStore = map[string]string{} runtimeStore = map[string]string{}
runtimeMu.Unlock() runtimeMu.Unlock()
seedRuntimeDefaults() seedRuntimeDefaults()
return nil return nil
} }
return fmt.Errorf("read runtime config: %w", err) return fmt.Errorf("read runtime config: %w", err)
} }
values := map[string]string{} values := map[string]string{}
if err := json.Unmarshal(data, &values); err != nil { if err := json.Unmarshal(data, &values); err != nil {
return fmt.Errorf("parse runtime config: %w", err) return fmt.Errorf("parse runtime config: %w", err)
} }
runtimeMu.Lock() runtimeMu.Lock()
defer runtimeMu.Unlock() defer runtimeMu.Unlock()
runtimeStore = values runtimeStore = values
return nil return nil
} }
// SaveRuntimeConfig persists the runtime store to disk as JSON. // SaveRuntimeConfig persists the runtime store to disk as JSON.
func SaveRuntimeConfig() error { func SaveRuntimeConfig() error {
runtimeMu.RLock() runtimeMu.RLock()
data, err := json.MarshalIndent(runtimeStore, "", " ") data, err := json.MarshalIndent(runtimeStore, "", " ")
runtimeMu.RUnlock() runtimeMu.RUnlock()
if err != nil { if err != nil {
return fmt.Errorf("marshal runtime config: %w", err) return fmt.Errorf("marshal runtime config: %w", err)
} }
if dir := filepath.Dir(ConfigPath()); dir != "" && dir != "." { if dir := filepath.Dir(ConfigPath()); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create runtime config dir: %w", err) return fmt.Errorf("create runtime config dir: %w", err)
} }
} }
if err := os.WriteFile(ConfigPath(), append(data, '\n'), 0o644); err != nil { if err := os.WriteFile(ConfigPath(), append(data, '\n'), 0o644); err != nil {
return fmt.Errorf("write runtime config: %w", err) return fmt.Errorf("write runtime config: %w", err)
} }
return nil return nil
} }
// seedRuntimeDefaults populates the store from environment variables. // seedRuntimeDefaults populates the store from environment variables.
func seedRuntimeDefaults() { func seedRuntimeDefaults() {
if v := strings.TrimSpace(os.Getenv("MATOMO_URL")); v != "" { if v := strings.TrimSpace(os.Getenv("MATOMO_URL")); v != "" {
SetRuntime(keyMatomoURL, v) SetRuntime(keyMatomoURL, v)
} }
if v := strings.TrimSpace(os.Getenv("MATOMO_SITE_ID")); v != "" { if v := strings.TrimSpace(os.Getenv("MATOMO_SITE_ID")); v != "" {
SetRuntime(keyMatomoSiteID, v) SetRuntime(keyMatomoSiteID, v)
} }
if v := strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION")); v != "" { if v := strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION")); v != "" {
SetRuntime(keyHLSSegmentDur, v) SetRuntime(keyHLSSegmentDur, v)
} }
if v := strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE")); v != "" { if v := strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE")); v != "" {
SetRuntime(keyHLSWindowSize, v) SetRuntime(keyHLSWindowSize, v)
} }
} }
// MatomoURL returns the Matomo analytics URL. // MatomoURL returns the Matomo analytics URL.
func MatomoURL() string { func MatomoURL() string {
if v, ok := GetRuntime(keyMatomoURL); ok && strings.TrimSpace(v) != "" { if v, ok := GetRuntime(keyMatomoURL); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v) return strings.TrimSpace(v)
} }
return strings.TrimSpace(os.Getenv("MATOMO_URL")) return strings.TrimSpace(os.Getenv("MATOMO_URL"))
} }
// MatomoSiteID returns the Matomo site ID. // MatomoSiteID returns the Matomo site ID.
func MatomoSiteID() string { func MatomoSiteID() string {
if v, ok := GetRuntime(keyMatomoSiteID); ok && strings.TrimSpace(v) != "" { if v, ok := GetRuntime(keyMatomoSiteID); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v) return strings.TrimSpace(v)
} }
return strings.TrimSpace(os.Getenv("MATOMO_SITE_ID")) return strings.TrimSpace(os.Getenv("MATOMO_SITE_ID"))
} }
// HLSSegmentDuration returns the HLS segment duration in seconds. // HLSSegmentDuration returns the HLS segment duration in seconds.
func HLSSegmentDuration() int { func HLSSegmentDuration() int {
raw := "" raw := ""
if v, ok := GetRuntime(keyHLSSegmentDur); ok { if v, ok := GetRuntime(keyHLSSegmentDur); ok {
raw = strings.TrimSpace(v) raw = strings.TrimSpace(v)
} }
if raw == "" { if raw == "" {
raw = strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION")) raw = strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION"))
} }
n, err := strconv.Atoi(raw) n, err := strconv.Atoi(raw)
if err != nil || n <= 0 { if err != nil || n <= 0 {
return defaultHLSSegmentDuration return defaultHLSSegmentDuration
} }
return n return n
} }
// HLSWindowSize returns the HLS playlist window size in segments. // HLSWindowSize returns the HLS playlist window size in segments.
func HLSWindowSize() int { func HLSWindowSize() int {
raw := "" raw := ""
if v, ok := GetRuntime(keyHLSWindowSize); ok { if v, ok := GetRuntime(keyHLSWindowSize); ok {
raw = strings.TrimSpace(v) raw = strings.TrimSpace(v)
} }
if raw == "" { if raw == "" {
raw = strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE")) raw = strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE"))
} }
n, err := strconv.Atoi(raw) n, err := strconv.Atoi(raw)
if err != nil || n <= 0 { if err != nil || n <= 0 {
return defaultHLSWindowSize return defaultHLSWindowSize
} }
return n return n
} }
// SetMatomoURL validates and stores the Matomo URL. // SetMatomoURL validates and stores the Matomo URL.
func SetMatomoURL(raw string) error { func SetMatomoURL(raw string) error {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
SetRuntime(keyMatomoURL, "") SetRuntime(keyMatomoURL, "")
return nil return nil
} }
u, err := url.Parse(raw) u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("matomo URL must be an absolute http(s) URL") return fmt.Errorf("matomo URL must be an absolute http(s) URL")
} }
SetRuntime(keyMatomoURL, raw) SetRuntime(keyMatomoURL, raw)
return nil return nil
} }
// SetMatomoSiteID validates and stores the Matomo site ID. // SetMatomoSiteID validates and stores the Matomo site ID.
func SetMatomoSiteID(raw string) error { func SetMatomoSiteID(raw string) error {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw != "" { if raw != "" {
if _, err := strconv.Atoi(raw); err != nil { if _, err := strconv.Atoi(raw); err != nil {
return fmt.Errorf("matomo site ID must be numeric") return fmt.Errorf("matomo site ID must be numeric")
} }
} }
SetRuntime(keyMatomoSiteID, raw) SetRuntime(keyMatomoSiteID, raw)
return nil return nil
} }
// SetHLSSegmentDuration validates (2–15s) and stores the HLS segment duration. // SetHLSSegmentDuration validates (2–15s) and stores the HLS segment duration.
func SetHLSSegmentDuration(seconds int) error { func SetHLSSegmentDuration(seconds int) error {
if seconds < 2 || seconds > 15 { if seconds < 2 || seconds > 15 {
return fmt.Errorf("HLS segment duration must be between 2 and 15 seconds") return fmt.Errorf("HLS segment duration must be between 2 and 15 seconds")
} }
SetRuntime(keyHLSSegmentDur, strconv.Itoa(seconds)) SetRuntime(keyHLSSegmentDur, strconv.Itoa(seconds))
return nil return nil
} }
// SetHLSWindowSize validates (3–100) and stores the HLS window size. // SetHLSWindowSize validates (3–100) and stores the HLS window size.
func SetHLSWindowSize(size int) error { func SetHLSWindowSize(size int) error {
if size < 3 || size > 100 { if size < 3 || size > 100 {
return fmt.Errorf("HLS window size must be between 3 and 100 segments") return fmt.Errorf("HLS window size must be between 3 and 100 segments")
} }
SetRuntime(keyHLSWindowSize, strconv.Itoa(size)) SetRuntime(keyHLSWindowSize, strconv.Itoa(size))
return nil return nil
} }
// RuntimeConfigSnapshot returns the resolved runtime configuration values. // RuntimeConfigSnapshot returns the resolved runtime configuration values.
func RuntimeConfigSnapshot() map[string]any { func RuntimeConfigSnapshot() map[string]any {
return map[string]any{ return map[string]any{
"matomoUrl": MatomoURL(), "matomoUrl": MatomoURL(),
"matomoSiteId": MatomoSiteID(), "matomoSiteId": MatomoSiteID(),
"hlsSegmentDuration": HLSSegmentDuration(), "hlsSegmentDuration": HLSSegmentDuration(),
"hlsWindowSize": HLSWindowSize(), "hlsWindowSize": HLSWindowSize(),
} }
} }
+181 -181
View File
@@ -1,181 +1,181 @@
package config package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
) )
func resetRuntime(t *testing.T) { func resetRuntime(t *testing.T) {
t.Helper() t.Helper()
runtimeMu.Lock() runtimeMu.Lock()
runtimeStore = map[string]string{} runtimeStore = map[string]string{}
runtimeMu.Unlock() runtimeMu.Unlock()
os.Unsetenv("CONFIG_PATH") os.Unsetenv("CONFIG_PATH")
os.Unsetenv("MATOMO_URL") os.Unsetenv("MATOMO_URL")
os.Unsetenv("MATOMO_SITE_ID") os.Unsetenv("MATOMO_SITE_ID")
os.Unsetenv("HLS_SEGMENT_DURATION") os.Unsetenv("HLS_SEGMENT_DURATION")
os.Unsetenv("HLS_WINDOW_SIZE") os.Unsetenv("HLS_WINDOW_SIZE")
} }
func TestConfigPath(t *testing.T) { func TestConfigPath(t *testing.T) {
resetRuntime(t) resetRuntime(t)
if got := ConfigPath(); got != "/app/playlist/config.json" { if got := ConfigPath(); got != "/app/playlist/config.json" {
t.Errorf("ConfigPath() = %q, want default /app/playlist/config.json", got) t.Errorf("ConfigPath() = %q, want default /app/playlist/config.json", got)
} }
os.Setenv("CONFIG_PATH", "/tmp/custom.json") os.Setenv("CONFIG_PATH", "/tmp/custom.json")
defer os.Unsetenv("CONFIG_PATH") defer os.Unsetenv("CONFIG_PATH")
if got := ConfigPath(); got != "/tmp/custom.json" { if got := ConfigPath(); got != "/tmp/custom.json" {
t.Errorf("ConfigPath() = %q, want /tmp/custom.json", got) t.Errorf("ConfigPath() = %q, want /tmp/custom.json", got)
} }
} }
func TestGetSetRuntime(t *testing.T) { func TestGetSetRuntime(t *testing.T) {
resetRuntime(t) resetRuntime(t)
if _, ok := GetRuntime("foo"); ok { if _, ok := GetRuntime("foo"); ok {
t.Errorf("expected missing key") t.Errorf("expected missing key")
} }
SetRuntime("foo", "bar") SetRuntime("foo", "bar")
v, ok := GetRuntime("foo") v, ok := GetRuntime("foo")
if !ok || v != "bar" { if !ok || v != "bar" {
t.Errorf("GetRuntime() = %q, %v; want bar, true", v, ok) t.Errorf("GetRuntime() = %q, %v; want bar, true", v, ok)
} }
} }
func TestGettersDefaults(t *testing.T) { func TestGettersDefaults(t *testing.T) {
resetRuntime(t) resetRuntime(t)
if got := MatomoURL(); got != "" { if got := MatomoURL(); got != "" {
t.Errorf("MatomoURL() = %q, want empty", got) t.Errorf("MatomoURL() = %q, want empty", got)
} }
if got := MatomoSiteID(); got != "" { if got := MatomoSiteID(); got != "" {
t.Errorf("MatomoSiteID() = %q, want empty", got) t.Errorf("MatomoSiteID() = %q, want empty", got)
} }
if got := HLSSegmentDuration(); got != 2 { if got := HLSSegmentDuration(); got != 2 {
t.Errorf("HLSSegmentDuration() = %d, want 2", got) t.Errorf("HLSSegmentDuration() = %d, want 2", got)
} }
if got := HLSWindowSize(); got != 12 { if got := HLSWindowSize(); got != 12 {
t.Errorf("HLSWindowSize() = %d, want 12", got) t.Errorf("HLSWindowSize() = %d, want 12", got)
} }
} }
func TestGettersEnvFallback(t *testing.T) { func TestGettersEnvFallback(t *testing.T) {
resetRuntime(t) resetRuntime(t)
os.Setenv("HLS_SEGMENT_DURATION", "4") os.Setenv("HLS_SEGMENT_DURATION", "4")
os.Setenv("HLS_WINDOW_SIZE", "30") os.Setenv("HLS_WINDOW_SIZE", "30")
os.Setenv("MATOMO_URL", "https://analytics.example.com") os.Setenv("MATOMO_URL", "https://analytics.example.com")
os.Setenv("MATOMO_SITE_ID", "7") os.Setenv("MATOMO_SITE_ID", "7")
if got := HLSSegmentDuration(); got != 4 { if got := HLSSegmentDuration(); got != 4 {
t.Errorf("HLSSegmentDuration() = %d, want 4", got) t.Errorf("HLSSegmentDuration() = %d, want 4", got)
} }
if got := HLSWindowSize(); got != 30 { if got := HLSWindowSize(); got != 30 {
t.Errorf("HLSWindowSize() = %d, want 30", got) t.Errorf("HLSWindowSize() = %d, want 30", got)
} }
if got := MatomoURL(); got != "https://analytics.example.com" { if got := MatomoURL(); got != "https://analytics.example.com" {
t.Errorf("MatomoURL() = %q", got) t.Errorf("MatomoURL() = %q", got)
} }
if got := MatomoSiteID(); got != "7" { if got := MatomoSiteID(); got != "7" {
t.Errorf("MatomoSiteID() = %q", got) t.Errorf("MatomoSiteID() = %q", got)
} }
} }
func TestLoadRuntimeConfigSeedsFromEnv(t *testing.T) { func TestLoadRuntimeConfigSeedsFromEnv(t *testing.T) {
resetRuntime(t) resetRuntime(t)
dir := t.TempDir() dir := t.TempDir()
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json")) os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
os.Setenv("HLS_SEGMENT_DURATION", "8") os.Setenv("HLS_SEGMENT_DURATION", "8")
if err := LoadRuntimeConfig(); err != nil { if err := LoadRuntimeConfig(); err != nil {
t.Fatalf("LoadRuntimeConfig error: %v", err) t.Fatalf("LoadRuntimeConfig error: %v", err)
} }
if got := HLSSegmentDuration(); got != 8 { if got := HLSSegmentDuration(); got != 8 {
t.Errorf("HLSSegmentDuration() = %d, want 8 (seeded from env)", got) t.Errorf("HLSSegmentDuration() = %d, want 8 (seeded from env)", got)
} }
} }
func TestSaveAndLoadRuntimeConfig(t *testing.T) { func TestSaveAndLoadRuntimeConfig(t *testing.T) {
resetRuntime(t) resetRuntime(t)
dir := t.TempDir() dir := t.TempDir()
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json")) os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
if err := SetHLSSegmentDuration(5); err != nil { if err := SetHLSSegmentDuration(5); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := SetHLSWindowSize(30); err != nil { if err := SetHLSWindowSize(30); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := SetMatomoURL("https://stats.example.com"); err != nil { if err := SetMatomoURL("https://stats.example.com"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := SetMatomoSiteID("42"); err != nil { if err := SetMatomoSiteID("42"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := SaveRuntimeConfig(); err != nil { if err := SaveRuntimeConfig(); err != nil {
t.Fatalf("SaveRuntimeConfig error: %v", err) t.Fatalf("SaveRuntimeConfig error: %v", err)
} }
// Reset the store and reload from disk. // Reset the store and reload from disk.
runtimeMu.Lock() runtimeMu.Lock()
runtimeStore = map[string]string{} runtimeStore = map[string]string{}
runtimeMu.Unlock() runtimeMu.Unlock()
if err := LoadRuntimeConfig(); err != nil { if err := LoadRuntimeConfig(); err != nil {
t.Fatalf("LoadRuntimeConfig error: %v", err) t.Fatalf("LoadRuntimeConfig error: %v", err)
} }
if got := HLSSegmentDuration(); got != 5 { if got := HLSSegmentDuration(); got != 5 {
t.Errorf("HLSSegmentDuration() = %d, want 5", got) t.Errorf("HLSSegmentDuration() = %d, want 5", got)
} }
if got := HLSWindowSize(); got != 30 { if got := HLSWindowSize(); got != 30 {
t.Errorf("HLSWindowSize() = %d, want 30", got) t.Errorf("HLSWindowSize() = %d, want 30", got)
} }
if got := MatomoURL(); got != "https://stats.example.com" { if got := MatomoURL(); got != "https://stats.example.com" {
t.Errorf("MatomoURL() = %q", got) t.Errorf("MatomoURL() = %q", got)
} }
if got := MatomoSiteID(); got != "42" { if got := MatomoSiteID(); got != "42" {
t.Errorf("MatomoSiteID() = %q", got) t.Errorf("MatomoSiteID() = %q", got)
} }
} }
func TestSettersValidation(t *testing.T) { func TestSettersValidation(t *testing.T) {
resetRuntime(t) resetRuntime(t)
if err := SetHLSSegmentDuration(1); err == nil { if err := SetHLSSegmentDuration(1); err == nil {
t.Error("expected error for duration 1") t.Error("expected error for duration 1")
} }
if err := SetHLSSegmentDuration(20); err == nil { if err := SetHLSSegmentDuration(20); err == nil {
t.Error("expected error for duration 20") t.Error("expected error for duration 20")
} }
if err := SetHLSSegmentDuration(6); err != nil { if err := SetHLSSegmentDuration(6); err != nil {
t.Errorf("unexpected error for duration 6: %v", err) t.Errorf("unexpected error for duration 6: %v", err)
} }
if err := SetHLSWindowSize(2); err == nil { if err := SetHLSWindowSize(2); err == nil {
t.Error("expected error for window 2") t.Error("expected error for window 2")
} }
if err := SetHLSWindowSize(101); err == nil { if err := SetHLSWindowSize(101); err == nil {
t.Error("expected error for window 101") t.Error("expected error for window 101")
} }
if err := SetHLSWindowSize(24); err != nil { if err := SetHLSWindowSize(24); err != nil {
t.Errorf("unexpected error for window 24: %v", err) t.Errorf("unexpected error for window 24: %v", err)
} }
if err := SetMatomoURL("not-a-url"); err == nil { if err := SetMatomoURL("not-a-url"); err == nil {
t.Error("expected error for invalid URL") t.Error("expected error for invalid URL")
} }
if err := SetMatomoURL("ftp://example.com"); err == nil { if err := SetMatomoURL("ftp://example.com"); err == nil {
t.Error("expected error for non-http URL") t.Error("expected error for non-http URL")
} }
if err := SetMatomoURL("https://stats.example.com"); err != nil { if err := SetMatomoURL("https://stats.example.com"); err != nil {
t.Errorf("unexpected error for valid URL: %v", err) t.Errorf("unexpected error for valid URL: %v", err)
} }
if err := SetMatomoSiteID("abc"); err == nil { if err := SetMatomoSiteID("abc"); err == nil {
t.Error("expected error for non-numeric site ID") t.Error("expected error for non-numeric site ID")
} }
if err := SetMatomoSiteID("99"); err != nil { if err := SetMatomoSiteID("99"); err != nil {
t.Errorf("unexpected error for numeric site ID: %v", err) t.Errorf("unexpected error for numeric site ID: %v", err)
} }
} }
+81 -81
View File
@@ -1,82 +1,82 @@
package db package db
import ( import (
"context" "context"
"log" "log"
"strings" "strings"
"time" "time"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
) )
// DBClient wraps gorm.DB for testability. // DBClient wraps gorm.DB for testability.
type DBClient struct { type DBClient struct {
*gorm.DB *gorm.DB
} }
// InitDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006). // InitDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006).
// Returns the client and the driver name. // Returns the client and the driver name.
func InitDB(databaseURL string) (*DBClient, string, error) { func InitDB(databaseURL string) (*DBClient, string, error) {
var ( var (
gormDB *gorm.DB gormDB *gorm.DB
err error err error
driver = "sqlite" driver = "sqlite"
) )
switch { switch {
case databaseURL == "": case databaseURL == "":
gormDB, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{}) gormDB, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
case strings.HasPrefix(databaseURL, "sqlite://"): case strings.HasPrefix(databaseURL, "sqlite://"):
sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://") sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://")
if sqlitePath == "" { if sqlitePath == "" {
sqlitePath = "chat.db" sqlitePath = "chat.db"
} }
gormDB, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{}) gormDB, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"): case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"):
driver = "postgres" driver = "postgres"
gormDB, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) gormDB, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
default: default:
log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL) log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL)
} }
if err != nil { if err != nil {
return nil, driver, err return nil, driver, err
} }
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{}, &User{}) gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{}, &User{})
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver)) log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
return &DBClient{DB: gormDB}, driver, nil return &DBClient{DB: gormDB}, driver, nil
} }
// RedisClient wraps the Redis client for testability. // RedisClient wraps the Redis client for testability.
// Interface defined for mocking in tests. // Interface defined for mocking in tests.
type RedisClient interface { type RedisClient interface {
Ping(ctx context.Context) error Ping(ctx context.Context) error
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
Get(ctx context.Context, key string) (string, error) Get(ctx context.Context, key string) (string, error)
Del(ctx context.Context, keys ...string) error Del(ctx context.Context, keys ...string) error
Incr(ctx context.Context, key string) (int64, error) Incr(ctx context.Context, key string) (int64, error)
Decr(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 Expire(ctx context.Context, key string, expiration time.Duration) error
Exists(ctx context.Context, keys ...string) (int64, error) Exists(ctx context.Context, keys ...string) (int64, error)
Publish(ctx context.Context, channel string, message interface{}) error Publish(ctx context.Context, channel string, message interface{}) error
PSubscribe(ctx context.Context, channels ...string) PubSub PSubscribe(ctx context.Context, channels ...string) PubSub
Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error)
Info(ctx context.Context, sections ...string) (string, error) Info(ctx context.Context, sections ...string) (string, error)
Close() error Close() error
} }
// PubSub wraps Redis pub/sub subscription. // PubSub wraps Redis pub/sub subscription.
type PubSub interface { type PubSub interface {
ReceiveMessage(ctx context.Context) (*PubSubMessage, error) ReceiveMessage(ctx context.Context) (*PubSubMessage, error)
Close() error Close() error
} }
// PubSubMessage represents a received pub/sub message. // PubSubMessage represents a received pub/sub message.
type PubSubMessage struct { type PubSubMessage struct {
Channel string Channel string
Payload string Payload string
} }
+178 -178
View File
@@ -1,179 +1,179 @@
package db package db
import ( import (
"testing" "testing"
"time" "time"
) )
func TestInitDB_SQLite(t *testing.T) { func TestInitDB_SQLite(t *testing.T) {
client, driver, err := InitDB("sqlite://:memory:") client, driver, err := InitDB("sqlite://:memory:")
if err != nil { if err != nil {
t.Fatalf("InitDB() error = %v", err) t.Fatalf("InitDB() error = %v", err)
} }
if driver != "sqlite" { if driver != "sqlite" {
t.Errorf("InitDB() driver = %q, want sqlite", driver) t.Errorf("InitDB() driver = %q, want sqlite", driver)
} }
if client == nil { if client == nil {
t.Fatal("InitDB() client = nil") t.Fatal("InitDB() client = nil")
} }
// Verify tables exist by running AutoMigrate (idempotent) // Verify tables exist by running AutoMigrate (idempotent)
err = client.DB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{}) err = client.DB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
if err != nil { if err != nil {
t.Errorf("AutoMigrate error = %v", err) t.Errorf("AutoMigrate error = %v", err)
} }
// Verify CRUD on Message // Verify CRUD on Message
msg := Message{ msg := Message{
Room: "test", Room: "test",
Username: "user1", Username: "user1",
Text: "hello", Text: "hello",
Timestamp: time.Now(), Timestamp: time.Now(),
Type: "public", Type: "public",
} }
if err := client.DB.Create(&msg).Error; err != nil { if err := client.DB.Create(&msg).Error; err != nil {
t.Fatalf("Create message error = %v", err) t.Fatalf("Create message error = %v", err)
} }
if msg.ID == 0 { if msg.ID == 0 {
t.Error("Create message did not set ID") t.Error("Create message did not set ID")
} }
var count int64 var count int64
client.DB.Model(&Message{}).Count(&count) client.DB.Model(&Message{}).Count(&count)
if count != 1 { if count != 1 {
t.Errorf("Message count = %d, want 1", count) t.Errorf("Message count = %d, want 1", count)
} }
// Verify read // Verify read
var read Message var read Message
if err := client.DB.First(&read, msg.ID).Error; err != nil { if err := client.DB.First(&read, msg.ID).Error; err != nil {
t.Fatalf("First message error = %v", err) t.Fatalf("First message error = %v", err)
} }
if read.Text != "hello" { if read.Text != "hello" {
t.Errorf("read.Text = %q, want hello", read.Text) t.Errorf("read.Text = %q, want hello", read.Text)
} }
// Verify delete // Verify delete
client.DB.Delete(&Message{}, msg.ID) client.DB.Delete(&Message{}, msg.ID)
client.DB.Model(&Message{}).Count(&count) client.DB.Model(&Message{}).Count(&count)
if count != 0 { if count != 0 {
t.Errorf("Message count after delete = %d, want 0", count) t.Errorf("Message count after delete = %d, want 0", count)
} }
} }
func TestInitDB_ModerationLog(t *testing.T) { func TestInitDB_ModerationLog(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:") client, _, err := InitDB("sqlite://:memory:")
if err != nil { if err != nil {
t.Fatalf("InitDB() error = %v", err) t.Fatalf("InitDB() error = %v", err)
} }
log := ModerationLog{ log := ModerationLog{
Action: "mute", Action: "mute",
Target: "user1", Target: "user1",
Moderator: "admin", Moderator: "admin",
Reason: "spam", Reason: "spam",
Timestamp: time.Now(), Timestamp: time.Now(),
} }
if err := client.DB.Create(&log).Error; err != nil { if err := client.DB.Create(&log).Error; err != nil {
t.Fatalf("Create moderation log error = %v", err) t.Fatalf("Create moderation log error = %v", err)
} }
if log.ID == 0 { if log.ID == 0 {
t.Error("Create moderation log did not set ID") t.Error("Create moderation log did not set ID")
} }
var count int64 var count int64
client.DB.Model(&ModerationLog{}).Count(&count) client.DB.Model(&ModerationLog{}).Count(&count)
if count != 1 { if count != 1 {
t.Errorf("ModerationLog count = %d, want 1", count) t.Errorf("ModerationLog count = %d, want 1", count)
} }
} }
func TestInitDB_Media(t *testing.T) { func TestInitDB_Media(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:") client, _, err := InitDB("sqlite://:memory:")
if err != nil { if err != nil {
t.Fatalf("InitDB() error = %v", err) t.Fatalf("InitDB() error = %v", err)
} }
media := Media{ media := Media{
Filename: "test.mp4", Filename: "test.mp4",
OriginalName: "test.mp4", OriginalName: "test.mp4",
Path: "/media/test.mp4", Path: "/media/test.mp4",
SizeBytes: 1024, SizeBytes: 1024,
DurationSeconds: 60, DurationSeconds: 60,
Status: "ready", Status: "ready",
Title: "Test Video", Title: "Test Video",
Tags: "tag1,tag2", Tags: "tag1,tag2",
} }
if err := client.DB.Create(&media).Error; err != nil { if err := client.DB.Create(&media).Error; err != nil {
t.Fatalf("Create media error = %v", err) t.Fatalf("Create media error = %v", err)
} }
if media.ID == 0 { if media.ID == 0 {
t.Error("Create media did not set ID") t.Error("Create media did not set ID")
} }
// Verify update // Verify update
client.DB.Model(&media).Update("status", "needs_normalization") client.DB.Model(&media).Update("status", "needs_normalization")
var updated Media var updated Media
client.DB.First(&updated, media.ID) client.DB.First(&updated, media.ID)
if updated.Status != "needs_normalization" { if updated.Status != "needs_normalization" {
t.Errorf("updated.Status = %q, want needs_normalization", updated.Status) t.Errorf("updated.Status = %q, want needs_normalization", updated.Status)
} }
} }
func TestInitDB_ScheduleOverride(t *testing.T) { func TestInitDB_ScheduleOverride(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:") client, _, err := InitDB("sqlite://:memory:")
if err != nil { if err != nil {
t.Fatalf("InitDB() error = %v", err) t.Fatalf("InitDB() error = %v", err)
} }
now := time.Now() now := time.Now()
ov := ScheduleOverride{ ov := ScheduleOverride{
MediaID: 1, MediaID: 1,
StartTime: now, StartTime: now,
EndTime: now.Add(1 * time.Hour), EndTime: now.Add(1 * time.Hour),
Reason: "special event", Reason: "special event",
} }
if err := client.DB.Create(&ov).Error; err != nil { if err := client.DB.Create(&ov).Error; err != nil {
t.Fatalf("Create schedule override error = %v", err) t.Fatalf("Create schedule override error = %v", err)
} }
if ov.ID == 0 { if ov.ID == 0 {
t.Error("Create schedule override did not set ID") t.Error("Create schedule override did not set ID")
} }
// Conflict detection // Conflict detection
var conflict int64 var conflict int64
client.DB.Model(&ScheduleOverride{}). client.DB.Model(&ScheduleOverride{}).
Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime). Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime).
Count(&conflict) Count(&conflict)
if conflict != 1 { if conflict != 1 {
t.Errorf("conflict count = %d, want 1", conflict) t.Errorf("conflict count = %d, want 1", conflict)
} }
} }
func TestInitDB_EmptyURL(t *testing.T) { func TestInitDB_EmptyURL(t *testing.T) {
// Empty URL should default to SQLite chat.db // Empty URL should default to SQLite chat.db
// This creates a file, clean up after // This creates a file, clean up after
client, driver, err := InitDB("") client, driver, err := InitDB("")
if err != nil { if err != nil {
t.Fatalf("InitDB(\"\") error = %v", err) t.Fatalf("InitDB(\"\") error = %v", err)
} }
if driver != "sqlite" { if driver != "sqlite" {
t.Errorf("InitDB(\"\") driver = %q, want sqlite", driver) t.Errorf("InitDB(\"\") driver = %q, want sqlite", driver)
} }
if client == nil { if client == nil {
t.Fatal("InitDB(\"\") client = nil") t.Fatal("InitDB(\"\") client = nil")
} }
} }
func TestInitDB_SQLitePath(t *testing.T) { func TestInitDB_SQLitePath(t *testing.T) {
client, driver, err := InitDB("sqlite://:memory:?cache=shared") client, driver, err := InitDB("sqlite://:memory:?cache=shared")
if err != nil { if err != nil {
t.Fatalf("InitDB() error = %v", err) t.Fatalf("InitDB() error = %v", err)
} }
if driver != "sqlite" { if driver != "sqlite" {
t.Errorf("InitDB() driver = %q, want sqlite", driver) t.Errorf("InitDB() driver = %q, want sqlite", driver)
} }
if client == nil { if client == nil {
t.Fatal("InitDB() client = nil") t.Fatal("InitDB() client = nil")
} }
} }
+64 -64
View File
@@ -1,65 +1,65 @@
package db package db
import ( import (
"time" "time"
) )
// Message represents a chat message (persisted in SQL DB per ADR-006) // Message represents a chat message (persisted in SQL DB per ADR-006)
type Message struct { type Message struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Room string `json:"room"` Room string `json:"room"`
Username string `json:"username"` Username string `json:"username"`
Text string `json:"text"` Text string `json:"text"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
Type string `json:"type"` // "public", "private", "system", "moderation" Type string `json:"type"` // "public", "private", "system", "moderation"
} }
// ModerationLog for audit trail (immutable per ADR-005) // ModerationLog for audit trail (immutable per ADR-005)
type ModerationLog struct { type ModerationLog struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Action string `json:"action"` // "mute", "ban", "delete", "timeout" Action string `json:"action"` // "mute", "ban", "delete", "timeout"
Target string `json:"target"` Target string `json:"target"`
Moderator string `json:"moderator"` Moderator string `json:"moderator"`
Reason string `json:"reason"` Reason string `json:"reason"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
} }
// Media stores admin media library metadata (filesystem + DB model per ADR-006) // Media stores admin media library metadata (filesystem + DB model per ADR-006)
type Media struct { type Media struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Filename string `gorm:"index" json:"filename"` Filename string `gorm:"index" json:"filename"`
OriginalName string `json:"originalName"` OriginalName string `json:"originalName"`
Path string `json:"path"` Path string `json:"path"`
SizeBytes int64 `json:"sizeBytes"` SizeBytes int64 `json:"sizeBytes"`
DurationSeconds int `json:"durationSeconds"` DurationSeconds int `json:"durationSeconds"`
Status string `gorm:"index" json:"status"` Status string `gorm:"index" json:"status"`
Title string `json:"title"` Title string `json:"title"`
Tags string `json:"tags"` Tags string `json:"tags"`
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"` UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
} }
// User represents a registered chat user account (ADR-006). // User represents a registered chat user account (ADR-006).
type User struct { type User struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Username string `gorm:"uniqueIndex;size:32" json:"username"` Username string `gorm:"uniqueIndex;size:32" json:"username"`
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"` CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"`
} }
// ScheduleOverride represents a scheduled playlist override (ADR-004) // ScheduleOverride represents a scheduled playlist override (ADR-004)
type ScheduleOverride struct { type ScheduleOverride struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
MediaID uint `json:"mediaId"` MediaID uint `json:"mediaId"`
StartTime time.Time `json:"startTime"` StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"` EndTime time.Time `json:"endTime"`
Reason string `json:"reason"` Reason string `json:"reason"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
// Presence tracks online users per room (using Redis per ADR-007) // Presence tracks online users per room (using Redis per ADR-007)
type Presence struct { type Presence struct {
Username string `json:"username"` Username string `json:"username"`
Room string `json:"room"` Room string `json:"room"`
Online bool `json:"online"` Online bool `json:"online"`
} }
+162 -162
View File
@@ -1,163 +1,163 @@
package db package db
import ( import (
"context" "context"
"log" "log"
"strings" "strings"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )
// RedisWrapper wraps go-redis client to implement RedisClient interface. // RedisWrapper wraps go-redis client to implement RedisClient interface.
type RedisWrapper struct { type RedisWrapper struct {
client *redis.Client client *redis.Client
} }
// NewRedisWrapper creates a RedisWrapper from a go-redis client. // NewRedisWrapper creates a RedisWrapper from a go-redis client.
func NewRedisWrapper(client *redis.Client) *RedisWrapper { func NewRedisWrapper(client *redis.Client) *RedisWrapper {
return &RedisWrapper{client: client} return &RedisWrapper{client: client}
} }
// InitRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007). // InitRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007).
// Returns nil if Redis is unavailable (non-fatal). // Returns nil if Redis is unavailable (non-fatal).
func InitRedis(redisAddress string) *RedisWrapper { func InitRedis(redisAddress string) *RedisWrapper {
var options *redis.Options var options *redis.Options
var err error var err error
if strings.Contains(redisAddress, "://") { if strings.Contains(redisAddress, "://") {
options, err = redis.ParseURL(redisAddress) options, err = redis.ParseURL(redisAddress)
if err != nil { if err != nil {
log.Printf("Redis configuration warning: %v (rate limiting and presence will be disabled)", err) log.Printf("Redis configuration warning: %v (rate limiting and presence will be disabled)", err)
return nil return nil
} }
} else { } else {
options = &redis.Options{Addr: redisAddress} options = &redis.Options{Addr: redisAddress}
} }
client := redis.NewClient(options) client := redis.NewClient(options)
if err := client.Ping(context.Background()).Err(); err != nil { if err := client.Ping(context.Background()).Err(); err != nil {
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err) log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
client.Close() client.Close()
// Start auto-reconnect goroutine // Start auto-reconnect goroutine
go redisReconnectLoop(redisAddress, options) go redisReconnectLoop(redisAddress, options)
return nil return nil
} }
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)") log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
return &RedisWrapper{client: client} return &RedisWrapper{client: client}
} }
// redisReconnectLoop attempts to reconnect to Redis with exponential backoff. // redisReconnectLoop attempts to reconnect to Redis with exponential backoff.
func redisReconnectLoop(addr string, baseOptions *redis.Options) { func redisReconnectLoop(addr string, baseOptions *redis.Options) {
backoff := 2 * time.Second backoff := 2 * time.Second
maxBackoff := 60 * time.Second maxBackoff := 60 * time.Second
for { for {
time.Sleep(backoff) time.Sleep(backoff)
testClient := redis.NewClient(baseOptions) testClient := redis.NewClient(baseOptions)
if err := testClient.Ping(context.Background()).Err(); err != nil { if err := testClient.Ping(context.Background()).Err(); err != nil {
log.Printf("Redis reconnect failed: %v (retry in %v)", err, backoff) log.Printf("Redis reconnect failed: %v (retry in %v)", err, backoff)
backoff *= 2 backoff *= 2
if backoff > maxBackoff { if backoff > maxBackoff {
backoff = maxBackoff backoff = maxBackoff
} }
testClient.Close() testClient.Close()
continue continue
} }
// Reconnected — caller must replace their reference. // Reconnected — caller must replace their reference.
// This is a best-effort background reconnect; the wrapper // This is a best-effort background reconnect; the wrapper
// returned by InitRedis remains nil until a new InitRedis call. // returned by InitRedis remains nil until a new InitRedis call.
log.Println("Redis reconnected successfully (background)") log.Println("Redis reconnected successfully (background)")
testClient.Close() testClient.Close()
return return
} }
} }
// Ping checks Redis connectivity. // Ping checks Redis connectivity.
func (r *RedisWrapper) Ping(ctx context.Context) error { func (r *RedisWrapper) Ping(ctx context.Context) error {
if r == nil || r.client == nil { if r == nil || r.client == nil {
return redis.ErrClosed return redis.ErrClosed
} }
return r.client.Ping(ctx).Err() return r.client.Ping(ctx).Err()
} }
// Set stores a key with TTL. // Set stores a key with TTL.
func (r *RedisWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error { func (r *RedisWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return r.client.Set(ctx, key, value, expiration).Err() return r.client.Set(ctx, key, value, expiration).Err()
} }
// Get retrieves a key. // Get retrieves a key.
func (r *RedisWrapper) Get(ctx context.Context, key string) (string, error) { func (r *RedisWrapper) Get(ctx context.Context, key string) (string, error) {
return r.client.Get(ctx, key).Result() return r.client.Get(ctx, key).Result()
} }
// Del removes keys. // Del removes keys.
func (r *RedisWrapper) Del(ctx context.Context, keys ...string) error { func (r *RedisWrapper) Del(ctx context.Context, keys ...string) error {
return r.client.Del(ctx, keys...).Err() return r.client.Del(ctx, keys...).Err()
} }
// Incr increments a key. // Incr increments a key.
func (r *RedisWrapper) Incr(ctx context.Context, key string) (int64, error) { func (r *RedisWrapper) Incr(ctx context.Context, key string) (int64, error) {
return r.client.Incr(ctx, key).Result() return r.client.Incr(ctx, key).Result()
} }
// Decr decrements a key. // Decr decrements a key.
func (r *RedisWrapper) Decr(ctx context.Context, key string) (int64, error) { func (r *RedisWrapper) Decr(ctx context.Context, key string) (int64, error) {
return r.client.Decr(ctx, key).Result() return r.client.Decr(ctx, key).Result()
} }
// Expire sets TTL on a key. // Expire sets TTL on a key.
func (r *RedisWrapper) Expire(ctx context.Context, key string, expiration time.Duration) error { func (r *RedisWrapper) Expire(ctx context.Context, key string, expiration time.Duration) error {
return r.client.Expire(ctx, key, expiration).Err() return r.client.Expire(ctx, key, expiration).Err()
} }
// Exists checks if keys exist. // Exists checks if keys exist.
func (r *RedisWrapper) Exists(ctx context.Context, keys ...string) (int64, error) { func (r *RedisWrapper) Exists(ctx context.Context, keys ...string) (int64, error) {
return r.client.Exists(ctx, keys...).Result() return r.client.Exists(ctx, keys...).Result()
} }
// Publish publishes a message to a channel. // Publish publishes a message to a channel.
func (r *RedisWrapper) Publish(ctx context.Context, channel string, message interface{}) error { func (r *RedisWrapper) Publish(ctx context.Context, channel string, message interface{}) error {
return r.client.Publish(ctx, channel, message).Err() return r.client.Publish(ctx, channel, message).Err()
} }
// PSubscribe subscribes to channels by pattern. // PSubscribe subscribes to channels by pattern.
func (r *RedisWrapper) PSubscribe(ctx context.Context, channels ...string) PubSub { func (r *RedisWrapper) PSubscribe(ctx context.Context, channels ...string) PubSub {
ps := r.client.PSubscribe(ctx, channels...) ps := r.client.PSubscribe(ctx, channels...)
return &redisPubSubWrapper{ps: ps} return &redisPubSubWrapper{ps: ps}
} }
// Scan scans keys matching a pattern. // Scan scans keys matching a pattern.
func (r *RedisWrapper) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) { 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() return r.client.Scan(ctx, cursor, match, count).Result()
} }
// Info returns Redis server info. // Info returns Redis server info.
func (r *RedisWrapper) Info(ctx context.Context, sections ...string) (string, error) { func (r *RedisWrapper) Info(ctx context.Context, sections ...string) (string, error) {
return r.client.Info(ctx, sections...).Result() return r.client.Info(ctx, sections...).Result()
} }
// Close closes the Redis connection. // Close closes the Redis connection.
func (r *RedisWrapper) Close() error { func (r *RedisWrapper) Close() error {
if r == nil || r.client == nil { if r == nil || r.client == nil {
return nil return nil
} }
return r.client.Close() return r.client.Close()
} }
// redisPubSubWrapper wraps go-redis PubSub. // redisPubSubWrapper wraps go-redis PubSub.
type redisPubSubWrapper struct { type redisPubSubWrapper struct {
ps *redis.PubSub ps *redis.PubSub
} }
func (w *redisPubSubWrapper) ReceiveMessage(ctx context.Context) (*PubSubMessage, error) { func (w *redisPubSubWrapper) ReceiveMessage(ctx context.Context) (*PubSubMessage, error) {
msg, err := w.ps.ReceiveMessage(ctx) msg, err := w.ps.ReceiveMessage(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &PubSubMessage{Channel: msg.Channel, Payload: msg.Payload}, nil return &PubSubMessage{Channel: msg.Channel, Payload: msg.Payload}, nil
} }
func (w *redisPubSubWrapper) Close() error { func (w *redisPubSubWrapper) Close() error {
return w.ps.Close() return w.ps.Close()
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+110 -110
View File
@@ -1,111 +1,111 @@
package main package main
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"chat/src/auth" "chat/src/auth"
"chat/src/config" "chat/src/config"
"chat/src/db" "chat/src/db"
"chat/src/handlers" "chat/src/handlers"
"chat/src/metrics" "chat/src/metrics"
"chat/src/ws" "chat/src/ws"
) )
func generateShortID() string { func generateShortID() string {
b := make([]byte, 6) b := make([]byte, 6)
rand.Read(b) rand.Read(b)
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
func main() { func main() {
instanceID := fmt.Sprintf("chat-%s", generateShortID()) instanceID := fmt.Sprintf("chat-%s", generateShortID())
rateLimitMax := config.RateLimitMax() rateLimitMax := config.RateLimitMax()
rateLimitWindow := config.RateLimitWindow() rateLimitWindow := config.RateLimitWindow()
log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow) log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow)
// Load persisted runtime configuration (Matomo + HLS settings) // Load persisted runtime configuration (Matomo + HLS settings)
if err := config.LoadRuntimeConfig(); err != nil { if err := config.LoadRuntimeConfig(); err != nil {
log.Printf("Warning: failed to load runtime config: %v", err) log.Printf("Warning: failed to load runtime config: %v", err)
} }
// Initialize Redis // Initialize Redis
redisWrapper := db.InitRedis(config.RedisAddress()) redisWrapper := db.InitRedis(config.RedisAddress())
// Initialize DB // Initialize DB
dbClient, dbDriver, err := db.InitDB(config.DatabaseURL()) dbClient, dbDriver, err := db.InitDB(config.DatabaseURL())
if err != nil { if err != nil {
log.Fatalf("Failed to initialize database: %v", err) log.Fatalf("Failed to initialize database: %v", err)
} }
// Initialize session store // Initialize session store
sessionStore := auth.NewSessionStore() sessionStore := auth.NewSessionStore()
userSessions := auth.NewUserSessionStore() userSessions := auth.NewUserSessionStore()
// Initialize WebSocket hub // Initialize WebSocket hub
hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername()) hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
// Start Redis pub/sub subscriber // Start Redis pub/sub subscriber
go hub.SubscribeRoomMessages() go hub.SubscribeRoomMessages()
log.Printf("Instance ID: %s", instanceID) log.Printf("Instance ID: %s", instanceID)
// Build dependencies // Build dependencies
deps := &handlers.Dependencies{ deps := &handlers.Dependencies{
DB: dbClient.DB, DB: dbClient.DB,
Redis: redisWrapper, Redis: redisWrapper,
Hub: hub, Hub: hub,
SessionStore: sessionStore, SessionStore: sessionStore,
UserSessions: userSessions, UserSessions: userSessions,
DBDriver: dbDriver, DBDriver: dbDriver,
} }
// Auth middleware // Auth middleware
requireAdmin := auth.RequireAdmin(sessionStore, config.AdminSessionCookieName) requireAdmin := auth.RequireAdmin(sessionStore, config.AdminSessionCookieName)
// Register routes // Register routes
http.HandleFunc("/ws", hub.HandleConnection) http.HandleFunc("/ws", hub.HandleConnection)
http.HandleFunc("/api/auth/register", metrics.WrapRequestTiming(handlers.HandleUserRegister(deps))) http.HandleFunc("/api/auth/register", metrics.WrapRequestTiming(handlers.HandleUserRegister(deps)))
http.HandleFunc("/api/auth/login", metrics.WrapRequestTiming(handlers.HandleUserLogin(deps))) http.HandleFunc("/api/auth/login", metrics.WrapRequestTiming(handlers.HandleUserLogin(deps)))
http.HandleFunc("/api/auth/session", metrics.WrapRequestTiming(handlers.HandleUserSession(deps))) http.HandleFunc("/api/auth/session", metrics.WrapRequestTiming(handlers.HandleUserSession(deps)))
http.HandleFunc("/api/auth/logout", metrics.WrapRequestTiming(handlers.HandleUserLogout(deps))) http.HandleFunc("/api/auth/logout", metrics.WrapRequestTiming(handlers.HandleUserLogout(deps)))
http.HandleFunc("/api/dm/history", metrics.WrapRequestTiming(handlers.HandleUserDMHistory(deps))) http.HandleFunc("/api/dm/history", metrics.WrapRequestTiming(handlers.HandleUserDMHistory(deps)))
http.HandleFunc("/api/dm/search", metrics.WrapRequestTiming(handlers.HandleUserDMSearch(deps))) http.HandleFunc("/api/dm/search", metrics.WrapRequestTiming(handlers.HandleUserDMSearch(deps)))
http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps))) http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps))) http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps)))
http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps))) http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps)))
http.HandleFunc("/api/admin/overview", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminOverview(deps)))) http.HandleFunc("/api/admin/overview", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminOverview(deps))))
http.HandleFunc("/api/admin/playlist", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminPlaylist(deps)))) http.HandleFunc("/api/admin/playlist", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminPlaylist(deps))))
http.HandleFunc("/api/admin/moderation/logs", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationLogs(deps)))) http.HandleFunc("/api/admin/moderation/logs", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationLogs(deps))))
http.HandleFunc("/api/admin/moderation/actions", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationAction(deps)))) http.HandleFunc("/api/admin/moderation/actions", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationAction(deps))))
http.HandleFunc("/api/admin/analytics", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminAnalytics(deps)))) http.HandleFunc("/api/admin/analytics", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminAnalytics(deps))))
http.HandleFunc("/api/admin/system", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSystemSummary(deps)))) http.HandleFunc("/api/admin/system", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSystemSummary(deps))))
http.HandleFunc("/api/admin/config", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminConfig(deps)))) http.HandleFunc("/api/admin/config", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminConfig(deps))))
http.HandleFunc("/api/admin/restart", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminRestart(deps)))) http.HandleFunc("/api/admin/restart", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminRestart(deps))))
http.HandleFunc("/api/admin/users", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminUsers(deps)))) http.HandleFunc("/api/admin/users", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminUsers(deps))))
http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps)))) http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps))))
http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps)))) http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps))))
http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps))) http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps)))
http.HandleFunc("/api/matomo", metrics.WrapRequestTiming(handlers.HandlePublicMatomo())) http.HandleFunc("/api/matomo", metrics.WrapRequestTiming(handlers.HandlePublicMatomo()))
http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps)))) http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps))))
http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps)))) http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps)))) http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
http.HandleFunc("/api/admin/media/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaByID(deps)))) http.HandleFunc("/api/admin/media/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaByID(deps))))
http.HandleFunc("/api/admin/chat-log/bulk", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogBulk(deps)))) http.HandleFunc("/api/admin/chat-log/bulk", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogBulk(deps))))
http.HandleFunc("/api/admin/chat-log/export", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogExport(deps)))) http.HandleFunc("/api/admin/chat-log/export", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogExport(deps))))
http.HandleFunc("/api/admin/chat-log/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogByID(deps)))) http.HandleFunc("/api/admin/chat-log/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogByID(deps))))
http.HandleFunc("/api/admin/chat-log", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLog(deps)))) http.HandleFunc("/api/admin/chat-log", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLog(deps))))
http.HandleFunc("/metrics", metrics.HandleMetrics(dbClient.DB, redisWrapper, hub, sessionStore, dbDriver, instanceID)) http.HandleFunc("/metrics", metrics.HandleMetrics(dbClient.DB, redisWrapper, hub, sessionStore, dbDriver, instanceID))
http.HandleFunc("/health", metrics.WrapRequestTiming(metrics.HandleHealth())) http.HandleFunc("/health", metrics.WrapRequestTiming(metrics.HandleHealth()))
http.HandleFunc("/", metrics.WrapRequestTiming(metrics.HandleNotFound())) http.HandleFunc("/", metrics.WrapRequestTiming(metrics.HandleNotFound()))
port := config.ChatPort() port := config.ChatPort()
log.Printf("Chat service starting on :%s", port) log.Printf("Chat service starting on :%s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil { if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Server failed: %v", err) log.Fatalf("Server failed: %v", err)
} }
} }
+449 -449
View File
@@ -1,450 +1,450 @@
package media package media
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
) )
// FormatDuration converts seconds to HH:MM:SS string. // FormatDuration converts seconds to HH:MM:SS string.
func FormatDuration(seconds int) string { func FormatDuration(seconds int) string {
if seconds < 0 { if seconds < 0 {
seconds = 0 seconds = 0
} }
hours := seconds / 3600 hours := seconds / 3600
minutes := (seconds % 3600) / 60 minutes := (seconds % 3600) / 60
remainingSeconds := seconds % 60 remainingSeconds := seconds % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds) return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds)
} }
// NormalizationResult holds ffprobe validation output for admin uploads. // NormalizationResult holds ffprobe validation output for admin uploads.
type NormalizationResult struct { type NormalizationResult struct {
Valid bool `json:"valid"` Valid bool `json:"valid"`
Errors []string `json:"errors"` Errors []string `json:"errors"`
} }
// ValidateNormalization runs ffprobe against a media file and checks // ValidateNormalization runs ffprobe against a media file and checks
// conformance with the Linear Void normalization profile (ADR-001). // conformance with the Linear Void normalization profile (ADR-001).
func ValidateNormalization(mediaPath string) NormalizationResult { func ValidateNormalization(mediaPath string) NormalizationResult {
result := NormalizationResult{Valid: true, Errors: []string{}} result := NormalizationResult{Valid: true, Errors: []string{}}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
out, err := exec.CommandContext(ctx, out, err := exec.CommandContext(ctx,
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", mediaPath, "ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", mediaPath,
).CombinedOutput() ).CombinedOutput()
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
return NormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}} return NormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}}
} }
if err != nil { if err != nil {
return NormalizationResult{Valid: false, Errors: []string{fmt.Sprintf("ffprobe failed: %v (%s)", err, strings.TrimSpace(string(out)))}} return NormalizationResult{Valid: false, Errors: []string{fmt.Sprintf("ffprobe failed: %v (%s)", err, strings.TrimSpace(string(out)))}}
} }
var probe struct { var probe struct {
Streams []struct { Streams []struct {
CodecName string `json:"codec_name"` CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"` CodecType string `json:"codec_type"`
Width int `json:"width"` Width int `json:"width"`
Height int `json:"height"` Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"` RFrameRate string `json:"r_frame_rate"`
PixFmt string `json:"pix_fmt"` PixFmt string `json:"pix_fmt"`
SampleRate string `json:"sample_rate"` SampleRate string `json:"sample_rate"`
} `json:"streams"` } `json:"streams"`
} }
if err := json.Unmarshal(out, &probe); err != nil { if err := json.Unmarshal(out, &probe); err != nil {
return NormalizationResult{Valid: false, Errors: []string{"ffprobe JSON parse failed: " + err.Error()}} return NormalizationResult{Valid: false, Errors: []string{"ffprobe JSON parse failed: " + err.Error()}}
} }
var hasVideo, hasAudio bool var hasVideo, hasAudio bool
for _, s := range probe.Streams { for _, s := range probe.Streams {
switch s.CodecType { switch s.CodecType {
case "video": case "video":
hasVideo = true hasVideo = true
if s.CodecName != "h264" { if s.CodecName != "h264" {
result.Errors = append(result.Errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName)) result.Errors = append(result.Errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
} }
if s.RFrameRate != "30/1" { if s.RFrameRate != "30/1" {
result.Errors = append(result.Errors, fmt.Sprintf("framerate must be 30 fps constant, got %s", s.RFrameRate)) result.Errors = append(result.Errors, fmt.Sprintf("framerate must be 30 fps constant, got %s", s.RFrameRate))
} }
if s.Width != 1280 && s.Width != 1920 { if s.Width != 1280 && s.Width != 1920 {
result.Errors = append(result.Errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width)) result.Errors = append(result.Errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
} }
if s.Height != 720 && s.Height != 1080 { if s.Height != 720 && s.Height != 1080 {
result.Errors = append(result.Errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height)) result.Errors = append(result.Errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
} }
if s.PixFmt != "yuv420p" { if s.PixFmt != "yuv420p" {
result.Errors = append(result.Errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt)) result.Errors = append(result.Errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
} }
case "audio": case "audio":
hasAudio = true hasAudio = true
if s.CodecName != "aac" { if s.CodecName != "aac" {
result.Errors = append(result.Errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName)) result.Errors = append(result.Errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
} }
if s.SampleRate != "48000" { if s.SampleRate != "48000" {
result.Errors = append(result.Errors, fmt.Sprintf("sample rate must be 48000 Hz, got %s", s.SampleRate)) result.Errors = append(result.Errors, fmt.Sprintf("sample rate must be 48000 Hz, got %s", s.SampleRate))
} }
} }
} }
if !hasVideo { if !hasVideo {
result.Errors = append(result.Errors, "no video stream found") result.Errors = append(result.Errors, "no video stream found")
} }
if !hasAudio { if !hasAudio {
result.Errors = append(result.Errors, "no audio stream found") result.Errors = append(result.Errors, "no audio stream found")
} }
if len(result.Errors) > 0 { if len(result.Errors) > 0 {
result.Valid = false result.Valid = false
} }
return result return result
} }
// ProbeDurationSeconds returns the duration of a media file in seconds. // ProbeDurationSeconds returns the duration of a media file in seconds.
func ProbeDurationSeconds(mediaPath string) (int, error) { func ProbeDurationSeconds(mediaPath string) (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel() defer cancel()
out, err := exec.CommandContext( out, err := exec.CommandContext(
ctx, ctx,
"ffprobe", "ffprobe",
"-v", "error", "-v", "error",
"-show_entries", "format=duration", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", "-of", "default=noprint_wrappers=1:nokey=1",
mediaPath, mediaPath,
).CombinedOutput() ).CombinedOutput()
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
return 0, fmt.Errorf("ffprobe timed out") return 0, fmt.Errorf("ffprobe timed out")
} }
if err != nil { if err != nil {
return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out))) return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out)))
} }
rawDuration := strings.TrimSpace(string(out)) rawDuration := strings.TrimSpace(string(out))
if rawDuration == "" { if rawDuration == "" {
return 0, fmt.Errorf("ffprobe returned empty duration") return 0, fmt.Errorf("ffprobe returned empty duration")
} }
secondsFloat, err := strconv.ParseFloat(rawDuration, 64) secondsFloat, err := strconv.ParseFloat(rawDuration, 64)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err) return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err)
} }
if secondsFloat < 0 { if secondsFloat < 0 {
secondsFloat = 0 secondsFloat = 0
} }
seconds := int(secondsFloat + 0.5) seconds := int(secondsFloat + 0.5)
if secondsFloat > 0 && seconds == 0 { if secondsFloat > 0 && seconds == 0 {
seconds = 1 seconds = 1
} }
return seconds, nil return seconds, nil
} }
// Media status values (ADR-006 media library states). // Media status values (ADR-006 media library states).
const ( const (
StatusReady = "ready" StatusReady = "ready"
StatusNeedsNormalization = "needs_normalization" StatusNeedsNormalization = "needs_normalization"
StatusNormalizing = "normalizing" StatusNormalizing = "normalizing"
StatusNormalizationFailed = "normalization_failed" StatusNormalizationFailed = "normalization_failed"
) )
// runCommand executes an external command and returns its combined output on failure. // runCommand executes an external command and returns its combined output on failure.
// It is a package variable so tests can substitute a stub. // It is a package variable so tests can substitute a stub.
var runCommand = func(ctx context.Context, name string, args ...string) error { var runCommand = func(ctx context.Context, name string, args ...string) error {
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("%s failed: %v (%s)", name, err, strings.TrimSpace(string(out))) return fmt.Errorf("%s failed: %v (%s)", name, err, strings.TrimSpace(string(out)))
} }
return nil return nil
} }
// normalizationArgs builds the ffmpeg arguments for the Linear Void normalization profile. // normalizationArgs builds the ffmpeg arguments for the Linear Void normalization profile.
func normalizationArgs(srcPath, dstPath string) []string { func normalizationArgs(srcPath, dstPath string) []string {
return []string{ return []string{
"-y", "-y",
"-i", srcPath, "-i", srcPath,
"-map", "0:v:0", "-map", "0:a:0?", "-map", "0:v:0", "-map", "0:a:0?",
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p", "-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p",
"-c:v", "libx264", "-preset", "medium", "-crf", "21", "-c:v", "libx264", "-preset", "medium", "-crf", "21",
"-pix_fmt", "yuv420p", "-pix_fmt", "yuv420p",
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0", "-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
"-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2", "-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2",
"-avoid_negative_ts", "make_zero", "-max_muxing_queue_size", "1024", "-avoid_negative_ts", "make_zero", "-max_muxing_queue_size", "1024",
"-movflags", "+faststart", "-movflags", "+faststart",
dstPath, dstPath,
} }
} }
// normalizeVideo transcodes srcPath to the Linear Void normalization profile, // normalizeVideo transcodes srcPath to the Linear Void normalization profile,
// re-verifies the output with ffprobe, and returns its duration (seconds) and size (bytes). // re-verifies the output with ffprobe, and returns its duration (seconds) and size (bytes).
func normalizeVideo(srcPath, dstPath string) (int, int64, error) { func normalizeVideo(srcPath, dstPath string) (int, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel() defer cancel()
if err := runCommand(ctx, "ffmpeg", normalizationArgs(srcPath, dstPath)...); err != nil { if err := runCommand(ctx, "ffmpeg", normalizationArgs(srcPath, dstPath)...); err != nil {
return 0, 0, err return 0, 0, err
} }
if res := ValidateNormalization(dstPath); !res.Valid { if res := ValidateNormalization(dstPath); !res.Valid {
return 0, 0, fmt.Errorf("normalized output failed validation: %s", strings.Join(res.Errors, "; ")) return 0, 0, fmt.Errorf("normalized output failed validation: %s", strings.Join(res.Errors, "; "))
} }
duration, err := ProbeDurationSeconds(dstPath) duration, err := ProbeDurationSeconds(dstPath)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("probe duration failed: %w", err) return 0, 0, fmt.Errorf("probe duration failed: %w", err)
} }
info, err := os.Stat(dstPath) info, err := os.Stat(dstPath)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("stat output failed: %w", err) return 0, 0, fmt.Errorf("stat output failed: %w", err)
} }
return duration, info.Size(), nil return duration, info.Size(), nil
} }
// NormalizeVideo is the injectable normalization entry point. // NormalizeVideo is the injectable normalization entry point.
var NormalizeVideo = normalizeVideo var NormalizeVideo = normalizeVideo
// DetectMime reads the first 512 bytes of a file to detect its MIME type. // DetectMime reads the first 512 bytes of a file to detect its MIME type.
func DetectMime(filePath string) (string, error) { func DetectMime(filePath string) (string, error) {
f, err := os.Open(filePath) f, err := os.Open(filePath)
if err != nil { if err != nil {
return "", err return "", err
} }
defer f.Close() defer f.Close()
head := make([]byte, 512) head := make([]byte, 512)
n, err := f.Read(head) n, err := f.Read(head)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
return "", err return "", err
} }
return strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0], nil return strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0], nil
} }
// IsAllowedExtension checks if the file extension is a supported video format. // IsAllowedExtension checks if the file extension is a supported video format.
func IsAllowedExtension(fileName string) bool { func IsAllowedExtension(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName)) ext := strings.ToLower(filepath.Ext(fileName))
switch ext { switch ext {
case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts": case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts":
return true return true
default: default:
return false return false
} }
} }
// IsAllowedMime checks if the MIME type is a supported video type. // IsAllowedMime checks if the MIME type is a supported video type.
func IsAllowedMime(mime string) bool { func IsAllowedMime(mime string) bool {
mime = strings.TrimSpace(strings.ToLower(mime)) mime = strings.TrimSpace(strings.ToLower(mime))
if mime == "" { if mime == "" {
return true return true
} }
if strings.HasPrefix(mime, "video/") { if strings.HasPrefix(mime, "video/") {
return true return true
} }
return mime == "application/octet-stream" return mime == "application/octet-stream"
} }
// UniqueTargetPath returns a unique file path in mediaDir, appending -N if needed. // UniqueTargetPath returns a unique file path in mediaDir, appending -N if needed.
func UniqueTargetPath(mediaDir, fileName string) string { func UniqueTargetPath(mediaDir, fileName string) string {
baseName := filepath.Base(fileName) baseName := filepath.Base(fileName)
ext := filepath.Ext(baseName) ext := filepath.Ext(baseName)
nameOnly := strings.TrimSuffix(baseName, ext) nameOnly := strings.TrimSuffix(baseName, ext)
candidate := filepath.Join(mediaDir, baseName) candidate := filepath.Join(mediaDir, baseName)
for i := 1; ; i++ { for i := 1; ; i++ {
if _, err := os.Stat(candidate); os.IsNotExist(err) { if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate return candidate
} }
candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext)) candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext))
} }
} }
// FileEntry represents a media file for API responses. // FileEntry represents a media file for API responses.
type FileEntry struct { type FileEntry struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
OriginalName string `json:"originalName"` OriginalName string `json:"originalName"`
Path string `json:"path"` Path string `json:"path"`
Size int64 `json:"size"` Size int64 `json:"size"`
DurationSeconds int `json:"durationSeconds"` DurationSeconds int `json:"durationSeconds"`
Runtime string `json:"runtime"` Runtime string `json:"runtime"`
Status string `json:"status"` Status string `json:"status"`
Title string `json:"title"` Title string `json:"title"`
Tags string `json:"tags"` Tags string `json:"tags"`
UploadedAt string `json:"uploadedAt"` UploadedAt string `json:"uploadedAt"`
ModifiedAt string `json:"modifiedAt"` ModifiedAt string `json:"modifiedAt"`
} }
// MediaRecord is the interface for media DB records. // MediaRecord is the interface for media DB records.
type MediaRecord interface { type MediaRecord interface {
GetID() uint GetID() uint
GetFilename() string GetFilename() string
GetOriginalName() string GetOriginalName() string
GetPath() string GetPath() string
GetSizeBytes() int64 GetSizeBytes() int64
GetDurationSeconds() int GetDurationSeconds() int
GetStatus() string GetStatus() string
GetTitle() string GetTitle() string
GetTags() string GetTags() string
GetUploadedAt() time.Time GetUploadedAt() time.Time
GetUpdatedAt() time.Time GetUpdatedAt() time.Time
} }
// ListFiles returns all media files sorted by upload date descending. // ListFiles returns all media files sorted by upload date descending.
func ListFiles(db *gorm.DB) ([]FileEntry, error) { func ListFiles(db *gorm.DB) ([]FileEntry, error) {
type record struct { type record struct {
ID uint ID uint
Filename string Filename string
OriginalName string OriginalName string
Path string Path string
SizeBytes int64 SizeBytes int64
DurationSeconds int DurationSeconds int
Status string Status string
Title string Title string
Tags string Tags string
UploadedAt time.Time UploadedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
var records []record var records []record
if err := db.Table("media").Order("uploaded_at desc").Find(&records).Error; err != nil { if err := db.Table("media").Order("uploaded_at desc").Find(&records).Error; err != nil {
return nil, err return nil, err
} }
files := make([]FileEntry, 0, len(records)) files := make([]FileEntry, 0, len(records))
for _, r := range records { for _, r := range records {
files = append(files, FileEntry{ files = append(files, FileEntry{
ID: r.ID, ID: r.ID,
Name: r.Filename, Name: r.Filename,
OriginalName: r.OriginalName, OriginalName: r.OriginalName,
Path: r.Path, Path: r.Path,
Size: r.SizeBytes, Size: r.SizeBytes,
DurationSeconds: r.DurationSeconds, DurationSeconds: r.DurationSeconds,
Runtime: FormatDuration(r.DurationSeconds), Runtime: FormatDuration(r.DurationSeconds),
Status: r.Status, Status: r.Status,
Title: r.Title, Title: r.Title,
Tags: r.Tags, Tags: r.Tags,
UploadedAt: r.UploadedAt.UTC().Format(time.RFC3339), UploadedAt: r.UploadedAt.UTC().Format(time.RFC3339),
ModifiedAt: r.UpdatedAt.UTC().Format(time.RFC3339), ModifiedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
}) })
} }
sort.Slice(files, func(i, j int) bool { sort.Slice(files, func(i, j int) bool {
if files[i].UploadedAt == files[j].UploadedAt { if files[i].UploadedAt == files[j].UploadedAt {
return files[i].Name < files[j].Name return files[i].Name < files[j].Name
} }
return files[i].UploadedAt > files[j].UploadedAt return files[i].UploadedAt > files[j].UploadedAt
}) })
return files, nil return files, nil
} }
// ParsePlaylistEntries parses playlist content into schedule entries. // ParsePlaylistEntries parses playlist content into schedule entries.
type ScheduleEntry struct { type ScheduleEntry struct {
Index int `json:"index"` Index int `json:"index"`
Path string `json:"path"` Path string `json:"path"`
Label string `json:"label"` Label string `json:"label"`
DurationSeconds int `json:"durationSeconds"` DurationSeconds int `json:"durationSeconds"`
Runtime string `json:"runtime"` Runtime string `json:"runtime"`
Status string `json:"status"` Status string `json:"status"`
Title string `json:"title"` Title string `json:"title"`
} }
// ParsePlaylist parses playlist text content into entries. // ParsePlaylist parses playlist text content into entries.
func ParsePlaylist(content string) []ScheduleEntry { func ParsePlaylist(content string) []ScheduleEntry {
entries := []ScheduleEntry{} entries := []ScheduleEntry{}
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
for _, rawLine := range lines { for _, rawLine := range lines {
line := strings.TrimSpace(rawLine) line := strings.TrimSpace(rawLine)
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
continue continue
} }
if strings.HasPrefix(line, "file ") { if strings.HasPrefix(line, "file ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "file ")) line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
line = strings.Trim(line, "'\"") line = strings.Trim(line, "'\"")
} }
entries = append(entries, ScheduleEntry{ entries = append(entries, ScheduleEntry{
Index: len(entries) + 1, Index: len(entries) + 1,
Path: line, Path: line,
Label: filepath.Base(line), Label: filepath.Base(line),
}) })
} }
return entries return entries
} }
// UploadSessionDir returns the directory for an upload session's chunks. // UploadSessionDir returns the directory for an upload session's chunks.
func UploadSessionDir(mediaDir, uploadID string) string { func UploadSessionDir(mediaDir, uploadID string) string {
return filepath.Join(mediaDir, ".uploads", uploadID) return filepath.Join(mediaDir, ".uploads", uploadID)
} }
// UploadSessionMetaPath returns the path to the session metadata file. // UploadSessionMetaPath returns the path to the session metadata file.
func UploadSessionMetaPath(mediaDir, uploadID string) string { func UploadSessionMetaPath(mediaDir, uploadID string) string {
return filepath.Join(UploadSessionDir(mediaDir, uploadID), "session.json") return filepath.Join(UploadSessionDir(mediaDir, uploadID), "session.json")
} }
// UploadChunkPath returns the path for a specific chunk file. // UploadChunkPath returns the path for a specific chunk file.
func UploadChunkPath(mediaDir, uploadID string, chunkIndex int) string { func UploadChunkPath(mediaDir, uploadID string, chunkIndex int) string {
return filepath.Join(UploadSessionDir(mediaDir, uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex)) return filepath.Join(UploadSessionDir(mediaDir, uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex))
} }
// CountUploadedChunks counts how many chunks exist for a session. // CountUploadedChunks counts how many chunks exist for a session.
func CountUploadedChunks(mediaDir, uploadID string, totalChunks int) int { func CountUploadedChunks(mediaDir, uploadID string, totalChunks int) int {
uploaded := 0 uploaded := 0
for i := 0; i < totalChunks; i++ { for i := 0; i < totalChunks; i++ {
if _, err := os.Stat(UploadChunkPath(mediaDir, uploadID, i)); err == nil { if _, err := os.Stat(UploadChunkPath(mediaDir, uploadID, i)); err == nil {
uploaded++ uploaded++
} }
} }
return uploaded return uploaded
} }
// ValidateUploadID checks that an upload ID is a valid hex string of appropriate length. // ValidateUploadID checks that an upload ID is a valid hex string of appropriate length.
func ValidateUploadID(uploadID string) bool { func ValidateUploadID(uploadID string) bool {
if len(uploadID) < 16 || len(uploadID) > 128 { if len(uploadID) < 16 || len(uploadID) > 128 {
return false return false
} }
for _, r := range uploadID { for _, r := range uploadID {
if (r < 'a' || r > 'f') && (r < '0' || r > '9') { if (r < 'a' || r > 'f') && (r < '0' || r > '9') {
return false return false
} }
} }
return true return true
} }
// GetMediaIDFromPath extracts a media ID from a URL path like /api/admin/media/123. // GetMediaIDFromPath extracts a media ID from a URL path like /api/admin/media/123.
func GetMediaIDFromPath(path, prefix string) (uint, error) { func GetMediaIDFromPath(path, prefix string) (uint, error) {
if !strings.HasPrefix(path, prefix) { if !strings.HasPrefix(path, prefix) {
return 0, fmt.Errorf("invalid media path") return 0, fmt.Errorf("invalid media path")
} }
idPart := strings.TrimPrefix(path, prefix) idPart := strings.TrimPrefix(path, prefix)
idPart = strings.TrimSpace(idPart) idPart = strings.TrimSpace(idPart)
if idPart == "" || strings.Contains(idPart, "/") { if idPart == "" || strings.Contains(idPart, "/") {
return 0, fmt.Errorf("invalid media id") return 0, fmt.Errorf("invalid media id")
} }
id, err := strconv.ParseUint(idPart, 10, 64) id, err := strconv.ParseUint(idPart, 10, 64)
if err != nil || id == 0 { if err != nil || id == 0 {
return 0, fmt.Errorf("invalid media id") return 0, fmt.Errorf("invalid media id")
} }
return uint(id), nil return uint(id), nil
} }
+245 -245
View File
@@ -1,246 +1,246 @@
package media package media
import ( import (
"context" "context"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
) )
func TestFormatDuration(t *testing.T) { func TestFormatDuration(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
seconds int seconds int
expected string expected string
}{ }{
{"zero", 0, "00:00:00"}, {"zero", 0, "00:00:00"},
{"one second", 1, "00:00:01"}, {"one second", 1, "00:00:01"},
{"one minute", 60, "00:01:00"}, {"one minute", 60, "00:01:00"},
{"one hour", 3600, "01:00:00"}, {"one hour", 3600, "01:00:00"},
{"mixed", 3661, "01:01:01"}, {"mixed", 3661, "01:01:01"},
{"large", 86399, "23:59:59"}, {"large", 86399, "23:59:59"},
{"negative", -5, "00:00:00"}, {"negative", -5, "00:00:00"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := FormatDuration(tt.seconds); got != tt.expected { if got := FormatDuration(tt.seconds); got != tt.expected {
t.Errorf("FormatDuration(%d) = %q, want %q", tt.seconds, got, tt.expected) t.Errorf("FormatDuration(%d) = %q, want %q", tt.seconds, got, tt.expected)
} }
}) })
} }
} }
func TestIsAllowedExtension(t *testing.T) { func TestIsAllowedExtension(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
expected bool expected bool
}{ }{
{"video.mp4", true}, {"video.mp4", true},
{"video.mov", true}, {"video.mov", true},
{"video.mkv", true}, {"video.mkv", true},
{"video.webm", true}, {"video.webm", true},
{"video.m4v", true}, {"video.m4v", true},
{"video.ts", true}, {"video.ts", true},
{"video.MP4", true}, {"video.MP4", true},
{"video.avi", false}, {"video.avi", false},
{"video.flv", false}, {"video.flv", false},
{"video.wmv", false}, {"video.wmv", false},
{"video", false}, {"video", false},
{"", false}, {"", false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := IsAllowedExtension(tt.name); got != tt.expected { if got := IsAllowedExtension(tt.name); got != tt.expected {
t.Errorf("IsAllowedExtension(%q) = %v, want %v", tt.name, got, tt.expected) t.Errorf("IsAllowedExtension(%q) = %v, want %v", tt.name, got, tt.expected)
} }
}) })
} }
} }
func TestIsAllowedMime(t *testing.T) { func TestIsAllowedMime(t *testing.T) {
tests := []struct { tests := []struct {
mime string mime string
expected bool expected bool
}{ }{
{"video/mp4", true}, {"video/mp4", true},
{"video/quicktime", true}, {"video/quicktime", true},
{"video/x-matroska", true}, {"video/x-matroska", true},
{"video/webm", true}, {"video/webm", true},
{"application/octet-stream", true}, {"application/octet-stream", true},
{"", true}, {"", true},
{"image/jpeg", false}, {"image/jpeg", false},
{"audio/mpeg", false}, {"audio/mpeg", false},
{"text/plain", false}, {"text/plain", false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.mime, func(t *testing.T) { t.Run(tt.mime, func(t *testing.T) {
if got := IsAllowedMime(tt.mime); got != tt.expected { if got := IsAllowedMime(tt.mime); got != tt.expected {
t.Errorf("IsAllowedMime(%q) = %v, want %v", tt.mime, got, tt.expected) t.Errorf("IsAllowedMime(%q) = %v, want %v", tt.mime, got, tt.expected)
} }
}) })
} }
} }
func TestValidateUploadID(t *testing.T) { func TestValidateUploadID(t *testing.T) {
tests := []struct { tests := []struct {
id string id string
expected bool expected bool
}{ }{
{"abcdef0123456789", true}, // 16 hex chars {"abcdef0123456789", true}, // 16 hex chars
{"abcdef0123456789abcdef0123456789", true}, // 32 hex chars {"abcdef0123456789abcdef0123456789", true}, // 32 hex chars
{"abc", false}, // too short {"abc", false}, // too short
{"abcdef012345678", false}, // 15 chars (too short) {"abcdef012345678", false}, // 15 chars (too short)
{"abcdef0123456789g", false}, // non-hex char {"abcdef0123456789g", false}, // non-hex char
{"ABCDEF0123456789", false}, // uppercase (not allowed) {"ABCDEF0123456789", false}, // uppercase (not allowed)
{"", false}, {"", false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.id, func(t *testing.T) { t.Run(tt.id, func(t *testing.T) {
if got := ValidateUploadID(tt.id); got != tt.expected { if got := ValidateUploadID(tt.id); got != tt.expected {
t.Errorf("ValidateUploadID(%q) = %v, want %v", tt.id, got, tt.expected) t.Errorf("ValidateUploadID(%q) = %v, want %v", tt.id, got, tt.expected)
} }
}) })
} }
} }
func TestParsePlaylist(t *testing.T) { func TestParsePlaylist(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
content string content string
expected int expected int
first string first string
}{ }{
{ {
name: "simple", name: "simple",
content: "/videos/video1.mp4\n/videos/video2.mp4\n", content: "/videos/video1.mp4\n/videos/video2.mp4\n",
expected: 2, expected: 2,
first: "/videos/video1.mp4", first: "/videos/video1.mp4",
}, },
{ {
name: "with file prefix", name: "with file prefix",
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n", content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n",
expected: 2, expected: 2,
first: "/videos/video1.mp4", first: "/videos/video1.mp4",
}, },
{ {
name: "with comments and blanks", name: "with comments and blanks",
content: "# comment\n\n/videos/video1.mp4\n\n# another comment\n/videos/video2.mp4\n", content: "# comment\n\n/videos/video1.mp4\n\n# another comment\n/videos/video2.mp4\n",
expected: 2, expected: 2,
first: "/videos/video1.mp4", first: "/videos/video1.mp4",
}, },
{ {
name: "empty", name: "empty",
content: "", content: "",
expected: 0, expected: 0,
}, },
{ {
name: "only comments", name: "only comments",
content: "# comment\n# another\n", content: "# comment\n# another\n",
expected: 0, expected: 0,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
entries := ParsePlaylist(tt.content) entries := ParsePlaylist(tt.content)
if len(entries) != tt.expected { if len(entries) != tt.expected {
t.Errorf("ParsePlaylist() len = %d, want %d", len(entries), tt.expected) t.Errorf("ParsePlaylist() len = %d, want %d", len(entries), tt.expected)
} }
if tt.expected > 0 && entries[0].Path != tt.first { if tt.expected > 0 && entries[0].Path != tt.first {
t.Errorf("ParsePlaylist()[0].Path = %q, want %q", entries[0].Path, tt.first) t.Errorf("ParsePlaylist()[0].Path = %q, want %q", entries[0].Path, tt.first)
} }
// Check indices // Check indices
for i, e := range entries { for i, e := range entries {
if e.Index != i+1 { if e.Index != i+1 {
t.Errorf("ParsePlaylist()[%d].Index = %d, want %d", i, e.Index, i+1) t.Errorf("ParsePlaylist()[%d].Index = %d, want %d", i, e.Index, i+1)
} }
} }
}) })
} }
} }
func TestGetMediaIDFromPath(t *testing.T) { func TestGetMediaIDFromPath(t *testing.T) {
tests := []struct { tests := []struct {
path string path string
prefix string prefix string
expected uint expected uint
expectErr bool expectErr bool
}{ }{
{"/api/admin/media/123", "/api/admin/media/", 123, false}, {"/api/admin/media/123", "/api/admin/media/", 123, false},
{"/api/admin/media/1", "/api/admin/media/", 1, false}, {"/api/admin/media/1", "/api/admin/media/", 1, false},
{"/api/admin/media/", "/api/admin/media/", 0, true}, {"/api/admin/media/", "/api/admin/media/", 0, true},
{"/api/admin/media/abc", "/api/admin/media/", 0, true}, {"/api/admin/media/abc", "/api/admin/media/", 0, true},
{"/api/admin/media/0", "/api/admin/media/", 0, true}, {"/api/admin/media/0", "/api/admin/media/", 0, true},
{"/other/path", "/api/admin/media/", 0, true}, {"/other/path", "/api/admin/media/", 0, true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) { t.Run(tt.path, func(t *testing.T) {
id, err := GetMediaIDFromPath(tt.path, tt.prefix) id, err := GetMediaIDFromPath(tt.path, tt.prefix)
if tt.expectErr && err == nil { if tt.expectErr && err == nil {
t.Errorf("GetMediaIDFromPath(%q) expected error, got nil", tt.path) t.Errorf("GetMediaIDFromPath(%q) expected error, got nil", tt.path)
} }
if !tt.expectErr && err != nil { if !tt.expectErr && err != nil {
t.Errorf("GetMediaIDFromPath(%q) error = %v", tt.path, err) t.Errorf("GetMediaIDFromPath(%q) error = %v", tt.path, err)
} }
if id != tt.expected { if id != tt.expected {
t.Errorf("GetMediaIDFromPath(%q) = %d, want %d", tt.path, id, tt.expected) t.Errorf("GetMediaIDFromPath(%q) = %d, want %d", tt.path, id, tt.expected)
} }
}) })
} }
} }
func TestUniqueTargetPath(t *testing.T) { func TestUniqueTargetPath(t *testing.T) {
// Test that the function returns a path with the mediaDir prefix // Test that the function returns a path with the mediaDir prefix
// and the correct filename. We can't easily test collision behavior // and the correct filename. We can't easily test collision behavior
// without filesystem setup, but we can verify the basic path. // without filesystem setup, but we can verify the basic path.
expected := filepath.Join("/tmp/media", "video.mp4") expected := filepath.Join("/tmp/media", "video.mp4")
path := UniqueTargetPath("/tmp/media", "video.mp4") path := UniqueTargetPath("/tmp/media", "video.mp4")
if path != expected { if path != expected {
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected) t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
} }
} }
func TestNormalizationArgs(t *testing.T) { func TestNormalizationArgs(t *testing.T) {
args := normalizationArgs("/in/movie.mov", "/out/movie.normalized.mp4") args := normalizationArgs("/in/movie.mov", "/out/movie.normalized.mp4")
joined := strings.Join(args, " ") joined := strings.Join(args, " ")
for _, want := range []string{ for _, want := range []string{
"-map 0:v:0", "-map 0:v:0",
"-map 0:a:0?", "-map 0:a:0?",
"-c:v libx264", "-c:v libx264",
"-r 30", "-r 30",
"-g 60", "-g 60",
"-keyint_min 60", "-keyint_min 60",
"-pix_fmt yuv420p", "-pix_fmt yuv420p",
"-c:a aac", "-c:a aac",
"-ar 48000", "-ar 48000",
"-ac 2", "-ac 2",
"-avoid_negative_ts make_zero", "-avoid_negative_ts make_zero",
"/out/movie.normalized.mp4", "/out/movie.normalized.mp4",
} { } {
if !strings.Contains(joined, want) { if !strings.Contains(joined, want) {
t.Errorf("normalization args missing %q: %v", want, args) t.Errorf("normalization args missing %q: %v", want, args)
} }
} }
} }
func TestNormalizeVideo_FFmpegError(t *testing.T) { func TestNormalizeVideo_FFmpegError(t *testing.T) {
origRun := runCommand origRun := runCommand
t.Cleanup(func() { runCommand = origRun }) t.Cleanup(func() { runCommand = origRun })
runCommand = func(ctx context.Context, name string, args ...string) error { runCommand = func(ctx context.Context, name string, args ...string) error {
if name != "ffmpeg" { if name != "ffmpeg" {
t.Errorf("runCommand name = %q, want ffmpeg", name) t.Errorf("runCommand name = %q, want ffmpeg", name)
} }
return fmt.Errorf("simulated ffmpeg failure") return fmt.Errorf("simulated ffmpeg failure")
} }
if _, _, err := normalizeVideo("/in/a.mov", "/out/a.normalized.mp4"); err == nil { if _, _, err := normalizeVideo("/in/a.mov", "/out/a.normalized.mp4"); err == nil {
t.Fatal("expected error when ffmpeg fails") t.Fatal("expected error when ffmpeg fails")
} }
} }
+277 -277
View File
@@ -1,278 +1,278 @@
package metrics package metrics
import ( import (
"context" "context"
"fmt" "fmt"
"math" "math"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
"chat/src/auth" "chat/src/auth"
"chat/src/db" "chat/src/db"
"chat/src/ws" "chat/src/ws"
) )
var requestDurationBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5} var requestDurationBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5}
var chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets) var chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
type requestDurationHistogram struct { type requestDurationHistogram struct {
mu sync.Mutex mu sync.Mutex
buckets []float64 buckets []float64
counts []uint64 counts []uint64
sum float64 sum float64
total uint64 total uint64
} }
func newRequestDurationHistogram(buckets []float64) *requestDurationHistogram { func newRequestDurationHistogram(buckets []float64) *requestDurationHistogram {
return &requestDurationHistogram{ return &requestDurationHistogram{
buckets: append([]float64(nil), buckets...), buckets: append([]float64(nil), buckets...),
counts: make([]uint64, len(buckets)+1), counts: make([]uint64, len(buckets)+1),
} }
} }
func (h *requestDurationHistogram) observe(durationSeconds float64) { func (h *requestDurationHistogram) observe(durationSeconds float64) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
h.total++ h.total++
h.sum += durationSeconds h.sum += durationSeconds
for index, bucket := range h.buckets { for index, bucket := range h.buckets {
if durationSeconds <= bucket { if durationSeconds <= bucket {
h.counts[index]++ h.counts[index]++
return return
} }
} }
h.counts[len(h.counts)-1]++ h.counts[len(h.counts)-1]++
} }
func (h *requestDurationHistogram) snapshot() (counts []uint64, sum float64, total uint64) { func (h *requestDurationHistogram) snapshot() (counts []uint64, sum float64, total uint64) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
counts = append([]uint64(nil), h.counts...) counts = append([]uint64(nil), h.counts...)
return counts, h.sum, h.total return counts, h.sum, h.total
} }
// ObserveRequestDuration records a chat HTTP request duration in seconds. // ObserveRequestDuration records a chat HTTP request duration in seconds.
func ObserveRequestDuration(duration time.Duration) { func ObserveRequestDuration(duration time.Duration) {
chatRequestDurationHistogram.observe(duration.Seconds()) chatRequestDurationHistogram.observe(duration.Seconds())
} }
// WrapRequestTiming records request duration for the provided handler. // WrapRequestTiming records request duration for the provided handler.
func WrapRequestTiming(next http.HandlerFunc) http.HandlerFunc { func WrapRequestTiming(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now() startedAt := time.Now()
defer func() { ObserveRequestDuration(time.Since(startedAt)) }() defer func() { ObserveRequestDuration(time.Since(startedAt)) }()
next(w, r) next(w, r)
} }
} }
// HandleMetrics returns Prometheus-format metrics for the chat service. // 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 { 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) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
} }
var storedMessages int64 var storedMessages int64
if gormDB != nil { if gormDB != nil {
_ = gormDB.Model(&db.Message{}).Count(&storedMessages).Error _ = gormDB.Model(&db.Message{}).Count(&storedMessages).Error
} }
var moderationActions int64 var moderationActions int64
if gormDB != nil { if gormDB != nil {
_ = gormDB.Model(&db.ModerationLog{}).Count(&moderationActions).Error _ = gormDB.Model(&db.ModerationLog{}).Count(&moderationActions).Error
} }
redisEnabled := 0 redisEnabled := 0
redisUp := 0 redisUp := 0
redisClients := 0 redisClients := 0
redisMemoryUsedBytes := int64(0) redisMemoryUsedBytes := int64(0)
redisMemoryMaxBytes := int64(0) redisMemoryMaxBytes := int64(0)
redisMemoryUsageRatio := 0.0 redisMemoryUsageRatio := 0.0
if redis != nil { if redis != nil {
redisEnabled = 1 redisEnabled = 1
if err := redis.Ping(context.Background()); err == nil { if err := redis.Ping(context.Background()); err == nil {
redisUp = 1 redisUp = 1
if info, err := redis.Info(context.Background(), "clients"); err == nil { if info, err := redis.Info(context.Background(), "clients"); err == nil {
for _, line := range strings.Split(info, "\n") { for _, line := range strings.Split(info, "\n") {
if strings.HasPrefix(line, "connected_clients:") { if strings.HasPrefix(line, "connected_clients:") {
if value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "connected_clients:"))); err == nil { if value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "connected_clients:"))); err == nil {
redisClients = value redisClients = value
} }
break break
} }
} }
} }
if info, err := redis.Info(context.Background(), "memory"); err == nil { if info, err := redis.Info(context.Background(), "memory"); err == nil {
if value := parseRedisMemoryBytes(info, "used_memory_human"); value > 0 { if value := parseRedisMemoryBytes(info, "used_memory_human"); value > 0 {
redisMemoryUsedBytes = value redisMemoryUsedBytes = value
} }
if value := parseRedisMemoryBytes(info, "maxmemory_human"); value > 0 { if value := parseRedisMemoryBytes(info, "maxmemory_human"); value > 0 {
redisMemoryMaxBytes = value redisMemoryMaxBytes = value
} else if value := parseRedisMemoryBytes(info, "maxmemory"); value > 0 { } else if value := parseRedisMemoryBytes(info, "maxmemory"); value > 0 {
redisMemoryMaxBytes = value redisMemoryMaxBytes = value
} }
if redisMemoryMaxBytes > 0 { if redisMemoryMaxBytes > 0 {
redisMemoryUsageRatio = math.Min(float64(redisMemoryUsedBytes)/float64(redisMemoryMaxBytes), 1.0) redisMemoryUsageRatio = math.Min(float64(redisMemoryUsedBytes)/float64(redisMemoryMaxBytes), 1.0)
} }
} }
} }
} }
connectedClients := 0 connectedClients := 0
dmsSent := int64(0) dmsSent := int64(0)
if hub != nil { if hub != nil {
connectedClients = hub.ConnectedClients() connectedClients = hub.ConnectedClients()
dmsSent = hub.DMsSent dmsSent = hub.DMsSent
} }
activeSessions := 0 activeSessions := 0
if sessionStore != nil { if sessionStore != nil {
activeSessions = sessionStore.CountActive() activeSessions = sessionStore.CountActive()
} }
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
writeLine := func(name string, value any) { writeLine := func(name string, value any) {
fmt.Fprintf(w, "%s %v\n", name, value) fmt.Fprintf(w, "%s %v\n", name, value)
} }
fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.") fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge") fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge")
writeLine("linearvoid_chat_connected_clients", connectedClients) writeLine("linearvoid_chat_connected_clients", connectedClients)
fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.") fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge") fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
writeLine("linearvoid_chat_messages_total", storedMessages) writeLine("linearvoid_chat_messages_total", storedMessages)
fmt.Fprintln(w, "# HELP linearvoid_chat_dms_total Total private messages sent.") fmt.Fprintln(w, "# HELP linearvoid_chat_dms_total Total private messages sent.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_dms_total counter") fmt.Fprintln(w, "# TYPE linearvoid_chat_dms_total counter")
writeLine("linearvoid_chat_dms_total", dmsSent) writeLine("linearvoid_chat_dms_total", dmsSent)
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.") fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge") fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
writeLine("linearvoid_chat_moderation_actions_total", moderationActions) writeLine("linearvoid_chat_moderation_actions_total", moderationActions)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_enabled Redis configured for chat service.") fmt.Fprintln(w, "# HELP linearvoid_chat_redis_enabled Redis configured for chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_enabled gauge") fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_enabled gauge")
writeLine("linearvoid_chat_redis_enabled", redisEnabled) writeLine("linearvoid_chat_redis_enabled", redisEnabled)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_up Redis reachable from chat service.") fmt.Fprintln(w, "# HELP linearvoid_chat_redis_up Redis reachable from chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_up gauge") fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_up gauge")
writeLine("linearvoid_chat_redis_up", redisUp) 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, "# HELP linearvoid_chat_redis_connected_clients Redis connected client count seen by chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge") fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
writeLine("linearvoid_chat_redis_connected_clients", redisClients) 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, "# 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") fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_used_bytes gauge")
writeLine("linearvoid_chat_redis_memory_used_bytes", redisMemoryUsedBytes) 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, "# 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") fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_max_bytes gauge")
writeLine("linearvoid_chat_redis_memory_max_bytes", redisMemoryMaxBytes) 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, "# HELP linearvoid_chat_redis_memory_usage_ratio Redis memory usage ratio (used / max).")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_usage_ratio gauge") fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_usage_ratio gauge")
writeLine("linearvoid_chat_redis_memory_usage_ratio", redisMemoryUsageRatio) writeLine("linearvoid_chat_redis_memory_usage_ratio", redisMemoryUsageRatio)
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.") fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge") fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
writeLine("linearvoid_chat_admin_sessions_active", activeSessions) writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.") fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge") 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) fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
counts, sum, total := chatRequestDurationHistogram.snapshot() counts, sum, total := chatRequestDurationHistogram.snapshot()
fmt.Fprintln(w, "# HELP linearvoid_chat_request_duration_seconds HTTP request duration for chat API handlers.") 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") fmt.Fprintln(w, "# TYPE linearvoid_chat_request_duration_seconds histogram")
cumulative := uint64(0) cumulative := uint64(0)
for index, bucket := range requestDurationBuckets { for index, bucket := range requestDurationBuckets {
cumulative += counts[index] cumulative += counts[index]
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"%g\"} %d\n", bucket, cumulative) fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"%g\"} %d\n", bucket, cumulative)
} }
cumulative += counts[len(counts)-1] 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_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_sum %g\n", sum)
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_count %d\n", total) fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_count %d\n", total)
} }
} }
// HandleHealth returns a simple health check endpoint. // HandleHealth returns a simple health check endpoint.
func HandleHealth() http.HandlerFunc { func HandleHealth() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`) fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
} }
} }
// HandleNotFound returns a 404 JSON response for all unhandled paths. // HandleNotFound returns a 404 JSON response for all unhandled paths.
func HandleNotFound() http.HandlerFunc { func HandleNotFound() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"not found"}`) fmt.Fprint(w, `{"error":"not found"}`)
} }
} }
func parseRedisMemoryBytes(info, key string) int64 { func parseRedisMemoryBytes(info, key string) int64 {
for _, line := range strings.Split(info, "\n") { for _, line := range strings.Split(info, "\n") {
if !strings.HasPrefix(line, key+":") { if !strings.HasPrefix(line, key+":") {
continue continue
} }
value := strings.TrimSpace(strings.TrimPrefix(line, key+":")) value := strings.TrimSpace(strings.TrimPrefix(line, key+":"))
if value == "" { if value == "" {
return 0 return 0
} }
if raw, err := strconv.ParseInt(value, 10, 64); err == nil { if raw, err := strconv.ParseInt(value, 10, 64); err == nil {
return raw return raw
} }
return parseHumanBytes(value) return parseHumanBytes(value)
} }
return 0 return 0
} }
func parseHumanBytes(value string) int64 { func parseHumanBytes(value string) int64 {
value = strings.TrimSpace(strings.ToUpper(value)) value = strings.TrimSpace(strings.ToUpper(value))
if value == "" { if value == "" {
return 0 return 0
} }
multiplier := float64(1) multiplier := float64(1)
suffixes := map[string]float64{ suffixes := map[string]float64{
"B": 1, "B": 1,
"K": 1024, "K": 1024,
"KB": 1024, "KB": 1024,
"M": 1024 * 1024, "M": 1024 * 1024,
"MB": 1024 * 1024, "MB": 1024 * 1024,
"G": 1024 * 1024 * 1024, "G": 1024 * 1024 * 1024,
"GB": 1024 * 1024 * 1024, "GB": 1024 * 1024 * 1024,
"T": 1024 * 1024 * 1024 * 1024, "T": 1024 * 1024 * 1024 * 1024,
"TB": 1024 * 1024 * 1024 * 1024, "TB": 1024 * 1024 * 1024 * 1024,
} }
for suffix, factor := range suffixes { for suffix, factor := range suffixes {
if strings.HasSuffix(value, suffix) { if strings.HasSuffix(value, suffix) {
multiplier = factor multiplier = factor
value = strings.TrimSpace(strings.TrimSuffix(value, suffix)) value = strings.TrimSpace(strings.TrimSuffix(value, suffix))
break break
} }
} }
parsed, err := strconv.ParseFloat(value, 64) parsed, err := strconv.ParseFloat(value, 64)
if err != nil { if err != nil {
return 0 return 0
} }
return int64(parsed * multiplier) return int64(parsed * multiplier)
} }
+215 -215
View File
@@ -1,216 +1,216 @@
package metrics package metrics
import ( import (
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"chat/src/auth" "chat/src/auth"
"chat/src/db" "chat/src/db"
"chat/src/ws" "chat/src/ws"
) )
func TestHandleHealth(t *testing.T) { func TestHandleHealth(t *testing.T) {
handler := HandleHealth() handler := HandleHealth()
req := httptest.NewRequest("GET", "/health", nil) req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler(w, req) handler(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code) t.Errorf("status = %d, want 200", w.Code)
} }
body := w.Body.String() body := w.Body.String()
if !strings.Contains(body, `"healthy"`) { if !strings.Contains(body, `"healthy"`) {
t.Errorf("body missing healthy: %s", body) t.Errorf("body missing healthy: %s", body)
} }
if !strings.Contains(body, `"chat"`) { if !strings.Contains(body, `"chat"`) {
t.Errorf("body missing service name: %s", body) t.Errorf("body missing service name: %s", body)
} }
} }
func TestHandleNotFound(t *testing.T) { func TestHandleNotFound(t *testing.T) {
handler := HandleNotFound() handler := HandleNotFound()
req := httptest.NewRequest("GET", "/nonexistent", nil) req := httptest.NewRequest("GET", "/nonexistent", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler(w, req) handler(w, req)
if w.Code != http.StatusNotFound { if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code) t.Errorf("status = %d, want 404", w.Code)
} }
body := w.Body.String() body := w.Body.String()
if !strings.Contains(body, `"not found"`) { if !strings.Contains(body, `"not found"`) {
t.Errorf("body missing not found: %s", body) t.Errorf("body missing not found: %s", body)
} }
} }
func TestHandleMetrics(t *testing.T) { func TestHandleMetrics(t *testing.T) {
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets) chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
ObserveRequestDuration(100 * time.Millisecond) ObserveRequestDuration(100 * time.Millisecond)
ObserveRequestDuration(750 * time.Millisecond) ObserveRequestDuration(750 * time.Millisecond)
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil { if err != nil {
t.Fatalf("failed to open test DB: %v", err) t.Fatalf("failed to open test DB: %v", err)
} }
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}) gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
// Add some test data // Add some test data
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"}) 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()}) gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
sessionStore := auth.NewSessionStore() sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, nil, "test-instance", 10, 10*time.Second, "admin") hub := ws.NewHub(gormDB, nil, "test-instance", 10, 10*time.Second, "admin")
hub.DMsSent = 5 hub.DMsSent = 5
handler := HandleMetrics(gormDB, nil, hub, sessionStore, "sqlite", "test-instance") handler := HandleMetrics(gormDB, nil, hub, sessionStore, "sqlite", "test-instance")
req := httptest.NewRequest("GET", "/metrics", nil) req := httptest.NewRequest("GET", "/metrics", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler(w, req) handler(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code) t.Errorf("status = %d, want 200", w.Code)
} }
body := w.Body.String() body := w.Body.String()
// Check required metric names // Check required metric names
requiredMetrics := []string{ requiredMetrics := []string{
"linearvoid_chat_connected_clients", "linearvoid_chat_connected_clients",
"linearvoid_chat_messages_total", "linearvoid_chat_messages_total",
"linearvoid_chat_dms_total", "linearvoid_chat_dms_total",
"linearvoid_chat_moderation_actions_total", "linearvoid_chat_moderation_actions_total",
"linearvoid_chat_redis_enabled", "linearvoid_chat_redis_enabled",
"linearvoid_chat_redis_up", "linearvoid_chat_redis_up",
"linearvoid_chat_redis_connected_clients", "linearvoid_chat_redis_connected_clients",
"linearvoid_chat_redis_memory_used_bytes", "linearvoid_chat_redis_memory_used_bytes",
"linearvoid_chat_redis_memory_max_bytes", "linearvoid_chat_redis_memory_max_bytes",
"linearvoid_chat_redis_memory_usage_ratio", "linearvoid_chat_redis_memory_usage_ratio",
"linearvoid_chat_admin_sessions_active", "linearvoid_chat_admin_sessions_active",
"linearvoid_chat_build_info", "linearvoid_chat_build_info",
"linearvoid_chat_request_duration_seconds", "linearvoid_chat_request_duration_seconds",
} }
for _, metric := range requiredMetrics { for _, metric := range requiredMetrics {
if !strings.Contains(body, metric) { if !strings.Contains(body, metric) {
t.Errorf("body missing metric %s", metric) t.Errorf("body missing metric %s", metric)
} }
} }
// Check build_info has instance_id label // Check build_info has instance_id label
if !strings.Contains(body, `instance_id="test-instance"`) { if !strings.Contains(body, `instance_id="test-instance"`) {
t.Error("build_info missing instance_id label") t.Error("build_info missing instance_id label")
} }
// Check values // Check values
if !strings.Contains(body, "linearvoid_chat_messages_total 1") { if !strings.Contains(body, "linearvoid_chat_messages_total 1") {
t.Error("messages_total should be 1") t.Error("messages_total should be 1")
} }
if !strings.Contains(body, "linearvoid_chat_moderation_actions_total 1") { if !strings.Contains(body, "linearvoid_chat_moderation_actions_total 1") {
t.Error("moderation_actions_total should be 1") t.Error("moderation_actions_total should be 1")
} }
if !strings.Contains(body, "linearvoid_chat_dms_total 5") { if !strings.Contains(body, "linearvoid_chat_dms_total 5") {
t.Error("dms_total should be 5") t.Error("dms_total should be 5")
} }
if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") { if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") {
t.Error("redis_enabled should be 0 (nil redis)") t.Error("redis_enabled should be 0 (nil redis)")
} }
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_bucket{le=\"1\"} 2") { 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") 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") { if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 2") {
t.Error("request duration count should be 2") t.Error("request duration count should be 2")
} }
} }
func TestHandleMetrics_WrongMethod(t *testing.T) { func TestHandleMetrics_WrongMethod(t *testing.T) {
handler := HandleMetrics(nil, nil, nil, nil, "", "") handler := HandleMetrics(nil, nil, nil, nil, "", "")
req := httptest.NewRequest("POST", "/metrics", nil) req := httptest.NewRequest("POST", "/metrics", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler(w, req) handler(w, req)
if w.Code != http.StatusMethodNotAllowed { if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", w.Code) t.Errorf("status = %d, want 405", w.Code)
} }
} }
func TestHandleMetrics_WithRedis(t *testing.T) { func TestHandleMetrics_WithRedis(t *testing.T) {
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets) chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
ObserveRequestDuration(250 * time.Millisecond) ObserveRequestDuration(250 * time.Millisecond)
gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}) gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
// mockRedis for metrics test // mockRedis for metrics test
mr := &metricsMockRedis{} mr := &metricsMockRedis{}
sessionStore := auth.NewSessionStore() sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin") hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
handler := HandleMetrics(gormDB, mr, hub, sessionStore, "sqlite", "inst-123") handler := HandleMetrics(gormDB, mr, hub, sessionStore, "sqlite", "inst-123")
req := httptest.NewRequest("GET", "/metrics", nil) req := httptest.NewRequest("GET", "/metrics", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
handler(w, req) handler(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code) t.Errorf("status = %d, want 200", w.Code)
} }
body := w.Body.String() body := w.Body.String()
if !strings.Contains(body, "linearvoid_chat_redis_enabled 1") { if !strings.Contains(body, "linearvoid_chat_redis_enabled 1") {
t.Error("redis_enabled should be 1") t.Error("redis_enabled should be 1")
} }
if !strings.Contains(body, "linearvoid_chat_redis_up 1") { if !strings.Contains(body, "linearvoid_chat_redis_up 1") {
t.Error("redis_up should be 1") t.Error("redis_up should be 1")
} }
if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") { if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") {
t.Error("redis_connected_clients should be 5") t.Error("redis_connected_clients should be 5")
} }
if !strings.Contains(body, "linearvoid_chat_redis_memory_used_bytes 1048576") { if !strings.Contains(body, "linearvoid_chat_redis_memory_used_bytes 1048576") {
t.Error("redis_memory_used_bytes should be 1048576") t.Error("redis_memory_used_bytes should be 1048576")
} }
if !strings.Contains(body, "linearvoid_chat_redis_memory_usage_ratio 1") { 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") 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") { if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 1") {
t.Error("request duration count should be 1") t.Error("request duration count should be 1")
} }
} }
// metricsMockRedis is a minimal mock for metrics tests. // metricsMockRedis is a minimal mock for metrics tests.
type metricsMockRedis struct{} type metricsMockRedis struct{}
func (m *metricsMockRedis) Ping(ctx context.Context) error { return nil } 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 { func (m *metricsMockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return nil return nil
} }
func (m *metricsMockRedis) Get(ctx context.Context, key string) (string, 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) 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) 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) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
func (m *metricsMockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error { func (m *metricsMockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
return nil return nil
} }
func (m *metricsMockRedis) Exists(ctx context.Context, keys ...string) (int64, error) { return 0, 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 { func (m *metricsMockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
return nil return nil
} }
func (m *metricsMockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub { 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) { func (m *metricsMockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
return nil, 0, nil return nil, 0, nil
} }
func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) { func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) {
return "connected_clients:5\nused_memory_human:1M\nmaxmemory_human:1M\n", nil return "connected_clients:5\nused_memory_human:1M\nmaxmemory_human:1M\n", nil
} }
func (m *metricsMockRedis) Close() error { return nil } func (m *metricsMockRedis) Close() error { return nil }
+74 -74
View File
@@ -1,75 +1,75 @@
package ratelimit package ratelimit
import ( import (
"context" "context"
"fmt" "fmt"
"time" "time"
) )
// RedisClient is the subset of Redis operations needed for rate limiting. // RedisClient is the subset of Redis operations needed for rate limiting.
type RedisClient interface { type RedisClient interface {
Incr(ctx context.Context, key string) (int64, error) Incr(ctx context.Context, key string) (int64, error)
Expire(ctx context.Context, key string, expiration time.Duration) error Expire(ctx context.Context, key string, expiration time.Duration) error
Decr(ctx context.Context, key string) (int64, error) Decr(ctx context.Context, key string) (int64, error)
} }
// CheckMessageRate checks if a user has exceeded the message rate limit. // CheckMessageRate checks if a user has exceeded the message rate limit.
// Returns true if allowed, false if rate limited. // Returns true if allowed, false if rate limited.
func CheckMessageRate(ctx context.Context, redis RedisClient, username string, max int, window time.Duration) (bool, error) { func CheckMessageRate(ctx context.Context, redis RedisClient, username string, max int, window time.Duration) (bool, error) {
if redis == nil { if redis == nil {
return true, nil // No Redis = no rate limiting return true, nil // No Redis = no rate limiting
} }
key := fmt.Sprintf("rate:%s", username) key := fmt.Sprintf("rate:%s", username)
count, err := redis.Incr(ctx, key) count, err := redis.Incr(ctx, key)
if err != nil { if err != nil {
return true, err // Fail open on Redis error return true, err // Fail open on Redis error
} }
if count == 1 { if count == 1 {
_ = redis.Expire(ctx, key, window) _ = redis.Expire(ctx, key, window)
} }
return count <= int64(max), nil return count <= int64(max), nil
} }
// CheckLoginRate checks if an IP has exceeded the login attempt limit. // CheckLoginRate checks if an IP has exceeded the login attempt limit.
// Returns true if allowed, false if rate limited. // Returns true if allowed, false if rate limited.
func CheckLoginRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) { func CheckLoginRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
if redis == nil { if redis == nil {
return true, nil return true, nil
} }
key := fmt.Sprintf("login_attempts:%s", clientIP) key := fmt.Sprintf("login_attempts:%s", clientIP)
count, err := redis.Incr(ctx, key) count, err := redis.Incr(ctx, key)
if err != nil { if err != nil {
return true, err return true, err
} }
if count == 1 { if count == 1 {
_ = redis.Expire(ctx, key, 5*time.Minute) _ = redis.Expire(ctx, key, 5*time.Minute)
} }
return count <= 5, nil return count <= 5, nil
} }
// CheckWSConnRate checks if an IP has exceeded the WebSocket connection limit. // CheckWSConnRate checks if an IP has exceeded the WebSocket connection limit.
// Returns true if allowed, false if rate limited. // Returns true if allowed, false if rate limited.
// Caller must call Decr on disconnect. // Caller must call Decr on disconnect.
func CheckWSConnRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) { func CheckWSConnRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
if redis == nil { if redis == nil {
return true, nil return true, nil
} }
key := fmt.Sprintf("ws_conn:%s", clientIP) key := fmt.Sprintf("ws_conn:%s", clientIP)
count, err := redis.Incr(ctx, key) count, err := redis.Incr(ctx, key)
if err != nil { if err != nil {
return true, err return true, err
} }
if count == 1 { if count == 1 {
_ = redis.Expire(ctx, key, 60*time.Second) _ = redis.Expire(ctx, key, 60*time.Second)
} }
return count <= 5, nil return count <= 5, nil
} }
// DecrWSConn decrements the WebSocket connection counter for an IP. // DecrWSConn decrements the WebSocket connection counter for an IP.
func DecrWSConn(ctx context.Context, redis RedisClient, clientIP string) { func DecrWSConn(ctx context.Context, redis RedisClient, clientIP string) {
if redis == nil { if redis == nil {
return return
} }
key := fmt.Sprintf("ws_conn:%s", clientIP) key := fmt.Sprintf("ws_conn:%s", clientIP)
_, _ = redis.Decr(ctx, key) _, _ = redis.Decr(ctx, key)
} }
+189 -189
View File
@@ -1,190 +1,190 @@
package ratelimit package ratelimit
import ( import (
"context" "context"
"sync" "sync"
"testing" "testing"
"time" "time"
) )
// mockRedis implements RedisClient for testing. // mockRedis implements RedisClient for testing.
type mockRedis struct { type mockRedis struct {
mu sync.Mutex mu sync.Mutex
store map[string]int64 store map[string]int64
} }
func newMockRedis() *mockRedis { func newMockRedis() *mockRedis {
return &mockRedis{store: make(map[string]int64)} return &mockRedis{store: make(map[string]int64)}
} }
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) { func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.store[key]++ m.store[key]++
return m.store[key], nil return m.store[key], nil
} }
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error { func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
return nil return nil
} }
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) { func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.store[key]-- m.store[key]--
return m.store[key], nil return m.store[key], nil
} }
func TestCheckMessageRate(t *testing.T) { func TestCheckMessageRate(t *testing.T) {
redis := newMockRedis() redis := newMockRedis()
ctx := context.Background() ctx := context.Background()
// First 10 messages allowed // First 10 messages allowed
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second) allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
if err != nil { if err != nil {
t.Fatalf("CheckMessageRate #%d error = %v", i+1, err) t.Fatalf("CheckMessageRate #%d error = %v", i+1, err)
} }
if !allowed { if !allowed {
t.Errorf("CheckMessageRate #%d = false, want true", i+1) t.Errorf("CheckMessageRate #%d = false, want true", i+1)
} }
} }
// 11th message blocked // 11th message blocked
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second) allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
if err != nil { if err != nil {
t.Fatalf("CheckMessageRate #11 error = %v", err) t.Fatalf("CheckMessageRate #11 error = %v", err)
} }
if allowed { if allowed {
t.Error("CheckMessageRate #11 = true, want false (rate limited)") t.Error("CheckMessageRate #11 = true, want false (rate limited)")
} }
// Different user not affected // Different user not affected
allowed, err = CheckMessageRate(ctx, redis, "user2", 10, 10*time.Second) allowed, err = CheckMessageRate(ctx, redis, "user2", 10, 10*time.Second)
if err != nil { if err != nil {
t.Fatalf("CheckMessageRate user2 error = %v", err) t.Fatalf("CheckMessageRate user2 error = %v", err)
} }
if !allowed { if !allowed {
t.Error("CheckMessageRate user2 = false, want true") t.Error("CheckMessageRate user2 = false, want true")
} }
} }
func TestCheckMessageRate_NilRedis(t *testing.T) { func TestCheckMessageRate_NilRedis(t *testing.T) {
allowed, err := CheckMessageRate(context.Background(), nil, "user1", 10, 10*time.Second) allowed, err := CheckMessageRate(context.Background(), nil, "user1", 10, 10*time.Second)
if err != nil { if err != nil {
t.Fatalf("CheckMessageRate nil redis error = %v", err) t.Fatalf("CheckMessageRate nil redis error = %v", err)
} }
if !allowed { if !allowed {
t.Error("CheckMessageRate nil redis = false, want true (fail open)") t.Error("CheckMessageRate nil redis = false, want true (fail open)")
} }
} }
func TestCheckLoginRate(t *testing.T) { func TestCheckLoginRate(t *testing.T) {
redis := newMockRedis() redis := newMockRedis()
ctx := context.Background() ctx := context.Background()
// First 5 attempts allowed // First 5 attempts allowed
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1") allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
if err != nil { if err != nil {
t.Fatalf("CheckLoginRate #%d error = %v", i+1, err) t.Fatalf("CheckLoginRate #%d error = %v", i+1, err)
} }
if !allowed { if !allowed {
t.Errorf("CheckLoginRate #%d = false, want true", i+1) t.Errorf("CheckLoginRate #%d = false, want true", i+1)
} }
} }
// 6th attempt blocked // 6th attempt blocked
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1") allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
if err != nil { if err != nil {
t.Fatalf("CheckLoginRate #6 error = %v", err) t.Fatalf("CheckLoginRate #6 error = %v", err)
} }
if allowed { if allowed {
t.Error("CheckLoginRate #6 = true, want false (rate limited)") t.Error("CheckLoginRate #6 = true, want false (rate limited)")
} }
// Different IP not affected // Different IP not affected
allowed, err = CheckLoginRate(ctx, redis, "10.0.0.1") allowed, err = CheckLoginRate(ctx, redis, "10.0.0.1")
if err != nil { if err != nil {
t.Fatalf("CheckLoginRate other IP error = %v", err) t.Fatalf("CheckLoginRate other IP error = %v", err)
} }
if !allowed { if !allowed {
t.Error("CheckLoginRate other IP = false, want true") t.Error("CheckLoginRate other IP = false, want true")
} }
} }
func TestCheckLoginRate_NilRedis(t *testing.T) { func TestCheckLoginRate_NilRedis(t *testing.T) {
allowed, err := CheckLoginRate(context.Background(), nil, "192.168.1.1") allowed, err := CheckLoginRate(context.Background(), nil, "192.168.1.1")
if err != nil { if err != nil {
t.Fatalf("CheckLoginRate nil redis error = %v", err) t.Fatalf("CheckLoginRate nil redis error = %v", err)
} }
if !allowed { if !allowed {
t.Error("CheckLoginRate nil redis = false, want true (fail open)") t.Error("CheckLoginRate nil redis = false, want true (fail open)")
} }
} }
func TestCheckWSConnRate(t *testing.T) { func TestCheckWSConnRate(t *testing.T) {
redis := newMockRedis() redis := newMockRedis()
ctx := context.Background() ctx := context.Background()
// First 5 connections allowed // First 5 connections allowed
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1") allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
if err != nil { if err != nil {
t.Fatalf("CheckWSConnRate #%d error = %v", i+1, err) t.Fatalf("CheckWSConnRate #%d error = %v", i+1, err)
} }
if !allowed { if !allowed {
t.Errorf("CheckWSConnRate #%d = false, want true", i+1) t.Errorf("CheckWSConnRate #%d = false, want true", i+1)
} }
} }
// 6th connection blocked // 6th connection blocked
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1") allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
if err != nil { if err != nil {
t.Fatalf("CheckWSConnRate #6 error = %v", err) t.Fatalf("CheckWSConnRate #6 error = %v", err)
} }
if allowed { if allowed {
t.Error("CheckWSConnRate #6 = true, want false (rate limited)") t.Error("CheckWSConnRate #6 = true, want false (rate limited)")
} }
} }
func TestCheckWSConnRate_NilRedis(t *testing.T) { func TestCheckWSConnRate_NilRedis(t *testing.T) {
allowed, err := CheckWSConnRate(context.Background(), nil, "192.168.1.1") allowed, err := CheckWSConnRate(context.Background(), nil, "192.168.1.1")
if err != nil { if err != nil {
t.Fatalf("CheckWSConnRate nil redis error = %v", err) t.Fatalf("CheckWSConnRate nil redis error = %v", err)
} }
if !allowed { if !allowed {
t.Error("CheckWSConnRate nil redis = false, want true (fail open)") t.Error("CheckWSConnRate nil redis = false, want true (fail open)")
} }
} }
func TestDecrWSConn(t *testing.T) { func TestDecrWSConn(t *testing.T) {
redis := newMockRedis() redis := newMockRedis()
ctx := context.Background() ctx := context.Background()
// Increment to 5 // Increment to 5
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
CheckWSConnRate(ctx, redis, "192.168.1.1") CheckWSConnRate(ctx, redis, "192.168.1.1")
} }
// 6th should be blocked // 6th should be blocked
allowed, _ := CheckWSConnRate(ctx, redis, "192.168.1.1") allowed, _ := CheckWSConnRate(ctx, redis, "192.168.1.1")
if allowed { if allowed {
t.Error("6th connection should be blocked") t.Error("6th connection should be blocked")
} }
// Decrement twice (simulate 2 disconnects) // Decrement twice (simulate 2 disconnects)
DecrWSConn(ctx, redis, "192.168.1.1") DecrWSConn(ctx, redis, "192.168.1.1")
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) // Now should be allowed again (counter 6→4, then incr→5)
allowed, _ = CheckWSConnRate(ctx, redis, "192.168.1.1") allowed, _ = CheckWSConnRate(ctx, redis, "192.168.1.1")
if !allowed { if !allowed {
t.Error("after 2x DecrWSConn, connection should be allowed") t.Error("after 2x DecrWSConn, connection should be allowed")
} }
} }
func TestDecrWSConn_NilRedis(t *testing.T) { func TestDecrWSConn_NilRedis(t *testing.T) {
// Should not panic // Should not panic
DecrWSConn(context.Background(), nil, "192.168.1.1") DecrWSConn(context.Background(), nil, "192.168.1.1")
} }
+499 -499
View File
@@ -1,500 +1,500 @@
package ws package ws
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"gorm.io/gorm" "gorm.io/gorm"
"chat/src/db" "chat/src/db"
"chat/src/ratelimit" "chat/src/ratelimit"
) )
// Upgrader is the WebSocket upgrader configuration. // Upgrader is the WebSocket upgrader configuration.
var Upgrader = websocket.Upgrader{ var Upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, CheckOrigin: func(r *http.Request) bool { return true },
} }
// ClientInfo stores metadata about a connected WebSocket client. // ClientInfo stores metadata about a connected WebSocket client.
type ClientInfo struct { type ClientInfo struct {
Username string Username string
Room string Room string
} }
// Hub manages all WebSocket connections and message routing. // Hub manages all WebSocket connections and message routing.
type Hub struct { type Hub struct {
mu sync.Mutex mu sync.Mutex
clients map[*websocket.Conn]bool clients map[*websocket.Conn]bool
clientMeta map[*websocket.Conn]ClientInfo clientMeta map[*websocket.Conn]ClientInfo
DB *gorm.DB DB *gorm.DB
Redis db.RedisClient Redis db.RedisClient
InstanceID string InstanceID string
RateLimitMax int RateLimitMax int
RateLimitWindow time.Duration RateLimitWindow time.Duration
AdminUsername string AdminUsername string
DMsSent int64 DMsSent int64
presenceMu sync.Mutex presenceMu sync.Mutex
presenceTimers map[string]*time.Timer presenceTimers map[string]*time.Timer
PresenceGracePeriod time.Duration PresenceGracePeriod time.Duration
} }
// NewHub creates a new WebSocket hub. // NewHub creates a new WebSocket hub.
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub { func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
return &Hub{ return &Hub{
clients: make(map[*websocket.Conn]bool), clients: make(map[*websocket.Conn]bool),
clientMeta: make(map[*websocket.Conn]ClientInfo), clientMeta: make(map[*websocket.Conn]ClientInfo),
DB: gormDB, DB: gormDB,
Redis: redis, Redis: redis,
InstanceID: instanceID, InstanceID: instanceID,
RateLimitMax: rateMax, RateLimitMax: rateMax,
RateLimitWindow: rateWindow, RateLimitWindow: rateWindow,
AdminUsername: adminUser, AdminUsername: adminUser,
presenceTimers: make(map[string]*time.Timer), presenceTimers: make(map[string]*time.Timer),
PresenceGracePeriod: 5 * time.Second, PresenceGracePeriod: 5 * time.Second,
} }
} }
// ConnectedClients returns the number of currently connected clients. // ConnectedClients returns the number of currently connected clients.
func (h *Hub) ConnectedClients() int { func (h *Hub) ConnectedClients() int {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
return len(h.clients) return len(h.clients)
} }
// Broadcast sends a message to all connected clients and persists to DB. // Broadcast sends a message to all connected clients and persists to DB.
// System messages (join/leave announcements, moderation notices) fan out but // System messages (join/leave announcements, moderation notices) fan out but
// are not persisted, so the chat-log table only holds real chat messages. // are not persisted, so the chat-log table only holds real chat messages.
func (h *Hub) Broadcast(msg db.Message) { func (h *Hub) Broadcast(msg db.Message) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
// Persist to configured SQL backend (chat messages only). // Persist to configured SQL backend (chat messages only).
if h.DB != nil && msg.Type != "system" { if h.DB != nil && msg.Type != "system" {
h.DB.Create(&msg) h.DB.Create(&msg)
} }
// Local fan-out // Local fan-out
for client := range h.clients { for client := range h.clients {
if err := client.WriteJSON(msg); err != nil { if err := client.WriteJSON(msg); err != nil {
client.Close() client.Close()
delete(h.clients, client) delete(h.clients, client)
delete(h.clientMeta, client) delete(h.clientMeta, client)
} }
} }
// Redis pub/sub for multi-instance fan-out // Redis pub/sub for multi-instance fan-out
if h.Redis != nil && msg.Room != "" { if h.Redis != nil && msg.Room != "" {
payload, _ := json.Marshal(map[string]any{ payload, _ := json.Marshal(map[string]any{
"instance": h.InstanceID, "instance": h.InstanceID,
"msg": msg, "msg": msg,
}) })
_ = h.Redis.Publish(context.Background(), fmt.Sprintf("room:%s:messages", msg.Room), payload) _ = h.Redis.Publish(context.Background(), fmt.Sprintf("room:%s:messages", msg.Room), payload)
} }
} }
// scheduleLeaveAnnouncement broadcasts a "left" message after a short grace // scheduleLeaveAnnouncement broadcasts a "left" message after a short grace
// period so quick reconnects don't produce join/leave spam. // period so quick reconnects don't produce join/leave spam.
func (h *Hub) scheduleLeaveAnnouncement(room, username string) { func (h *Hub) scheduleLeaveAnnouncement(room, username string) {
key := room + "\x00" + username key := room + "\x00" + username
h.presenceMu.Lock() h.presenceMu.Lock()
if h.presenceTimers == nil { if h.presenceTimers == nil {
h.presenceTimers = make(map[string]*time.Timer) h.presenceTimers = make(map[string]*time.Timer)
} }
grace := h.PresenceGracePeriod grace := h.PresenceGracePeriod
if grace <= 0 { if grace <= 0 {
grace = 5 * time.Second grace = 5 * time.Second
} }
h.presenceTimers[key] = time.AfterFunc(grace, func() { h.presenceTimers[key] = time.AfterFunc(grace, func() {
h.presenceMu.Lock() h.presenceMu.Lock()
delete(h.presenceTimers, key) delete(h.presenceTimers, key)
h.presenceMu.Unlock() h.presenceMu.Unlock()
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()}) h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
}) })
h.presenceMu.Unlock() h.presenceMu.Unlock()
} }
// cancelLeaveAnnouncement cancels a pending "left" announcement for a // cancelLeaveAnnouncement cancels a pending "left" announcement for a
// room+user and reports whether one existed (i.e. a reconnect within grace). // room+user and reports whether one existed (i.e. a reconnect within grace).
func (h *Hub) cancelLeaveAnnouncement(room, username string) bool { func (h *Hub) cancelLeaveAnnouncement(room, username string) bool {
key := room + "\x00" + username key := room + "\x00" + username
h.presenceMu.Lock() h.presenceMu.Lock()
defer h.presenceMu.Unlock() defer h.presenceMu.Unlock()
if timer, ok := h.presenceTimers[key]; ok { if timer, ok := h.presenceTimers[key]; ok {
timer.Stop() timer.Stop()
delete(h.presenceTimers, key) delete(h.presenceTimers, key)
return true return true
} }
return false return false
} }
// BroadcastTyping notifies other users in the room that a user is typing. // BroadcastTyping notifies other users in the room that a user is typing.
func (h *Hub) BroadcastTyping(username, room string) { func (h *Hub) BroadcastTyping(username, room string) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
for conn, info := range h.clientMeta { for conn, info := range h.clientMeta {
if info.Room == room && info.Username != username { if info.Room == room && info.Username != username {
_ = conn.WriteJSON(db.Message{Room: room, Username: username, Text: "", Type: "typing", Timestamp: time.Now()}) _ = conn.WriteJSON(db.Message{Room: room, Username: username, Text: "", Type: "typing", Timestamp: time.Now()})
} }
} }
} }
// PushChatHistory sends the last N messages for a room via WebSocket. // PushChatHistory sends the last N messages for a room via WebSocket.
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) { func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
if h.DB == nil || room == "" { if h.DB == nil || room == "" {
return return
} }
var messages []db.Message var messages []db.Message
h.DB.Where("room = ? AND type <> ?", room, "system").Order("timestamp desc").Limit(limit).Find(&messages) h.DB.Where("room = ? AND type <> ?", room, "system").Order("timestamp desc").Limit(limit).Find(&messages)
// Reverse to chronological order // Reverse to chronological order
for i := len(messages)/2 - 1; i >= 0; i-- { for i := len(messages)/2 - 1; i >= 0; i-- {
opp := len(messages) - 1 - i opp := len(messages) - 1 - i
messages[i], messages[opp] = messages[opp], messages[i] messages[i], messages[opp] = messages[opp], messages[i]
} }
h.mu.Lock() h.mu.Lock()
for _, msg := range messages { for _, msg := range messages {
conn.WriteJSON(msg) conn.WriteJSON(msg)
} }
h.mu.Unlock() h.mu.Unlock()
} }
// HandleConnection handles a new WebSocket connection. // HandleConnection handles a new WebSocket connection.
func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) { func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
clientIP := r.Header.Get("X-Forwarded-For") clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" { if clientIP == "" {
clientIP = r.RemoteAddr clientIP = r.RemoteAddr
} }
// IP-based connection rate limiting // IP-based connection rate limiting
allowed, err := ratelimit.CheckWSConnRate(context.Background(), h.Redis, clientIP) allowed, err := ratelimit.CheckWSConnRate(context.Background(), h.Redis, clientIP)
if err != nil { if err != nil {
log.Printf("WS rate limit check error: %v", err) log.Printf("WS rate limit check error: %v", err)
} }
if !allowed { if !allowed {
http.Error(w, "too many connections from this IP", http.StatusTooManyRequests) http.Error(w, "too many connections from this IP", http.StatusTooManyRequests)
return return
} }
defer ratelimit.DecrWSConn(context.Background(), h.Redis, clientIP) defer ratelimit.DecrWSConn(context.Background(), h.Redis, clientIP)
conn, err := Upgrader.Upgrade(w, r, nil) conn, err := Upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Println("Upgrade error:", err) log.Println("Upgrade error:", err)
return return
} }
username := r.URL.Query().Get("username") username := r.URL.Query().Get("username")
room := r.URL.Query().Get("room") room := r.URL.Query().Get("room")
if username == "" { if username == "" {
username = fmt.Sprintf("anon_%d", time.Now().UnixNano()%10000) username = fmt.Sprintf("anon_%d", time.Now().UnixNano()%10000)
} }
h.mu.Lock() h.mu.Lock()
h.clients[conn] = true h.clients[conn] = true
h.clientMeta[conn] = ClientInfo{Username: username, Room: room} h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
h.mu.Unlock() h.mu.Unlock()
// Announce presence (ADR-007). A reconnect within the grace window cancels // Announce presence (ADR-007). A reconnect within the grace window cancels
// the pending "left" announcement and skips a redundant "joined". // the pending "left" announcement and skips a redundant "joined".
rejoined := h.cancelLeaveAnnouncement(room, username) rejoined := h.cancelLeaveAnnouncement(room, username)
if h.Redis != nil && room != "" { if h.Redis != nil && room != "" {
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second) _ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
if !rejoined { if !rejoined {
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()}) 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) // Push chat history on join (last 50 messages)
h.PushChatHistory(conn, room, 50) h.PushChatHistory(conn, room, 50)
// Heartbeat goroutine: refresh presence TTL every 30s // Heartbeat goroutine: refresh presence TTL every 30s
heartbeatStop := make(chan struct{}) heartbeatStop := make(chan struct{})
go func() { go func() {
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
if h.Redis != nil && room != "" { if h.Redis != nil && room != "" {
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second) _ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
} }
// Ping to detect dead connections // Ping to detect dead connections
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return return
} }
case <-heartbeatStop: case <-heartbeatStop:
return return
} }
} }
}() }()
defer func() { defer func() {
close(heartbeatStop) close(heartbeatStop)
h.mu.Lock() h.mu.Lock()
delete(h.clients, conn) delete(h.clients, conn)
delete(h.clientMeta, conn) delete(h.clientMeta, conn)
h.mu.Unlock() h.mu.Unlock()
if h.Redis != nil && room != "" { if h.Redis != nil && room != "" {
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username)) _ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
h.scheduleLeaveAnnouncement(room, username) h.scheduleLeaveAnnouncement(room, username)
} }
conn.Close() conn.Close()
}() }()
for { for {
var msg db.Message var msg db.Message
if err := conn.ReadJSON(&msg); err != nil { if err := conn.ReadJSON(&msg); err != nil {
break break
} }
msg.Timestamp = time.Now() msg.Timestamp = time.Now()
msg.Username = username // enforce from query param for security msg.Username = username // enforce from query param for security
// Check if banned before processing // Check if banned before processing
if h.Redis != nil { if h.Redis != nil {
if isBanned, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("banned:%s", username)); isBanned > 0 { 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()}) conn.WriteJSON(db.Message{Type: "system", Text: "You are banned from chat.", Timestamp: time.Now()})
continue continue
} }
if isMuted, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("muted:%s", username)); isMuted > 0 { 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()}) conn.WriteJSON(db.Message{Type: "system", Text: "You are muted.", Timestamp: time.Now()})
continue continue
} }
} }
// Rate limiting check // Rate limiting check
allowed, _ := ratelimit.CheckMessageRate(context.Background(), h.Redis, msg.Username, h.RateLimitMax, h.RateLimitWindow) allowed, _ := ratelimit.CheckMessageRate(context.Background(), h.Redis, msg.Username, h.RateLimitMax, h.RateLimitWindow)
if !allowed { if !allowed {
conn.WriteJSON(db.Message{Type: "system", Text: "Rate limit exceeded. Please slow down.", Timestamp: time.Now()}) conn.WriteJSON(db.Message{Type: "system", Text: "Rate limit exceeded. Please slow down.", Timestamp: time.Now()})
continue continue
} }
// Handle moderation commands // Handle moderation commands
if strings.HasPrefix(msg.Text, "/mod ") || strings.HasPrefix(msg.Text, "/mute ") || strings.HasPrefix(msg.Text, "/ban ") { if strings.HasPrefix(msg.Text, "/mod ") || strings.HasPrefix(msg.Text, "/mute ") || strings.HasPrefix(msg.Text, "/ban ") {
h.handleModerationCommand(msg, conn) h.handleModerationCommand(msg, conn)
continue continue
} }
// Handle typing indicator (ephemeral — not persisted, not rate-limited) // Handle typing indicator (ephemeral — not persisted, not rate-limited)
if msg.Type == "typing" { if msg.Type == "typing" {
h.BroadcastTyping(msg.Username, msg.Room) h.BroadcastTyping(msg.Username, msg.Room)
continue continue
} }
// Handle private DMs // Handle private DMs
if strings.HasPrefix(msg.Room, "dm:") { if strings.HasPrefix(msg.Room, "dm:") {
h.handlePrivateDM(msg) h.handlePrivateDM(msg)
continue continue
} }
msg.Type = "public" msg.Type = "public"
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text) log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
h.Broadcast(msg) h.Broadcast(msg)
} }
} }
// handlePrivateDM routes private messages to the specific user pair. // handlePrivateDM routes private messages to the specific user pair.
func (h *Hub) handlePrivateDM(msg db.Message) { func (h *Hub) handlePrivateDM(msg db.Message) {
msg.Type = "private" msg.Type = "private"
h.DMsSent++ h.DMsSent++
// Persist for history // Persist for history
if h.DB != nil { if h.DB != nil {
h.DB.Create(&msg) h.DB.Create(&msg)
} }
// Deliver to target if online // Deliver to target if online
h.mu.Lock() h.mu.Lock()
targetOnline := false targetOnline := false
for conn, info := range h.clientMeta { for conn, info := range h.clientMeta {
if info.Username == msg.Room || strings.Contains(msg.Room, info.Username) { if info.Username == msg.Room || strings.Contains(msg.Room, info.Username) {
if err := conn.WriteJSON(msg); err == nil { if err := conn.WriteJSON(msg); err == nil {
targetOnline = true targetOnline = true
} }
} }
} }
h.mu.Unlock() h.mu.Unlock()
if !targetOnline { if !targetOnline {
log.Printf("DM stored for offline user: %s -> room %s", msg.Username, msg.Room) log.Printf("DM stored for offline user: %s -> room %s", msg.Username, msg.Room)
} }
// Also deliver to sender for confirmation // Also deliver to sender for confirmation
h.mu.Lock() h.mu.Lock()
for conn, info := range h.clientMeta { for conn, info := range h.clientMeta {
if info.Username == msg.Username { if info.Username == msg.Username {
conn.WriteJSON(msg) conn.WriteJSON(msg)
break break
} }
} }
h.mu.Unlock() h.mu.Unlock()
log.Printf("Private DM from %s to room %s: %s", msg.Username, msg.Room, msg.Text) 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. // logModerationAction logs an action to the immutable ModerationLog table.
func (h *Hub) logModerationAction(action, target, moderator, reason string) { func (h *Hub) logModerationAction(action, target, moderator, reason string) {
logEntry := db.ModerationLog{ logEntry := db.ModerationLog{
Action: action, Action: action,
Target: target, Target: target,
Moderator: moderator, Moderator: moderator,
Reason: reason, Reason: reason,
Timestamp: time.Now(), Timestamp: time.Now(),
} }
if h.DB != nil { if h.DB != nil {
h.DB.Create(&logEntry) h.DB.Create(&logEntry)
} }
log.Printf("Moderation action logged: %s %s by %s - %s", action, target, moderator, reason) log.Printf("Moderation action logged: %s %s by %s - %s", action, target, moderator, reason)
} }
// handleModerationCommand processes /mod, /mute, /ban commands. // handleModerationCommand processes /mod, /mute, /ban commands.
func (h *Hub) handleModerationCommand(msg db.Message, conn *websocket.Conn) { func (h *Hub) handleModerationCommand(msg db.Message, conn *websocket.Conn) {
parts := strings.Fields(msg.Text) parts := strings.Fields(msg.Text)
if len(parts) < 3 { if len(parts) < 3 {
conn.WriteJSON(db.Message{Type: "system", Text: "Usage: /mute <user> <duration_m> <reason> or /ban <user> <reason>", Timestamp: time.Now()}) conn.WriteJSON(db.Message{Type: "system", Text: "Usage: /mute <user> <duration_m> <reason> or /ban <user> <reason>", Timestamp: time.Now()})
return return
} }
if msg.Username != h.AdminUsername { if msg.Username != h.AdminUsername {
conn.WriteJSON(db.Message{Type: "system", Text: "Only moderators can use moderation commands.", Timestamp: time.Now()}) conn.WriteJSON(db.Message{Type: "system", Text: "Only moderators can use moderation commands.", Timestamp: time.Now()})
return return
} }
action := strings.TrimPrefix(parts[0], "/") action := strings.TrimPrefix(parts[0], "/")
target := parts[1] target := parts[1]
reason := strings.Join(parts[2:], " ") reason := strings.Join(parts[2:], " ")
if h.Redis != nil { if h.Redis != nil {
switch action { switch action {
case "mute", "mod": case "mute", "mod":
durationMinutes := 10 // default durationMinutes := 10 // default
if len(parts) >= 3 { if len(parts) >= 3 {
if d, err := strconv.Atoi(parts[2]); err == nil && d > 0 { if d, err := strconv.Atoi(parts[2]); err == nil && d > 0 {
durationMinutes = d durationMinutes = d
if len(parts) > 3 { if len(parts) > 3 {
reason = strings.Join(parts[3:], " ") reason = strings.Join(parts[3:], " ")
} else { } else {
reason = "no reason given" reason = "no reason given"
} }
} }
} }
_ = h.Redis.Set(context.Background(), fmt.Sprintf("muted:%s", target), "1", time.Duration(durationMinutes)*time.Minute) _ = h.Redis.Set(context.Background(), fmt.Sprintf("muted:%s", target), "1", time.Duration(durationMinutes)*time.Minute)
case "ban": case "ban":
_ = h.Redis.Set(context.Background(), fmt.Sprintf("banned:%s", target), "1", 0) // no expiry _ = h.Redis.Set(context.Background(), fmt.Sprintf("banned:%s", target), "1", 0) // no expiry
} }
} }
h.logModerationAction(action, target, msg.Username, reason) h.logModerationAction(action, target, msg.Username, reason)
h.Broadcast(db.Message{ h.Broadcast(db.Message{
Room: msg.Room, Room: msg.Room,
Username: "system", Username: "system",
Type: "system", Type: "system",
Text: fmt.Sprintf("%s was %s by %s: %s", target, action, msg.Username, reason), Text: fmt.Sprintf("%s was %s by %s: %s", target, action, msg.Username, reason),
Timestamp: time.Now(), Timestamp: time.Now(),
}) })
} }
// SubscribeRoomMessages listens for Redis pub/sub messages and fans out locally. // SubscribeRoomMessages listens for Redis pub/sub messages and fans out locally.
func (h *Hub) SubscribeRoomMessages() { func (h *Hub) SubscribeRoomMessages() {
if h.Redis == nil { if h.Redis == nil {
return return
} }
pubsub := h.Redis.PSubscribe(context.Background(), "room:*:messages") pubsub := h.Redis.PSubscribe(context.Background(), "room:*:messages")
defer pubsub.Close() defer pubsub.Close()
log.Printf("Redis pub/sub subscriber started (instance %s)", h.InstanceID) log.Printf("Redis pub/sub subscriber started (instance %s)", h.InstanceID)
for { for {
msg, err := pubsub.ReceiveMessage(context.Background()) msg, err := pubsub.ReceiveMessage(context.Background())
if err != nil { if err != nil {
log.Printf("Redis pub/sub error: %v — retrying in 5s", err) log.Printf("Redis pub/sub error: %v — retrying in 5s", err)
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
pubsub = h.Redis.PSubscribe(context.Background(), "room:*:messages") pubsub = h.Redis.PSubscribe(context.Background(), "room:*:messages")
continue continue
} }
var envelope struct { var envelope struct {
Instance string `json:"instance"` Instance string `json:"instance"`
Msg db.Message `json:"msg"` Msg db.Message `json:"msg"`
} }
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil { if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil {
continue continue
} }
// Skip messages originating from this instance // Skip messages originating from this instance
if envelope.Instance == h.InstanceID { if envelope.Instance == h.InstanceID {
continue continue
} }
// Fan out to local clients // Fan out to local clients
h.mu.Lock() h.mu.Lock()
for client := range h.clients { for client := range h.clients {
client.WriteJSON(envelope.Msg) client.WriteJSON(envelope.Msg)
} }
h.mu.Unlock() h.mu.Unlock()
} }
} }
// ScanPresence returns all active presence entries from Redis. // ScanPresence returns all active presence entries from Redis.
func (h *Hub) ScanPresence(ctx context.Context) ([]db.Presence, error) { func (h *Hub) ScanPresence(ctx context.Context) ([]db.Presence, error) {
if h.Redis == nil { if h.Redis == nil {
return nil, nil return nil, nil
} }
keys, _, err := h.Redis.Scan(ctx, 0, "presence:*", 200) keys, _, err := h.Redis.Scan(ctx, 0, "presence:*", 200)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var presences []db.Presence var presences []db.Presence
for _, key := range keys { for _, key := range keys {
parts := strings.SplitN(key, ":", 3) parts := strings.SplitN(key, ":", 3)
if len(parts) != 3 { if len(parts) != 3 {
continue continue
} }
presences = append(presences, db.Presence{ presences = append(presences, db.Presence{
Room: parts[1], Room: parts[1],
Username: parts[2], Username: parts[2],
Online: true, Online: true,
}) })
} }
return presences, nil return presences, nil
} }
// MuteUser mutes a user for a duration. // MuteUser mutes a user for a duration.
func (h *Hub) MuteUser(ctx context.Context, username string, duration time.Duration) error { func (h *Hub) MuteUser(ctx context.Context, username string, duration time.Duration) error {
if h.Redis == nil { if h.Redis == nil {
return fmt.Errorf("redis unavailable") return fmt.Errorf("redis unavailable")
} }
return h.Redis.Set(ctx, fmt.Sprintf("muted:%s", username), "1", duration) return h.Redis.Set(ctx, fmt.Sprintf("muted:%s", username), "1", duration)
} }
// UnmuteUser removes a mute. // UnmuteUser removes a mute.
func (h *Hub) UnmuteUser(ctx context.Context, username string) error { func (h *Hub) UnmuteUser(ctx context.Context, username string) error {
if h.Redis == nil { if h.Redis == nil {
return fmt.Errorf("redis unavailable") return fmt.Errorf("redis unavailable")
} }
return h.Redis.Del(ctx, fmt.Sprintf("muted:%s", username)) return h.Redis.Del(ctx, fmt.Sprintf("muted:%s", username))
} }
// BanUser bans a user permanently. // BanUser bans a user permanently.
func (h *Hub) BanUser(ctx context.Context, username string) error { func (h *Hub) BanUser(ctx context.Context, username string) error {
if h.Redis == nil { if h.Redis == nil {
return fmt.Errorf("redis unavailable") return fmt.Errorf("redis unavailable")
} }
return h.Redis.Set(ctx, fmt.Sprintf("banned:%s", username), "1", 0) return h.Redis.Set(ctx, fmt.Sprintf("banned:%s", username), "1", 0)
} }
// UnbanUser removes a ban. // UnbanUser removes a ban.
func (h *Hub) UnbanUser(ctx context.Context, username string) error { func (h *Hub) UnbanUser(ctx context.Context, username string) error {
if h.Redis == nil { if h.Redis == nil {
return fmt.Errorf("redis unavailable") return fmt.Errorf("redis unavailable")
} }
return h.Redis.Del(ctx, fmt.Sprintf("banned:%s", username)) return h.Redis.Del(ctx, fmt.Sprintf("banned:%s", username))
} }
File diff suppressed because it is too large. Load diff
+32 -32
View File
@@ -1,33 +1,33 @@
FROM golang:1.25-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
COPY src/ ./src/ COPY src/ ./src/
COPY src/go.mod src/go.sum ./ COPY src/go.mod src/go.sum ./
# Download dependencies and build # Download dependencies and build
RUN go mod download RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux GOFLAGS=-mod=mod go build -o /playlist-manager ./src/playlist.go RUN CGO_ENABLED=0 GOOS=linux GOFLAGS=-mod=mod go build -o /playlist-manager ./src/playlist.go
FROM alpine:3.20 FROM alpine:3.20
# Install FFmpeg, curl for health checks, and basic tools # Install FFmpeg, curl for health checks, and basic tools
RUN apk add --no-cache \ RUN apk add --no-cache \
ffmpeg \ ffmpeg \
curl \ curl \
bash bash
# Set working directory for playout # Set working directory for playout
WORKDIR /app WORKDIR /app
# Create directories for playlist, media, HLS output, and emergency slate # Create directories for playlist, media, HLS output, and emergency slate
RUN mkdir -p /app/hls /app/media /app/playlists /app/slate RUN mkdir -p /app/hls /app/media /app/playlists /app/slate
# Copy the playout start script and the built playlist manager binary # Copy the playout start script and the built playlist manager binary
COPY start-playout.sh /app/ COPY start-playout.sh /app/
# Copy demo data for out-of-the-box playout # Copy demo data for out-of-the-box playout
COPY demodata/ /app/ COPY demodata/ /app/
# Copy the built playlist manager binary from the builder stage # Copy the built playlist manager binary from the builder stage
COPY --from=builder /playlist-manager /app/playlist-manager COPY --from=builder /playlist-manager /app/playlist-manager
# Set execute permissions for the start script and playlist manager binary # Set execute permissions for the start script and playlist manager binary
RUN chmod +x /app/start-playout.sh /app/playlist-manager RUN chmod +x /app/start-playout.sh /app/playlist-manager
# Expose HLS directory (will be served by nginx) # Expose HLS directory (will be served by nginx)
VOLUME ["/app/hls"] VOLUME ["/app/hls"]
# Default command runs the playout loop (which calls the Go validator) # Default command runs the playout loop (which calls the Go validator)
CMD ["./start-playout.sh"] CMD ["./start-playout.sh"]
@@ -1,3 +1,3 @@
# Linear Void Demo Playlist # Linear Void Demo Playlist
file '/app/media/demo1.mp4' file '/app/media/demo1.mp4'
file '/app/media/demo2.mp4' file '/app/media/demo2.mp4'
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,2 +1,2 @@
# Linear Void test playlist # Linear Void test playlist
# No media entries here # No media entries here
+1 -1
View File
@@ -1 +1 @@
+5 -5
View File
@@ -1,6 +1,6 @@
# Valid entries should still parse # Valid entries should still parse
file '/app/media/demo1.mp4' file '/app/media/demo1.mp4'
# Invalid or malformed lines are preserved as literal paths by the parser # Invalid or malformed lines are preserved as literal paths by the parser
not-a-file-entry not-a-file-entry
file '/app/media/demo2.mp4' file '/app/media/demo2.mp4'
+1 -1
View File
@@ -1,2 +1,2 @@
file '/app/media/demo1.mp4' file '/app/media/demo1.mp4'
file '/app/media/demo2.mp4' file '/app/media/demo2.mp4'
+68 -216
View File
@@ -1,216 +1,101 @@
# API Reference # Linear Void API Reference
This document describes the currently implemented API surfaces in Linear Void. Base URLs: Public/admin app `https://linearvoid.tv` · WebSocket `wss://linearvoid.tv/ws` · Local fallback `http://localhost` / `ws://localhost/ws`
## Base URLs ## Authentication
- Public app and admin: <https://linearvoid.tv> Admin REST endpoints use a cookie session:
- WebSocket endpoint: <wss://linearvoid.tv/ws>
- Local fallback: <http://localhost> and <ws://localhost/ws>
## Authentication Model - Cookie: `linearvoid_admin_session` — `HttpOnly`, `SameSite=Strict`, `Secure` when request is HTTPS (or `X-Forwarded-Proto=https`)
- Session TTL: `ADMIN_SESSION_TTL_HOURS` (default 12h)
Admin REST endpoints use a cookie session.
- Cookie name: linearvoid_admin_session
- Cookie attributes: HttpOnly, SameSite=Strict, Secure when request is HTTPS (or X-Forwarded-Proto=https)
- Session TTL: controlled by ADMIN_SESSION_TTL_HOURS (default 12h)
## Chat WebSocket API ## Chat WebSocket API
### Connect Connect: `GET /ws?username={name}&room={room}` — client/server exchange JSON messages.
Endpoint: | Message (client→server) | Field | Notes |
| ----------------------- | -------------------------------------------------------------------------------- | ----- |
| `type` | `public` for room chat, `private` for DM routing; `system` for moderation/events | |
| `room` | `main`; private DMs use `dm:userA-userB` (usernames sorted) | |
| `username` | sender | |
| `text` | message body | |
- GET /ws?username={name}&room={room} Server→client envelope adds `"timestamp"` (ISO-8601, e.g. `2026-08-04T12:34:56Z`).
Client sends JSON messages over the socket.
### Message Envelope (client -> server)
```json ```json
{ { "room": "main", "username": "viewer123", "text": "hello world", "type": "public" }
"room": "main",
"username": "viewer123",
"text": "hello world",
"type": "public"
}
```
Notes:
- type is usually public for room chat and private for DM room routing.
- Private room names are formatted like dm:userA-userB (sorted usernames).
- Moderation/system events are sent as type=system messages.
### Message Envelope (server -> client)
```json
{
"room": "main",
"username": "viewer123",
"text": "hello world",
"type": "public",
"timestamp": "2026-08-04T12:34:56Z"
}
``` ```
## Admin Session API ## Admin Session API
### Login | Method & Path | Auth | Description |
| ------------------------ | ----------------------------------- | ----------------------------- |
| `POST /api/admin/login` | none | Authenticate |
| `POST /api/admin/logout` | optional (clears cookie either way) | Logout |
| `GET /api/admin/session` | cookie required | Session check; 401 if invalid |
- POST /api/admin/login Login request:
- Auth: none
Request:
```json ```json
{ { "username": "admin", "password": "your-password" }
"username": "admin",
"password": "your-password"
}
``` ```
Success response: Login/logout/session success response (session returns 401 on failure):
```json ```json
{ { "authenticated": true, "username": "admin" }
"authenticated": true,
"username": "admin"
}
``` ```
### Logout Logout response: `{ "authenticated": false }`
- POST /api/admin/logout ## Admin Data API (auth required)
- Auth: optional (clears cookie either way)
Response: | Method & Path | Returns |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `GET /api/admin/overview` | Aggregate counters + runtime/system snapshot |
| `GET /api/admin/playlist` | Playlist |
| `PUT /api/admin/playlist` | Set playlist (body: `{ "content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n" }`) |
| `GET /api/admin/schedule` | Parsed playlist entries + matched media metadata/runtime |
| `GET /api/admin/moderation/logs?limit=50` | Moderation logs |
| `POST /api/admin/moderation/actions` | Perform moderation action |
| `GET /api/admin/analytics` | Totals (messages, moderation actions, rooms) + top room summary |
| `GET /api/admin/system` | Health snapshot (chat/playout/redis/database) + config metadata |
| `GET /api/admin/users` | Presence users (room + username) when Redis available |
Moderation action request:
```json ```json
{ { "action": "mute", "target": "username-or-id", "moderator": "admin", "reason": "spam", "room": "main" }
"authenticated": false
}
``` ```
### Session Check
- GET /api/admin/session
- Auth: cookie required
Success response:
```json
{
"authenticated": true,
"username": "admin"
}
```
Failure:
- 401 unauthorized
## Admin Data API
All endpoints below require admin authentication.
### Overview
- GET /api/admin/overview
Returns aggregate counters and runtime/system snapshot fields.
### Playlist
- GET /api/admin/playlist
- PUT /api/admin/playlist
PUT request:
```json
{
"content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n"
}
```
### Schedule
- GET /api/admin/schedule
Returns parsed playlist entries plus matched media metadata/runtime.
### Moderation
- GET /api/admin/moderation/logs?limit=50
- POST /api/admin/moderation/actions
Action request:
```json
{
"action": "mute",
"target": "username-or-id",
"moderator": "admin",
"reason": "spam",
"room": "main"
}
```
### Analytics
- GET /api/admin/analytics
Returns totals (messages, moderation actions, rooms) and top room summary.
### System
- GET /api/admin/system
Returns service health snapshot for chat/playout/redis/database and config metadata.
### Active Users
- GET /api/admin/users
Returns currently discovered presence users (room + username) when Redis is available.
## Media Library API ## Media Library API
### Media Collection | Method & Path | Description |
| ------------------------------ | ------------------------------------------------- |
| `GET /api/admin/media` | List media |
| `POST /api/admin/media` | Multipart upload (legacy), form field name `file` |
| `GET /api/admin/media/{id}` | Get item |
| `PUT /api/admin/media/{id}` | Update item |
| `DELETE /api/admin/media/{id}` | Delete item |
- GET /api/admin/media PUT media body:
- POST /api/admin/media (multipart upload, legacy path)
Multipart form field name: file
### Media Item
- GET /api/admin/media/{id}
- PUT /api/admin/media/{id}
- DELETE /api/admin/media/{id}
PUT request:
```json ```json
{ { "status": "ready", "title": "My Clip", "tags": "music,live" }
"status": "ready",
"title": "My Clip",
"tags": "music,live"
}
``` ```
## Resumable Upload API ## Resumable Upload API
Maximum file size: 2GB Max file size: **2GB** · Default chunk size: **8MB**
Default chunk size: 8MB | Method & Path | Description |
| ------------------------------------------------------------------ | ----------------------------------------------------------------- |
| `POST /api/admin/media/uploads` | Initialize or resume session |
| `GET /api/admin/media/uploads/{uploadId}` | Check upload status |
| `PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}` | Upload chunk (`Content-Type: application/octet-stream`) |
| `POST /api/admin/media/uploads/{uploadId}/complete` | Complete upload → returns media listing (`files` + `generatedAt`) |
### Initialize or Resume Session Initialize request:
- POST /api/admin/media/uploads
Request:
```json ```json
{ {
@@ -223,7 +108,7 @@ Request:
} }
``` ```
Response: Status envelope (returned by init, status check, and chunk upload):
```json ```json
{ {
@@ -237,61 +122,33 @@ Response:
} }
``` ```
### Check Upload Status ## Metrics & Health
- GET /api/admin/media/uploads/{uploadId} - `GET /metrics` (chat) — Prometheus: connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, build info
- `GET /health` — chat service, playout service, and nginx reverse proxy each expose it
Returns same status envelope as initialize.
### Upload Chunk
- PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}
- Content-Type: application/octet-stream
Response returns updated upload status envelope.
### Complete Upload
- POST /api/admin/media/uploads/{uploadId}/complete
On success returns admin media listing payload (files + generatedAt).
## Metrics and Health
### Chat metrics
- GET /metrics
Prometheus exposition includes connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, and build info.
### Health
- GET /health on chat service
- GET /health on playout service
- GET /health on nginx reverse proxy
## HTTP Status Conventions ## HTTP Status Conventions
| Code | Meaning | When | | Code | Meaning | When |
| ---- | --------------------- | ----------------------------------------------------------- | | ---- | --------------------- | ----------------------------------------------------------- |
| 200 | OK | Successful GET, PUT, or action | | 200 | OK | Successful GET, PUT, action |
| 201 | Created | Successful POST (upload init, override created) | | 201 | Created | Successful POST (upload init, override created) |
| 204 | No Content | Successful DELETE, CORS preflight | | 204 | No Content | Successful DELETE, CORS preflight |
| 400 | Bad Request | Malformed JSON, missing required fields, invalid input | | 400 | Bad Request | Malformed JSON, missing required fields, invalid input |
| 401 | Unauthorized | Missing or expired admin session | | 401 | Unauthorized | Missing/expired admin session |
| 403 | Forbidden | Valid session but insufficient permissions | | 403 | Forbidden | Valid session, insufficient permissions |
| 404 | Not Found | Resource doesn't exist | | 404 | Not Found | Resource doesn't exist |
| 405 | Method Not Allowed | Wrong HTTP method for endpoint | | 405 | Method Not Allowed | Wrong method for endpoint |
| 409 | Conflict | Schedule override overlap, upload ID mismatch | | 409 | Conflict | Schedule override overlap, upload ID mismatch |
| 413 | Payload Too Large | Upload exceeds 2 GB limit | | 413 | Payload Too Large | Upload exceeds 2 GB limit |
| 422 | Unprocessable | File fails normalization check (codec, fps, resolution) | | 422 | Unprocessable | File fails normalization check (codec, fps, resolution) |
| 429 | Too Many Requests | Rate limit exceeded (chat messages, WS connections, login) | | 429 | Too Many Requests | Rate limit exceeded (chat, WS, login) |
| 500 | Internal Server Error | Unexpected server failure | | 500 | Internal Server Error | Unexpected server failure |
| 503 | Service Unavailable | Redis unavailable for user actions, playout health degraded | | 503 | Service Unavailable | Redis unavailable for user actions, playout health degraded |
## Rate Limiting ## Rate Limiting
All limits are per-IP (from `X-Forwarded-For` or `RemoteAddr`) unless noted. Per-IP (from `X-Forwarded-For` or `RemoteAddr`) unless noted.
| Resource | Limit | Window | Response | | Resource | Limit | Window | Response |
| --------------------- | ---------------------------------- | ---------------------------------------- | ------------------------------------------------------ | | --------------------- | ---------------------------------- | ---------------------------------------- | ------------------------------------------------------ |
@@ -299,9 +156,4 @@ All limits are per-IP (from `X-Forwarded-For` or `RemoteAddr`) unless noted.
| WebSocket connections | 5 | 60s | 429 + `"too many connections from this IP"` | | WebSocket connections | 5 | 60s | 429 + `"too many connections from this IP"` |
| Admin login | 5 | 5min | 429 + `"too many login attempts, try again later"` | | Admin login | 5 | 5min | 429 + `"too many login attempts, try again later"` |
Rate limit headers on applicable responses: Rate limit headers (applicable responses): `X-RateLimit-Limit` (max requests/window), `Retry-After` (seconds until reset). 413 = payload too large; 500 = internal server error.
- `X-RateLimit-Limit`: maximum requests per window
- `Retry-After`: seconds until window resets
- 413: payload too large
- 500: internal server error
+79 -160
View File
@@ -1,229 +1,148 @@
# Coolify Deployment # Coolify Deployment
**Last Updated**: 2026-08-28 *Last Updated: 2026-08-28*
This guide describes an alternative deployment path for Linear Void using Alternative deployment path for Linear Void using [Coolify](https://github.com/coollabsio/coolify/tree/v4.3.14) (v4.3.14), an open-source self-hostable PaaS that manages servers, apps, and databases over SSH. Drop-in alternative to the flows in [SETUP.md](./SETUP.md): local dev (`docker-compose.yml`) and test-lab/prod (`docker-compose.prod.yml` via Gitea Actions CD). Coolify deploys the same Compose stack through a web UI and its own Traefik reverse proxy for automatic HTTPS, domain routing, and one-click redeploys (no manual `DOCKER_HOST`).
[Coolify](https://github.com/coollabsio/coolify/tree/v4.3.14) (v4.3.14) — an
open-source, self-hostable PaaS that lets you manage servers, applications, and
databases over SSH without vendor lock-in.
Coolify is a drop-in alternative to the two existing deployment flows described ## Why Coolify
in [SETUP.md](./SETUP.md):
1. **Local development** (`docker-compose.yml`) — builds from source on your - **Single control plane**: manage playout, chat, nginx, redis, db (plus exporters/monitoring) from one dashboard.
workstation. - **Automatic TLS**: Let's Encrypt provisioning/renewal per app/domain, replacing the manual Cloudflare + nginx origin TLS setup for the app hostname.
2. **Test-lab / production** (`docker-compose.prod.yml`) — pulls prebuilt images - **No vendor lock-in**: config lives on your server; containers keep running if you stop using Coolify — you only lose automation.
from the Gitea registry and is driven by the Gitea Actions CD workflow. - **Simpler cutover**: on Hetzner VPS provisioning, point Coolify at the new server and redeploy instead of editing CD `DOCKER_HOST`.
With Coolify, the same Docker Compose stack is deployed through a web UI and > Replaces the *deployment orchestration* layer only. Cloudflare DNS, R2 media bucket, cache rules, and the edge Worker from [SETUP.md](./SETUP.md) are unchanged.
Coolify's own reverse proxy (Traefik), giving you automatic HTTPS, domain
routing, and one-click redeploys without managing `DOCKER_HOST` manually.
## Why use Coolify?
- **Single control plane**: manage the playout, chat, nginx, redis, and db
services (plus exporters and monitoring) from one dashboard.
- **Automatic TLS**: Coolify provisions and renews Let's Encrypt certificates
per application/domain, replacing the manual Cloudflare + nginx origin TLS
dance for the app hostname.
- **No vendor lock-in**: all configuration lives on your server. If you stop
using Coolify, the running containers keep working; you only lose the
automation.
- **Simpler cutover**: when the Hetzner VPS is provisioned, point Coolify at the
new server and redeploy instead of editing CD `DOCKER_HOST` values.
> **Note**: Coolify replaces the _deployment orchestration_ layer only. Cloudflare
> DNS, the R2 media bucket, cache rules, and the edge Worker described in
> [SETUP.md](./SETUP.md) still apply and are unchanged.
## Prerequisites ## Prerequisites
- A Linux server running **Docker Engine** (any distro Coolify supports). - Linux server with **Docker Engine** (any supported distro).
- **SSH access** (root or a user with passwordless `sudo`) from the machine - **SSH access** (root or passwordless `sudo`) from the Coolify install machine — Coolify provisions the target over SSH.
where you run the Coolify install — Coolify uses SSH to provision the target - **2 GB RAM / 20 GB disk** minimum for Coolify itself (plus resources for the stack).
server. - A domain pointed at the server for HTTPS (e.g. `linearvoid.tv`).
- At least **2 GB RAM / 20 GB disk** for the Coolify instance itself (plus - Ports `80` and `443` reachable for Traefik.
additional resources for the Linear Void stack). - Gitea registry credentials (`CI_REGISTRY_USER` / `CI_REGISTRY_TOKEN`) if pulling prebuilt `playout`, `chat`, `nginx` images.
- A domain name pointed at the server for HTTPS (e.g. `linearvoid.tv`).
- Ports `80` and `443` reachable on the server for Traefik.
- The Gitea registry credentials (`CI_REGISTRY_USER` / `CI_REGISTRY_TOKEN`) if
you intend to pull the prebuilt `playout`, `chat`, and `nginx` images.
## Installation ## Installation
Install Coolify on a dedicated server (or alongside your stack — but a dedicated Recommended on a dedicated server:
Coolify server is the recommended layout):
```bash ```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
``` ```
The installer boots a Docker-based Coolify instance and prints the URL and The installer boots a Docker-based Coolify instance and prints the URL and default credentials. Complete first-run setup in the dashboard. For non-interactive/offline installs, see the [installation docs](https://coolify.io/docs/installation) and [install script source](https://github.com/coollabsio/coolify/blob/v4.3.14/scripts/install.sh).
default credentials. Open the dashboard in your browser and complete first-run
setup.
For non-interactive or offline installs, see the
[installation docs](https://coolify.io/docs/installation) and the
[install script source](https://github.com/coollabsio/coolify/blob/v4.3.14/scripts/install.sh).
## Add the Linear Void server ## Add the Linear Void server
1. In the Coolify dashboard, go to **Servers → Add**. 1. **Servers → Add**.
2. Choose your provider (or **Localhost** if Coolify runs on the same machine 2. Choose your provider (or **Localhost** if Coolify runs on the same host as the stack).
as the stack). 3. Provide SSH details; for a remote host Coolify validates and installs its agent.
3. Provide the SSH details. For a remote host, Coolify validates the connection 4. Confirm the server shows **Reachable** before creating resources.
and installs its agent.
4. Verify the server shows as **Reachable** before creating resources.
### Current topology (verified 2026-08-30) ### Current topology (verified 2026-08-30)
The active test-lab setup is **two-tier**: Active test-lab setup is **two-tier**:
| Component | Host | Environment | Exposes | Docker TCP | Notes | | Component | Host | Exposes | Docker TCP | Notes |
| --------------------- | --------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |---|---|---|---|---|
| **Coolify dashboard** | `192.168.88.18` | Coolify; docker container; coolify dashboard at `8000` | `https://coolify.allucanget.biz` via Cloudflare tunnel (`coolify-cloudflared`) | No | Coolify manages target servers over SSH | | **Coolify dashboard** | `192.168.88.18` | `https://coolify.allucanget.biz` via Cloudflare tunnel (`coolify-cloudflared`); dashboard at `8000` | No | Manages target servers over SSH |
| **Deployment target** | `192.168.88.40` | Only Linear Void deploys here | `coolify-proxy` = Traefik on 80/443, Port 8000 on this host is bound by Portainer, not Coolify. | Yes | Contains Coolify's per-server helpers (`coolify-proxy`, `coolify-sentinel`, `coolify-cloudflared`) plus the stack via `docker-compose.prod.yml` (the `linearvoid` compose project, 11 containers). | | **Deployment target** | `192.168.88.40` | `coolify-proxy` = Traefik on 80/443; port `8000` bound by Portainer, not Coolify | Yes | Per-server helpers (`coolify-proxy`, `coolify-sentinel`, `coolify-cloudflared`) + the stack via `docker-compose.prod.yml` (`linearvoid` compose project, 11 containers) |
The main `coolify` app container does **not** run on `192.168.88.40`; it lives on The main `coolify` app container runs only on the dashboard host `192.168.88.18`; Compose-resource services land on the target `192.168.88.40`. The dashboard host exposes no Docker TCP socket, so run the checks below against the **target** `192.168.88.40`:
the dashboard host `192.168.88.18`. When a Coolify deployment creates a Compose
resource, the app services land on the target server `192.168.88.40`.
Useful checks: ```bash
./scripts/check-env.sh # env/deployment health on target (containers, helpers, health)
```powershell ./scripts/check-tokens.sh # Gitea registry, Cloudflare, R2, admin credential access
# Environment / deployment health on the target 192.168.88.40 (containers, helpers, health)
./scripts/check-env.ps1
# Credential / token access (Gitea registry, Cloudflare, R2, admin login)
./scripts/check-tokens.ps1
``` ```
The Coolify dashboard runs on the `192.168.88.18` container host (port `8000`,
publicly `https://coolify.allucanget.biz`) and exposes no Docker TCP socket, so
the environment/token scripts are intended to run against the deployment target
`192.168.88.40`, not `192.168.88.18`.
## Deploy the stack ## Deploy the stack
Linear Void is a multi-service Docker Compose stack, so deploy it as a Deploy as a **Docker Compose** resource (not individual apps):
**Compose** (Docker Compose) resource rather than individual applications.
1. **Projects → Add → Docker Compose**. 1. **Projects → Add → Docker Compose**.
2. Select the target server. 2. Select the target server.
3. In **Build Settings**, set the compose file source: 3. In **Build Settings**, set the compose file source:
- **Git Repository** — point Coolify at - **Git Repository** → point at `https://git.allucanget.biz/zwitschi/linearvoid.git`, compose file `docker-compose.prod.yml` (Coolify pulls on each redeploy).
`https://git.allucanget.biz/zwitschi/linearvoid.git` and use - **Raw Compose** → paste `docker-compose.prod.yml` directly to manage manually.
`docker-compose.prod.yml` as the compose file. Coolify pulls on each 4. For the Git source, add the Gitea registry login as a **Deployment Key / environment secret** so `playout`, `chat`, `nginx` images can be pulled. Alternatively switch those services to `build:` blocks (as in `docker-compose.yml`) to build from source on the server.
redeploy.
- **Raw Compose** — paste the contents of `docker-compose.prod.yml` directly
if you prefer to manage it manually.
4. If using the Git source, add the Gitea registry login as a **Deployment Key /
environment secret** so the `playout`, `chat`, and `nginx` images can be
pulled. Alternatively, switch those services to `build:` blocks (as in
`docker-compose.yml`) so Coolify builds from source on the server.
### Environment variables ### Environment variables
Coolify injects a secret store into the compose resource. Map the Linear Void Map Linear Void's `.env` values into Coolify's **Environment Variables** tab:
environment to Coolify's **Environment Variables** tab (these correspond to the
values normally placed in `.env`):
| Variable | Purpose | | Variable | Purpose |
| ---------------------------------------------- | --------------------------------------------------------------------- | |---|---|
| `DATABASE_URL` | `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable` | | `DATABASE_URL` | `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable` |
| `REDIS_URL` | `redis://redis:6379/0` | | `REDIS_URL` | `redis://redis:6379/0` |
| `POSTGRES_PASSWORD` | Database password (used by the `db` service) | | `POSTGRES_PASSWORD` | DB password (used by `db` service) |
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Admin panel credentials | | `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Admin panel credentials |
| `ADMIN_SESSION_TTL_HOURS` | Admin session lifetime | | `ADMIN_SESSION_TTL_HOURS` | Admin session lifetime |
| `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` | Cloudflare R2 credentials | | `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` | Cloudflare R2 credentials |
| `R2_BUCKET_NAME` / `R2_ENDPOINT` / `R2_REGION` | R2 bucket config (`R2_REGION=auto`) | | `R2_BUCKET_NAME` / `R2_ENDPOINT` / `R2_REGION` | R2 bucket config (`R2_REGION=auto`) |
| `MEDIA_DOMAIN` | `media.linearvoid.tv` | | `MEDIA_DOMAIN` | `media.linearvoid.tv` |
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` | | `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` |
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` | | `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` |
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | | `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` |
Keep internal service URLs on compose hostnames (`redis`, `db`) exactly as in Keep internal service URLs on compose hostnames (`redis`, `db`) exactly as in `docker-compose.prod.yml`.
`docker-compose.prod.yml`.
### Persistent storage ### Persistent storage
The compose file uses named volumes (`redis-data`, `postgres-data`) and bind Named volumes (`redis-data`, `postgres-data`) and bind mounts under `./data` must be configured under **Persistent Storage** to survive redeploys:
mounts under `./data` (playlist, media, hls, chat, slate). Configure these under
**Persistent Storage** so data survives redeploys:
| Mount | Purpose | | Mount | Purpose |
| --------------- | ----------------------------------------------- | |---|---|
| `data/playlist` | Playout playlist file | | `data/playlist` | Playout playlist file |
| `data/media` | Normalized source videos (read-only to playout) | | `data/media` | Normalized source videos (read-only to playout) |
| `data/hls` | Generated HLS manifests and segments | | `data/hls` | Generated HLS manifests/segments |
| `data/chat` | Chat service working data | | `data/chat` | Chat service working data |
| `data/slate` | Slate media | | `data/slate` | Slate media |
| `redis-data` | Redis append-only persistence | | `redis-data` | Redis append-only persistence |
| `postgres-data` | PostgreSQL data directory | | `postgres-data` | PostgreSQL data directory |
Create the `data` subdirectories before the first deploy if Coolify does not If Coolify doesn't auto-create the dirs, create them before first deploy:
auto-create them (`mkdir -p data/playlist data/media data/hls data/chat`).
```bash
mkdir -p data/playlist data/media data/hls data/chat
```
### Domains and proxy ### Domains and proxy
1. Open the compose resource and assign the **nginx** service a public domain 1. Assign the **nginx** service a public domain (e.g. `linearvoid.tv`); Traefik terminates TLS and forwards to `nginx`.
(e.g. `linearvoid.tv`). Coolify's Traefik reverse proxy then terminates TLS 2. Set **Ports Exposes** so Traefik targets the nginx port (`80` inside container, mapped from `${NGINX_PORT:-8080}:80`).
and forwards to the `nginx` container. 3. Do **not** publicly expose playout (`8081`) or chat (`3000`) health ports — keep them internal and route through nginx.
2. Set the **Ports Exposes** mapping so Traefik targets the nginx service port 4. For `media.linearvoid.tv`, keep serving HLS from the R2 custom domain (per SETUP.md) rather than routing through Coolify, so segments stay on Cloudflare's edge cache.
(`80` inside the container, mapped from `${NGINX_PORT:-8080}:80`).
3. Do **not** also expose the playout (`8081`) or chat (`3000`) health ports
publicly — keep those internal to the compose network and let Traefik route
through nginx.
4. For the media domain (`media.linearvoid.tv`), keep serving HLS from the R2
custom domain (per SETUP.md) rather than routing it through Coolify, so
segments stay on Cloudflare's edge cache.
## Post-deploy verification ## Post-deploy verification
After the first deploy, confirm: - `docker compose ps` on the server shows all services healthy.
- `docker compose ps` (on the server) shows all services healthy.
- Playout health: `https://<host>/health` via nginx (internal `:8081/health`). - Playout health: `https://<host>/health` via nginx (internal `:8081/health`).
- Chat health: internal `:3000/health`. - Chat health: internal `:3000/health`.
- HLS is written to `data/hls` and served from `media.linearvoid.tv`. - HLS written to `data/hls` and served from `media.linearvoid.tv`.
- Chat WebSocket connects over `wss://linearvoid.tv/ws`. - Chat WebSocket connects over `wss://linearvoid.tv/ws`.
- Admin login works at `/admin` with the configured credentials. - Admin login works at `/admin` with configured credentials.
## Monitoring ## Monitoring
Coolify exposes container logs and basic metrics per resource. The stack also Coolify exposes container logs/basic metrics per resource. The stack ships Prometheus + Grafana (`monitoring/`) plus nginx/redis exporters. To reach them via Coolify, expose Grafana on a restricted subdomain (e.g. `grafana.linearvoid.tv`) and keep Prometheus internal, mirroring SETUP.md access guidance.
ships Prometheus + Grafana (`monitoring/`) and nginx/redis exporters. If you
want those reachable through Coolify, expose Grafana on a restricted subdomain
(e.g. `grafana.linearvoid.tv`) and keep Prometheus internal, mirroring the
access guidance in SETUP.md.
## Cutover from Gitea CD ## Cutover from Gitea CD
Coolify can fully replace the Gitea Actions CD step. To switch:
1. Deploy the stack as a Coolify compose resource (above). 1. Deploy the stack as a Coolify compose resource (above).
2. Point Cloudflare DNS for `linearvoid.tv` / `www` at the server IP (proxied). 2. Point Cloudflare DNS for `linearvoid.tv` / `www` at the server IP (proxied).
3. Disable (or keep as a fallback) the `cd` job in the Gitea workflow so the two 3. Disable (or keep as fallback) the `cd` job in the Gitea workflow so the two orchestrators don't fight over the same `DOCKER_HOST`.
orchestrators don't fight over the same `DOCKER_HOST`. 4. Keep the Gitea **CI** (build + push images) job so Coolify keeps pulling `:latest` from the registry.
4. Keep the Gitea **CI** (build + push images) job so Coolify continues pulling
`:latest` images from the registry.
## Troubleshooting ## Troubleshooting
- **Image pull fails**: ensure the Gitea registry credentials are set in the - **Image pull fails**: set Gitea registry credentials in the compose secret store, or switch services to `build:` blocks.
compose resource's secret store, or switch the services to `build:` blocks. - **Healthchecks fail**: confirm `curl` exists in the built images (healthchecks rely on it).
- **Healthchecks fail**: confirm `curl` is available in the built images (the - **TLS errors**: check the domain's DNS points at the Coolify server and ports `80`/`443` are open.
compose healthchecks rely on `curl`). - **HLS not updating**: verify `data/hls` is writable and playout restarts cleanly; see SETUP.md normalization troubleshooting.
- **TLS errors**: check the domain's DNS points at the Coolify server and ports - **Ports conflict**: Coolify's Traefik binds `80`/`443`; do not also map those to the host in the compose file.
`80`/`443` are open.
- **HLS not updating**: verify `data/hls` is writable and the playout service
restarts cleanly; see the normalization troubleshooting in SETUP.md.
- **Ports conflict**: Coolify's Traefik binds `80`/`443`; do not also map those
to the host in the compose file.
## Further reading ## Further reading
- Coolify repo (v4.3.14): <https://github.com/coollabsio/coolify/tree/v4.3.14> - Coolify repo (v4.3.14): <https://github.com/coollabsio/coolify/tree/v4.3.14>
- Coolify docs: <https://coolify.io/docs> - Coolify docs: <https://coolify.io/docs>
- Linear Void deployment: [DEPLOYMENT.md](./DEPLOYMENT.md) - [DEPLOYMENT.md](./DEPLOYMENT.md) · [SETUP.md](./SETUP.md)
- Linear Void setup: [SETUP.md](./SETUP.md)
+72 -194
View File
@@ -1,64 +1,26 @@
# Deployment Guide # Deployment Guide
## Table of Contents
- [Deployment Guide](#deployment-guide)
- [Table of Contents](#table-of-contents)
- [Environments](#environments)
- [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)
- [Prerequisites](#prerequisites)
- [Environment File](#environment-file)
- [Gitea Secrets](#gitea-secrets)
- [Pull-and-Run Deployment](#pull-and-run-deployment)
- [First-Time or Recovery Provisioning](#first-time-or-recovery-provisioning)
- [Health Verification](#health-verification)
- [Zero/Low-Downtime Update Pattern](#zerolow-downtime-update-pattern)
- [Rollback](#rollback)
- [Operational Tasks](#operational-tasks)
- [View logs](#view-logs)
- [Restart one service](#restart-one-service)
- [Stop stack](#stop-stack)
- [Keep volumes (recommended) vs remove volumes (destructive)](#keep-volumes-recommended-vs-remove-volumes-destructive)
- [Nginx and Proxy Considerations](#nginx-and-proxy-considerations)
- [Security Baseline](#security-baseline)
- [Common Deployment Pitfalls](#common-deployment-pitfalls)
- [Troubleshooting](#troubleshooting)
## Environments ## Environments
Linear Void runs in two separate environments: | Environment | Host | Docker Target | Purpose |
|---|---|---|---|
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv |
| Environment | Host | Docker Target | Purpose | **Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. `cd.yml` and `docker-compose.prod.yml` target the test lab; switching to production only requires changing `DOCKER_HOST`.
| -------------- | -------------------------- | -------------------------- | ---------------------------------------------- |
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv serving real viewers |
**Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. The CD workflow (`cd.yml`) and `docker-compose.prod.yml` currently target the test lab. Switching to production requires only changing `DOCKER_HOST` — see [Production Deployment](#production-target-hetzner) below.
## Production Target (Hetzner) ## Production Target (Hetzner)
**Status: Not provisioned.** The production deployment plan is documented and the pipeline is ready — only the VPS needs to be created. **Status: Not provisioned.** Plan documented and pipeline ready; only the VPS needs creation.
### Planned Infrastructure ### Planned Infrastructure
- Provider: Hetzner Cloud, Region: Ashburn, VA (us-east)
| Resource | Spec | - Instance: CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent
| -------------- | ------------------------------------------------ | - OS: Ubuntu 24.04 LTS or Debian 12
| Provider | Hetzner Cloud | - Bandwidth: 20 TB included traffic/month; Estimated cost: ~€7–10/month base
| Region | Ashburn, VA (us-east) |
| Instance | CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent |
| OS | Ubuntu 24.04 LTS or Debian 12 |
| Bandwidth | 20 TB included traffic/month |
| Estimated cost | ~€7–10/month base |
### Cutover Plan ### Cutover Plan
1. Provision Hetzner VPS
1. Provision Hetzner VPS (see [Production Deployment](#production-target-hetzner) section)
2. Add production secrets to Gitea (`CI_PROD_*` prefix) 2. Add production secrets to Gitea (`CI_PROD_*` prefix)
3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP 3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP
4. Trigger deploy → validate health checks 4. Trigger deploy → validate health checks
@@ -66,44 +28,33 @@ Linear Void runs in two separate environments:
6. Enable HTTPS, WAF, cache rules on production zone 6. Enable HTTPS, WAF, cache rules on production zone
7. Decommission test lab as public target 7. Decommission test lab as public target
### Production Preflight ### Production Preflight (minimum readiness gate)
1. Verify Hetzner host reachable over SSH and Docker remote API TLS works for the Gitea runner
Before the cutover in the plan above, treat the following as the minimum readiness gate: 2. Confirm all production secrets exist under `CI_PROD_*` prefix
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, `media.linearvoid.tv` DNS targets match intended phase
1. Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner. 4. Complete manual Cloudflare/UptimeRobot checklist (see `docs/operations/cloudflare-uptimerobot-checklist.md`)
2. Confirm all required production secrets are present under the `CI_PROD_*` prefix. 5. Run production smoke path end-to-end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, playout health
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv` DNS targets are correct for the intended phase. 6. Verify rollback possible before final DNS switch
4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in [Cloudflare Uptimerobot checklist](../docs/operations/cloudflare-uptimerobot-checklist.md).
5. Run the production smoke path end to end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, and playout health.
6. Verify rollback is still possible before the final DNS switch.
Readiness is not complete until the checklist above and the manual evidence tracker are both satisfied.
### Firewall / Security Group ### Firewall / Security Group
| Port | Source | Purpose |
| Port | Source | Purpose | |---|---|---|
| ---- | --------------------- | ------------------------------ | | 22 | Admin IPs | SSH management |
| 22 | Admin IPs | SSH management | | 80 | 0.0.0.0/0 | HTTP (redirect to HTTPS) |
| 80 | 0.0.0.0/0 | HTTP (redirect to HTTPS) | | 443 | 0.0.0.0/0 | HTTPS (nginx) |
| 443 | 0.0.0.0/0 | HTTPS (nginx) | | 2375 | Gitea runner IP only | Docker remote API (TLS) |
| 2375 | Gitea runner IP only | Docker remote API (TLS) |
| 9090 | Admin IPs or internal | Prometheus (or tunnel via SSH) | | 9090 | Admin IPs or internal | Prometheus (or tunnel via SSH) |
| 3001 | Admin IPs or internal | Grafana (or tunnel via SSH) | | 3001 | Admin IPs or internal | Grafana (or tunnel via SSH) |
### Pipeline Readiness ### Pipeline Readiness
| Workflow | Trigger | Purpose |
|---|---|---|
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push images (`:latest`, `:main`) |
| `cd.yml` | Push to `main` | Deploy to test lab (`192.168.88.40:2375`) — runs **in parallel** with CI |
| `release.yml` | Tag push `*.*.*` | Build & push versioned images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
| `workers-deploy.yml` | After CI succeeds | Deploy frontend to Cloudflare Workers |
Three Gitea Actions workflows drive the full pipeline: CI and CD run simultaneously on every push to `main`. Deploy pulls whatever `:latest` images are available at that moment. `cd.yml` uses `DOCKER_HOST` to target the host:
| Workflow | Trigger | Purpose |
| -------------------- | ----------------- | ----------------------------------------------------------------------------------------- |
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push Docker images (`:latest`, `:main`) |
| `cd.yml` | Push to `main` | Deploy to test lab (`192.168.88.40:2375`) — runs **in parallel** with CI |
| `release.yml` | Tag push `*.*.*` | Build & push versioned Docker images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
| `workers-deploy.yml` | After CI succeeds | Deploy frontend to Cloudflare Workers (see `docs/operations/gitea-cloudflare-workers.md`) |
CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
The CD workflow (`cd.yml`) uses `DOCKER_HOST` to target the deployment host. To switch targets:
```bash ```bash
# Test lab (current) # Test lab (current)
@@ -113,10 +64,9 @@ DOCKER_HOST=tcp://192.168.88.40:2375
DOCKER_HOST=tcp://<hetzner-public-ip>:2375 DOCKER_HOST=tcp://<hetzner-public-ip>:2375
``` ```
No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deployment section for full checklist. No pipeline code changes needed to switch targets.
## Target Topology ## Target Topology
- Host: Linux server with Docker Engine - Host: Linux server with Docker Engine
- Reverse proxy: nginx container (linearvoid-nginx) - Reverse proxy: nginx container (linearvoid-nginx)
- Core services: playout, chat, redis, postgres - Core services: playout, chat, redis, postgres
@@ -124,16 +74,14 @@ No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deploymen
- CDN/edge: Cloudflare - CDN/edge: Cloudflare
## Prerequisites ## Prerequisites
- Docker Engine + Docker Compose plugin - Docker Engine + Docker Compose plugin
- Network access to git.allucanget.biz - Network access to git.allucanget.biz
- A valid .env file with production values - Valid `.env` file with production values
- DNS configured for linearvoid.tv (and media.linearvoid.tv if split media domain is used) - DNS configured for `linearvoid.tv` (and `media.linearvoid.tv` if split media domain used)
## Environment File ## Environment File
Use `.env` with at least: `.env` requires at minimum:
- `DATABASE_URL` — PostgreSQL connection string - `DATABASE_URL` — PostgreSQL connection string
- `REDIS_URL` — Redis connection string - `REDIS_URL` — Redis connection string
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` — admin dashboard credentials - `ADMIN_USERNAME` / `ADMIN_PASSWORD` — admin dashboard credentials
@@ -141,186 +89,116 @@ Use `.env` with at least:
- `VITE_PUBLIC_APP_URL` / `VITE_PUBLIC_WS_URL` / `VITE_PUBLIC_HLS_URL` — frontend URLs - `VITE_PUBLIC_APP_URL` / `VITE_PUBLIC_WS_URL` / `VITE_PUBLIC_HLS_URL` — frontend URLs
**Cloudflare R2 (required for HLS segment sync to edge):** **Cloudflare R2 (required for HLS segment sync to edge):**
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY`
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` — R2 write credentials - `R2_BUCKET_NAME` (e.g. `linearvoid-media`)
- `R2_BUCKET_NAME` — bucket name (e.g. `linearvoid-media`)
- `R2_ENDPOINT` — S3-compatible endpoint URL - `R2_ENDPOINT` — S3-compatible endpoint URL
- `R2_REGION` — set to `auto` - `R2_REGION` — set to `auto`
**Cloudflare (optional, for Workers):** **Cloudflare (optional, for Workers):**
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` - `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`) - `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
See `.env.example` and `SETUP.md` for full variable reference. See `.env.example` and `SETUP.md` for full variable reference.
### Gitea Secrets ### Gitea Secrets
`cd.yml` injects sensitive values from Gitea secrets into `.env` at deploy time. Configure in the Gitea repository:
The CD workflow (`cd.yml`) injects sensitive values from Gitea secrets into the `.env` file at deploy time. The following secrets must be configured in the Gitea repository: `DATABASE_URL`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN_ACCESS_KEY_ID` (optional), `R2_ADMIN_SECRET_ACCESS_KEY` (optional), `R2_ADMIN_ENDPOINT` (optional), `CLOUDFLARE_API_TOKEN` (optional), `CLOUDFLARE_ACCOUNT_ID` (optional), `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN`
| Secret Name | Description |
| ---------------------------- | --------------------------------- |
| `DATABASE_URL` | PostgreSQL connection string |
| `ADMIN_USERNAME` | Admin dashboard username |
| `ADMIN_PASSWORD` | Admin dashboard password |
| `R2_ACCESS_KEY_ID` | Cloudflare R2 access key |
| `R2_SECRET_ACCESS_KEY` | Cloudflare R2 secret key |
| `R2_BUCKET_NAME` | R2 bucket name |
| `R2_ENDPOINT` | R2 S3-compatible endpoint URL |
| `R2_ADMIN_ACCESS_KEY_ID` | R2 admin access key (optional) |
| `R2_ADMIN_SECRET_ACCESS_KEY` | R2 admin secret key (optional) |
| `R2_ADMIN_ENDPOINT` | R2 admin endpoint (optional) |
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token (optional) |
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID (optional) |
| `CI_REGISTRY_USER` | Gitea container registry username |
| `CI_REGISTRY_TOKEN` | Gitea container registry token |
## Pull-and-Run Deployment ## Pull-and-Run Deployment
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](../CONTRIBUTING.md#versioning-and-releases) for the release procedure). Images are built/pushed automatically by CI and release workflows. Every push to `main` triggers CI (`:main`, `:latest`) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow (`:X.Y.Z`, `:X.Y`, `:latest` + Gitea Release).
Every push to `main` triggers CI (builds `:main` and `:latest` tags) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow which builds `:X.Y.Z`, `:X.Y`, and `:latest` tags and creates a Gitea Release.
From repository root:
```bash ```bash
docker login git.allucanget.biz docker login git.allucanget.biz
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans docker compose -f docker-compose.prod.yml up -d --remove-orphans
``` ```
## First-Time or Recovery Provisioning ## First-Time or Recovery Provisioning
1. Ensure host paths exist:
```bash ```bash
mkdir -p data/chat data/playlist data/media data/hls mkdir -p data/chat data/playlist data/media data/hls
```
1. Confirm volumes and networks:
```bash
docker volume ls docker volume ls
docker network ls docker network ls
```
1. Start stack:
```bash
docker compose -f docker-compose.prod.yml up -d docker compose -f docker-compose.prod.yml up -d
``` ```
## Health Verification ## Health Verification
After startup, check service status:
```bash ```bash
docker compose -f docker-compose.prod.yml ps docker compose -f docker-compose.prod.yml ps
``` ```
Endpoint checks: Endpoint checks:
- App: `http://{host}:8080/`
- App: <http://{host}:8080/> - Nginx health: `http://{host}:8080/health`
- Nginx health: <http://{host}:8080/health> - Chat health: `http://{host}:3000/health`
- Chat health: <http://{host}:3000/health> - Playout health: `http://{host}:8081/health`
- Playout health: <http://{host}:8081/health> - Prometheus: `http://{host}:9090`
- Prometheus: <http://{host}:9090> - Grafana: `http://{host}:3001`
- Grafana: <http://{host}:3001>
Domain checks: Domain checks:
- `https://linearvoid.tv`
- <https://linearvoid.tv> - `https://linearvoid.tv/admin`
- <https://linearvoid.tv/admin> - `https://linearvoid.tv/metrics`
- <https://linearvoid.tv/metrics>
## Zero/Low-Downtime Update Pattern ## Zero/Low-Downtime Update Pattern
1. Pull newest images. 1. Pull newest images.
1. Recreate changed services only: 2. Recreate changed services only:
```bash ```bash
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d nginx chat playout docker compose -f docker-compose.prod.yml up -d nginx chat playout
``` ```
3. Re-run health checks.
1. Re-run health checks.
## Rollback ## Rollback
If new images are unhealthy: If new images are unhealthy:
1. Pin previous tags in `docker-compose.prod.yml`.
1. Pin previous tags in docker-compose.prod.yml.
2. Pull pinned tags. 2. Pull pinned tags.
3. Restart impacted services. 3. Restart impacted services:
```bash ```bash
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d nginx chat playout docker compose -f docker-compose.prod.yml up -d nginx chat playout
``` ```
## Operational Tasks ## Operational Tasks
### View logs
```bash ```bash
# View logs
docker compose -f docker-compose.prod.yml logs -f nginx chat playout docker compose -f docker-compose.prod.yml logs -f nginx chat playout
```
### Restart one service # Restart one service
```bash
docker compose -f docker-compose.prod.yml restart chat docker compose -f docker-compose.prod.yml restart chat
```
### Stop stack # Stop stack (keep data)
```bash
docker compose -f docker-compose.prod.yml down
```
### Keep volumes (recommended) vs remove volumes (destructive)
```bash
# keep data
docker compose -f docker-compose.prod.yml down docker compose -f docker-compose.prod.yml down
# destructive (remove data) # Stop stack — destructive (remove data)
docker compose -f docker-compose.prod.yml down -v docker compose -f docker-compose.prod.yml down -v
``` ```
## Nginx and Proxy Considerations ## Nginx and Proxy Considerations
- If an external proxy (e.g. Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx (see `infrastructure/nginx/nginx.conf`).
- If an external proxy (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx, see [nginx.conf](../infrastructure/nginx/nginx.conf). - For resumable admin uploads, ensure `client_max_body_size` supports chunk requests in both proxy layers.
- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
## Security Baseline ## Security Baseline
- Use a strong `ADMIN_PASSWORD` and rotate regularly.
- Use strong ADMIN_PASSWORD and rotate regularly.
- Keep Docker host access restricted. - Keep Docker host access restricted.
- Prefer HTTPS/WSS through Cloudflare. - Prefer HTTPS/WSS through Cloudflare.
- Restrict exposed ports if only proxy access is needed. - Restrict exposed ports if only proxy access is needed.
## Common Deployment Pitfalls ## Common Deployment Pitfalls
- Missing frontend build artifacts in local-only compose workflows. - Missing frontend build artifacts in local-only compose workflows.
- Wrong registry credentials or missing login. - Wrong registry credentials or missing login.
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors. - Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
## Troubleshooting ## Troubleshooting
| Symptom | Check | Fix |
|---|---|---|
| Service not starting | `docker logs linearvoid-<svc> --tail 50` | Check `.env` vars, volume mounts, port conflicts |
| Playout stall / slate mode | `curl http://<host>:8081/health` — mode=slate | Check playlist file, media paths, disk space |
| Chat not connecting | `curl http://<host>:3000/health` | Check Redis: `docker exec linearvoid-redis redis-cli ping` |
| Admin 401 loop | Cookie not sent | Verify `X-Forwarded-Proto: https`, nginx auth_request headers |
| Upload fails (413) | File too large | Check nginx `client_max_body_size` (should be 64m+) |
| HLS not playing | `curl http://<host>/hls/playlist.m3u8` | Check playout health, HLS dir permissions |
| Prometheus no targets | `curl http://<host>:9090/targets` | Verify container network, scrape config |
| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
| Symptom | Check | Fix | Additional notes: proxy-layer body size limits can return 413 on upload; health checks may fail before service warmup completes.
| -------------------------- | --------------------------------------------- | ------------------------------------------------------------- |
| Service not starting | `docker logs linearvoid-<svc> --tail 50` | Check `.env` vars, volume mounts, port conflicts |
| Playout stall / slate mode | `curl http://<host>:8081/health` — mode=slate | Check playlist file, media paths, disk space |
| Chat not connecting | `curl http://<host>:3000/health` | Check Redis: `docker exec linearvoid-redis redis-cli ping` |
| Admin 401 loop | Cookie not sent | Verify `X-Forwarded-Proto: https`, nginx auth_request headers |
| Upload fails (413) | File too large | Check nginx `client_max_body_size` (should be 64m+) |
| HLS not playing | `curl http://<host>/hls/playlist.m3u8` | Check playout health, HLS dir permissions |
| Prometheus no targets | `curl http://<host>:9090/targets` | Verify container network, scrape config |
| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
- Proxy-layer body size limits returning 413 on upload.
- Health checks failing before service warmup is complete.
+4 -12
View File
@@ -52,7 +52,7 @@ Config: `monitoring/prometheus/prometheus.yml` (15s scrape interval)
- **Linear Void app**: `monitoring/grafana/dashboard.json` — clients, messages, moderation, Redis, sessions, scrape health - **Linear Void app**: `monitoring/grafana/dashboard.json` — clients, messages, moderation, Redis, sessions, scrape health
- **Prometheus ops**: `monitoring/grafana/dashboards/dashboard.json` — TSDB, CPU/memory, WAL, compactions, query latency - **Prometheus ops**: `monitoring/grafana/dashboards/dashboard.json` — TSDB, CPU/memory, WAL, compactions, query latency
## Metrics checking ## Metrics Checking
See detailed scripts for querying Prometheus scrape targets: See detailed scripts for querying Prometheus scrape targets:
@@ -78,21 +78,13 @@ See detailed scripts for querying Prometheus scrape targets:
| Prometheus targets down | Check container network: all must be on `linearvoid-net` | | Prometheus targets down | Check container network: all must be on `linearvoid-net` |
| Alert not firing | Check Prometheus → Alerts tab, verify `rule_files` in prometheus.yml | | Alert not firing | Check Prometheus → Alerts tab, verify `rule_files` in prometheus.yml |
### Grafana shows no data **Grafana no data**: ensure datasource points to `http://prometheus:9090`; confirm Prometheus is healthy; check dashboard query labels match active jobs.
- Ensure datasource points to <http://prometheus:9090>. **High cardinality / slow queries**: reduce unneeded labels; limit expensive panels; tune retention and query windows.
- Confirm Prometheus is healthy.
- Check dashboard query labels match active jobs.
### High cardinality or query slowness
- Reduce unneeded labels.
- Limit expensive dashboard panels.
- Tune retention and query windows.
## Operational Recommendations ## Operational Recommendations
- Keep alert thresholds tied to actual user impact. - Keep alert thresholds tied to actual user impact.
- Review dashboards after each release that changes endpoints or metrics names. - Review dashboards after each release that changes endpoints or metrics names.
- Periodically test alert routing and incident response flow. - Periodically test alert routing and incident response flow.
- Metrics retention is set to **30 days** time-based plus a **15GB** size cap (`--storage.tsdb.retention.time=30d --storage.tsdb.retention.size=15GB` on the `prometheus` service in `docker-compose.yml` and `docker-compose.prod.yml`). Increase the size cap before extending the time window so the TSDB does not hit the disk limit first. - Retention: **30 days** time-based plus **15GB** size cap (`--storage.tsdb.retention.time=30d --storage.tsdb.retention.size=15GB` on the `prometheus` service in `docker-compose.yml` and `docker-compose.prod.yml`). Increase the size cap before extending the time window so the TSDB does not hit the disk limit first.
+55 -55
View File
@@ -1,55 +1,55 @@
# Docs Folder # 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. 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](architecture/README.md) - High-level system architecture and component interactions. - [Architecture](architecture/README.md) - High-level system architecture and component interactions.
## Infrastructure ## Infrastructure
- Cloudflare - Cloudflare
- [Cache Rules](./infrastructure/cloudflare/cache-rules.md) - Cache rules for Cloudflare Workers. - [Cache Rules](./infrastructure/cloudflare/cache-rules.md) - Cache rules for Cloudflare Workers.
- [Account Troubleshooting](./infrastructure/cloudflare/cloudflare-account-troubleshooting.md) - Troubleshooting Cloudflare account issues. - [Account Troubleshooting](./infrastructure/cloudflare/cloudflare-account-troubleshooting.md) - Troubleshooting Cloudflare account issues.
- Monitoring - Monitoring
- [Prometheus CURL](./infrastructure/monitoring/prometheus_curl.md) - Prometheus metrics endpoint testing with curl and jq. Doocumentation of available metrics and their meanings. - [Prometheus CURL](./infrastructure/monitoring/prometheus_curl.md) - Prometheus metrics endpoint testing with curl and jq. Doocumentation of available metrics and their meanings.
## API ## API
- [API](API.md) - Implemented WebSocket and REST API reference. - [API](API.md) - Implemented WebSocket and REST API reference.
## Deployment ## Deployment
- [Deployment](DEPLOYMENT.md) - Production deployment and operational runbook. - [Deployment](DEPLOYMENT.md) - Production deployment and operational runbook.
## Monitoring ## Monitoring
- [Monitoring](MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting. - [Monitoring](MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
## Security ## Security
- [Security](SECURITY.md) - Security model, controls, and hardening baseline. - [Security](SECURITY.md) - Security model, controls, and hardening baseline.
## Setup ## Setup
- [Setup](SETUP.md) - Test lab setup, local development, and CI/CD workflow. - [Setup](SETUP.md) - Test lab setup, local development, and CI/CD workflow.
## Streaming ## Streaming
- [Streaming](STREAMING.md) - Streaming architecture, playout, and delivery details. - [Streaming](STREAMING.md) - Streaming architecture, playout, and delivery details.
## Additional Resources ## Additional Resources
- [Marketing](marketing/README.md) - Marketing materials and resources. - [Marketing](marketing/README.md) - Marketing materials and resources.
- [Operations](operations/README.md) - Operational procedures and checklists. - [Operations](operations/README.md) - Operational procedures and checklists.
- [Incident Runbook](incident-runbook.md) - Incident response and disaster recovery procedures. - [Incident Runbook](incident-runbook.md) - Incident response and disaster recovery procedures.
## Other Documents ## Other Documents
- [CHANGELOG](../CHANGELOG.md) - Release notes and version history. - [CHANGELOG](../CHANGELOG.md) - Release notes and version history.
- [CONTRIBUTING](../CONTRIBUTING.md) - Contribution guidelines and code of conduct. - [CONTRIBUTING](../CONTRIBUTING.md) - Contribution guidelines and code of conduct.
- [TODO](../.github/instructions/TODO.md) - Active implementation plan with checkboxes. - [TODO](../.github/instructions/TODO.md) - Active implementation plan with checkboxes.
- [Backlog](../.github/instructions/BACKLOG.md) - Consolidated list of all development tasks. - [Backlog](../.github/instructions/BACKLOG.md) - Consolidated list of all development tasks.
- [Agents](../.github/AGENTS.md) - Instructions for agents and task management. - [Agents](../.github/AGENTS.md) - Instructions for agents and task management.
- [Memory](../.github/MEMORY.md) - Session memory and development progress tracking. - [Memory](../.github/MEMORY.md) - Session memory and development progress tracking.
- [README](../README.md) - Project overview and high-level information. - [README](../README.md) - Project overview and high-level information.
+80 -503
View File
@@ -2,572 +2,149 @@
**Last Updated**: 2026-08-03 **Last Updated**: 2026-08-03
This guide covers prerequisites, environment setup, running the application locally and in production, and troubleshooting. It references the [architecture documentation](./docs/architecture/README.md)(`docs/architecture/README.md`). Prerequisites, environment setup, and running the app locally and in production. Architecture: [`docs/architecture/README.md`](./architecture/README.md).
## Prerequisites ## Prerequisites
- **Docker & Docker Compose**: Required for running the full stack (playout, chat, nginx, redis, db, prometheus, grafana). Install from [docker.com](https://www.docker.com). - **Docker + Docker Compose** — runs the full stack (playout, chat, nginx, redis, db, prometheus, grafana).
- **Node.js 22+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`). - **Node.js 22+ / npm** — frontend dev and builds (`cd frontend && npm install && npm run build`).
- **npm**: Installed with Node.js and used for the frontend dependency installation and Vite workflow. - **Go 1.25+** — working on the Go services outside Docker.
- **Go 1.25+**: Recommended when working on the Go services outside Docker (`backend/chat`, `backend/playout`). - **FFmpeg** — playout HLS generation (`apt install ffmpeg`).
- **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows). - **Git**.
- **Git**: For cloning and version control. - **Cloudflare account** — R2 storage + CDN.
- **Cloudflare Account**: For R2 storage and CDN (free tier sufficient for most use cases). - **Gitea account** — image registry + CI/CD. Set Actions secrets at `https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets`: `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN` (scope `write:packages`), `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `DATABASE_URL`.
- **Gitea Account**: Required if you want to push images to the self-hosted registry or use the repository's CI/CD flow. - **Hetzner Cloud (optional, prod)** — US-East VPS (Ashburn, VA per ADR-001).
- Set up the required Actions secrets in Gitea at: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
- Add the following secrets:
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`)
- `CI_REGISTRY_TOKEN`: A Gitea Personal Access Token with `write:packages` scope
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`)
- `ADMIN_PASSWORD`: A strong password for the production admin panel
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`)
- **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001).
## Network Environments ## Network Environments
Linear Void operates in two distinct environments. Know which one you're targeting. Two distinct targets; know which one you're using.
```mermaid ```mermaid
flowchart LR flowchart LR
subgraph Test_Lab["Test Lab (192.168.88.0/24)"] subgraph Test_Lab["Test Lab (192.168.88.0/24)"]
TEST_HOST["192.168.88.40<br/>Docker Engine :2375"] TEST_HOST["192.168.88.40<br/>Docker Engine :2375"]
end end
subgraph Prod["Production (Hetzner — NOT provisioned)"] subgraph Prod["Production (Hetzner — NOT provisioned)"]
PROD_HOST["Hetzner CX22 Ashburn, VA<br/>Public IP TBD"] PROD_HOST["Hetzner CX22 Ashburn, VA<br/>Public IP TBD"]
end end
subgraph Gitea["Gitea CI/CD"] subgraph Gitea["Gitea CI/CD"]
CI["CI: build + push images"] CI["CI: build + push images"]
CD["CD: deploy via DOCKER_HOST"] CD["CD: deploy via DOCKER_HOST"]
end end
subgraph CF["Cloudflare"] subgraph CF["Cloudflare"]
DNS["linearvoid.tv"] DNS["linearvoid.tv"]
R2["R2 Bucket"] R2["R2 Bucket"]
end end
CD -->|"DOCKER_HOST=tcp://192.168.88.40:2375"| TEST_HOST CD -->|"DOCKER_HOST=tcp://192.168.88.40:2375"| TEST_HOST
CD -.->|"DOCKER_HOST=tcp://&lt;hetzner-ip&gt;:2375<br/>(when provisioned)"| PROD_HOST CD -.->|"DOCKER_HOST=tcp://&lt;hetzner-ip&gt;:2375 (when provisioned)"| PROD_HOST
TEST_HOST -->|"test traffic"| DNS TEST_HOST -->|"test traffic"| DNS
PROD_HOST -.->|"production traffic"| DNS PROD_HOST -.->|"production traffic"| DNS
TEST_HOST --> R2 TEST_HOST --> R2
PROD_HOST -.-> R2 PROD_HOST -.-> R2
``` ```
**Test Lab (active):** Internal network `192.168.88.0/24`, host `192.168.88.40`. Receives automated CD deploys from Gitea Actions. Used for integration testing and staging validation. - **Test Lab (active):** internal `192.168.88.0/24`, host `192.168.88.40`; receives automated CD deploys from Gitea Actions. Used for staging validation.
- **Production (pending):** Hetzner CX22, Ashburn VA — not yet provisioned. Compose/CD are ready; only `DOCKER_HOST` needs changing. See [DEPLOYMENT.md](./DEPLOYMENT.md).
**Production (pending):** Hetzner Cloud VPS, Ashburn VA. Not yet provisioned. CD workflow and compose files are ready — only `DOCKER_HOST` needs changing. See Production Deployment section in [DEPLOYMENT.md](./DEPLOYMENT.md) for cutover plan.
## Development Prerequisites
- **Frontend dependencies**: Run `cd frontend && npm install` before `npm run dev` or `npm run build`.
- **Docker build support**: Local development expects Docker to be able to build the `chat`, `playout`, and `nginx` images referenced in `docker-compose.yml`.
- **Data directories**: Create `data/playlist`, `data/media`, `data/hls`, and `data/chat` before the first run so bind mounts resolve cleanly.
- **Database choice**: The stack defaults to PostgreSQL in `docker-compose.yml`. If you want SQLite for local-only development, update `DATABASE_URL` in `.env` accordingly.
- **Redis availability**: Chat presence, rate limiting, and pub/sub require Redis. The compose stack provides it automatically.
- **Video inputs**: Source videos should be normalized before playout. If the playlist contains invalid files, the playout container will fail its validation flow.
## Environment Setup ## Environment Setup
1. **Clone the Repository** (if not already done): 1. `git clone https://git.allucanget.biz/zwitschi/linearvoid.git && cd linearvoid`
2. `cp .env.example .env` then replace placeholders.
```bash 3. Compose overrides: `docker-compose.yml` builds images locally and mounts `frontend/dist` into nginx; `docker-compose.prod.yml` pulls prebuilt images from Gitea.
git clone https://git.allucanget.biz/zwitschi/linearvoid.git - Internal URLs use compose hostnames: `REDIS_URL=redis://redis:6379/0`, `DATABASE_URL=postgres://linearvoid:<pw>@db:5432/linearvoid?sslmode=disable`.
cd linearvoid - Public routing: `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
``` 4. Create data dirs: `mkdir -p data/playlist data/media data/hls data/chat`
5. Optional: place normalized MP4s in `data/media/` and create `data/playlist/playlist.txt` (see ADR-001 for normalization).
2. **Copy Environment File**:
```bash
cp .env.example .env
```
3. **Configure `.env`** (copy from `.env.example` and replace placeholders)
4. **Check local value overrides for Docker Compose**:
- `docker-compose.yml` builds images locally and maps the frontend build output from `frontend/dist` into nginx.
- `docker-compose.prod.yml` pulls prebuilt images from the Gitea registry.
- Internal container-to-container URLs should use compose hostnames, for example `REDIS_URL=redis://redis:6379/0` and `DATABASE_URL=postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`.
- For production public routing, set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
5. **Create Data Directories**:
```bash
mkdir -p data/playlist data/media data/hls data/chat
```
6. **Prepare Sample Videos** (optional):
- Place normalized MP4 files in `data/media/`.
- Create a `data/playlist/playlist.txt` with paths (see ADR-001 for normalization requirements).
## Gitea Setup ## Gitea Setup
1. **Create or verify a Gitea user**: 1. User with repo + registry access; generate a token (scope `write:packages`) stored as `CI_REGISTRY_TOKEN`.
- The user should have access to the repository and permission to read from the container registry. 2. Set Actions secrets (see Prerequisites) to enable automated CI/CD and deploys.
3. Set registry vars: `CI_REGISTRY_URL`, `CI_REGISTRY_NAMESPACE` (e.g. `zwitschi/linearvoid`), `CI_REGISTRY_USER`.
2. **Create an access token**: 4. Auth: `docker login git.allucanget.biz`.
- Generate a token with the scopes needed for package/registry access and, if applicable, repository automation. 5. Local dev builds from source; prod pulls `playout`, `chat`, `nginx` images from the registry namespace. Verify `:latest` tags exist before rollout.
- Store it as `CI_REGISTRY_TOKEN` in `.env`.
3. **Configure Gitea Actions Secrets**:
- Go to the repository settings page: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
- Add the following secrets to enable automated CI/CD and production deployment:
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`).
- `CI_REGISTRY_TOKEN`: Your Gitea Personal Access Token with `write:packages` scope.
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`).
- `ADMIN_PASSWORD`: A strong password for the production admin panel.
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`).
4. **Configure registry variables**:
- Set `CI_REGISTRY_URL` to the Gitea host.
- Set `CI_REGISTRY_NAMESPACE` to the image namespace, for example `zwitschi/linearvoid`.
- Set `CI_REGISTRY_USER` to the account that will push or pull images.
5. **Authenticate Docker to the registry**:
```bash
docker login git.allucanget.biz
```
6. **Build and publish images when needed**:
- Local development uses `docker-compose.yml`, which builds from source.
- Production uses `docker-compose.prod.yml`, which expects the `playout`, `chat`, and `nginx` images to exist in the configured Gitea registry namespace.
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 ## Cloudflare R2 Setup
1. **Create an R2 bucket**: 1. Create R2 bucket (e.g. `linearvoid-media`, matching `R2_BUCKET_NAME`); generate an access key pair → `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_REGION=auto`.
- Create a bucket such as `linearvoid-media` in Cloudflare R2. 2. S3 endpoint: `https://<account-id>.r2.cloudflarestorage.com` → `R2_ENDPOINT`.
- Keep the bucket name aligned with `R2_BUCKET_NAME` in `.env`. 3. Attach `media.linearvoid.tv` as custom media domain → `MEDIA_DOMAIN`; add CORS allowing `https://linearvoid.tv` GET for HLS delivery.
4. Point `linearvoid.tv` (+ `www` redirect) at the app entrypoint; keep `media.*` on R2. Proxied DNS, TLS **Full (strict)**.
2. **Generate R2 API credentials**: 5. Apply the `.m4s`/`.m3u8` cache rules from [`cache-rules.md`](./infrastructure/cloudflare/cache-rules.md); enable Smart Tiered Cache; optionally deploy `infrastructure/cloudflare/worker.js` for header normalization.
- Create an access key pair with read/write permissions for the bucket. 6. Verify: `aws s3 ls --endpoint-url $R2_ENDPOINT`; check cache hit ratio after serving traffic.
- 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 [cache-rules](../docs/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. **Configure R2 CORS**:
- Add CORS rules to allow the app domain to access the R2 bucket for HLS delivery.
- Example CORS configuration:
```json
[
{
"AllowedOrigins": ["https://linearvoid.tv"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Cache-Control"],
"MaxAgeSeconds": 3600
}
]
```
8. **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, Workers, cache, security, and monitoring).
Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
### 1. DNS Setup
1. Add apex record for `linearvoid.tv` pointing to the Cloudflare Worker.
2. Add `www` CNAME to the Cloudflare Worker 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. Configure CORS rules to allow `https://linearvoid.tv` for HLS delivery.
5. 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: [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
### 4. Cloudflare Workers
Also see [the automated Gitea Actions deployment guide](./operations/gitea-cloudflare-workers.md).
Manual setup:
1. Create a Cloudflare Worker for the static SPA (Workers & Pages → Create → Worker).
2. Build the frontend:
```bash
cd frontend && npm run build
```
3. Bind the build output (`frontend/dist`) as Worker static assets.
4. Configure production domain `linearvoid.tv` on the Worker.
### 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. Workers serve 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 ## Running the Application
### Local Development ### Local development
1. **Start All Services**: ```bash
docker compose up --build # start all services
docker compose down # stop
```
```bash Access: app/admin `https://linearvoid.tv` (via proxy) or `http://localhost`; WS `wss://linearvoid.tv/ws` / `ws://localhost/ws`. Health: playout `:8081/health`, chat `:3000/health`. Monitoring: Prometheus `:9090`, Grafana `:3001` (default `admin/admin`).
docker compose up --build
```
2. **Access the Application**: Frontend dev (optional): `cd frontend && npm install && npm run dev` (http://localhost:5173, proxies API). For nginx-backed local runs: `npm run build` (nginx serves `frontend/dist`).
- Preferred local access with the documented proxy setup: <https://linearvoid.tv>
- Admin Interface: <https://linearvoid.tv/admin> (redirects to `/admin/login/` until authenticated)
- Chat WebSocket: `wss://linearvoid.tv/ws`
- Direct-local fallback when bypassing Cloudflare/Nginx Proxy Manager: <http://localhost>, <http://localhost/admin>, and `ws://localhost/ws`
- Health Checks:
- Playout: <http://localhost:8081/health>
- Chat: <http://localhost:3000/health>
- Monitoring:
- Prometheus: <http://localhost:9090>
- Grafana: <http://localhost:3001> (default login: admin/admin)
3. **Frontend Development** (optional, for UI changes): ### Test lab (remote Docker host)
```bash Deploy to `192.168.88.40` over Docker's TCP socket (port `2375`).
cd frontend
npm install
npm run dev
```
The dev server runs on <http://localhost:5173>. It proxies API calls to the backend services. Enable TCP on Alpine host — edit `/etc/conf.d/docker`:
4. **Build frontend assets for nginx-backed local runs**: ```bash
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
```
```bash Then `rc-service docker restart`; verify with `netstat -tlnp | grep 2375`; allow port `2375` from `192.168.88.0/24` in the firewall.
cd frontend
npm install
npm run build
```
The local nginx container serves `frontend/dist`, so rebuilding the frontend is required before validating production-like static output. Deploy from a machine with the Docker CLI:
5. **Stop Services**: ```bash
export DOCKER_HOST="tcp://192.168.88.40:2375"
docker info # confirm remote host
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans # registry images
# or build from source: docker compose up -d --build
unset DOCKER_HOST # back to local
```
```bash Health: `http://192.168.88.40:8081/health` (playout), `:3000/health` (chat), nginx entrypoint `:8080`.
docker compose down
```
### Local Test Environment Deployment (Remote Docker Host) ### Coolify deployment
For testing deployments on a local network server without setting up a full production VPS, you can deploy directly to a remote Docker host (e.g., a home lab server or test machine) using Docker's TCP socket. Coolify replaces the Gitea CD `DOCKER_HOST` orchestration (Cloudflare DNS/R2/cache unchanged). Install (`curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash`), add the target server via SSH, create a Docker Compose resource from `docker-compose.prod.yml` (or the repo), configure env vars in Coolify's store, map persistent `data/` and named volumes, and assign `linearvoid.tv` to nginx for Traefik TLS. Full walkthrough: [`COOLIFY.md`](./COOLIFY.md).
#### Test Environment Prerequisites ### Production setup
- A remote server running Docker at IP `192.168.88.40` (running Alpine Linux). 1. Provision a Linux VPS (Docker, Compose, Git); open HTTP/HTTPS ports.
- Docker TCP socket enabled and listening on port `2375` on the remote server. 2. `git clone … && cd linearvoid && cp .env.example .env`.
3. Set production `.env`: strong `POSTGRES_PASSWORD`/`GRAFANA_PASSWORD`, compose-hostname internal URLs, `MEDIA_DOMAIN=media.linearvoid.tv`, public `VITE_PUBLIC_*` URLs. For local-only testing bypassing the proxy, use `http://localhost`/`ws://localhost/ws`/`http://localhost/hls/playlist.m3u8`.
4. `mkdir -p data/playlist data/media data/hls data/chat`.
5. Ensure `playout`, `chat`, `nginx` images exist in the registry; `docker login` from the VPS.
6. Deploy: `docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d`.
7. Verify: `docker compose -f docker-compose.prod.yml ps`, health endpoints, HLS written to `data/hls`, media domain serves via Cloudflare.
8. Harden: HTTPS in front of nginx (Cloudflare, `www` → apex), restrict Grafana/Prometheus to trusted IPs, run under systemd for auto-start.
##### Enabling Docker TCP Socket on Alpine Linux ### Production (Hetzner VPS + Cloudflare)
To allow the Gitea Actions runner (or your local machine) to deploy to the remote Alpine host, you must configure the Docker daemon to listen on the TCP socket: 1. Provision CX22 in Ashburn, VA (ADR-001).
2. Cloudflare: R2 bucket (`enam` hint), `media.linearvoid.tv` custom domain + cache rules, `linearvoid.tv`/`www` at the app entrypoint with `www`→apex redirect, Full (strict) TLS, deploy `infrastructure/cloudflare/worker.js`.
3. Deploy from the project: `docker compose -f docker-compose.prod.yml up -d` (or pull from Gitea registry); manage under systemd.
4. Monitoring: Grafana dashboards from `monitoring/grafana/`.
1. Edit `/etc/conf.d/docker` on the Alpine host: See `docs/architecture/README.md` (arc42) and the ADRs for decisions and cost/scaling guidance (ADR-001).
```bash
vi /etc/conf.d/docker
```
2. Update the `DOCKER_OPTS` line to include both the unix socket and the TCP socket:
```bash
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
```
3. Restart the Docker service:
```bash
rc-service docker restart
```
4. Verify that Docker is listening on port `2375`:
```bash
netstat -tlnp | grep 2375
```
5. Ensure any firewall (like `awall` or `iptables`) on the Alpine host allows incoming traffic on port `2375` from your local network subnet (e.g., `192.168.88.0/24`).
#### Deployment Steps
1. **Configure Docker CLI to use the Remote Host**:
You can point your local Docker CLI to the remote server by setting the `DOCKER_HOST` environment variable:
- **PowerShell (Windows)**:
```powershell
$env:DOCKER_HOST="tcp://192.168.88.40:2375"
```
- **Bash (Linux/macOS)**:
```bash
export DOCKER_HOST="tcp://192.168.88.40:2375"
```
2. **Verify Connection**:
Run `docker info` to confirm your local CLI is communicating with the remote server at `192.168.88.40`:
```bash
docker info
```
The output should show the remote server's hostname and system details.
3. **Deploy the Stack**:
With `DOCKER_HOST` set, any `docker` or `docker compose` commands you run locally will execute directly on the remote server.
- **Deploy from Registry (Production-like)**:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
```
- **Build and Deploy from Source**:
```bash
docker compose up -d --build
```
4. **Access the Test Environment**:
- Playout Health: `http://192.168.88.40:8081/health`
- Chat Health: `http://192.168.88.40:3000/health`
- Nginx Entrypoint: `http://192.168.88.40:8080` (or the port mapped to `NGINX_PORT` in `.env`)
5. **Reset to Local Docker Host**:
Once testing is complete, clear the `DOCKER_HOST` variable to point back to your local machine:
- **PowerShell**:
```powershell
Remove-Item env:DOCKER_HOST
```
- **Bash**:
```bash
unset DOCKER_HOST
```
### Coolify Deployment
Coolify is an open-source, self-hostable PaaS (Heroku/Netlify/Vercel
alternative) that can deploy the Linear Void stack through a web UI with
automatic HTTPS and one-click redeploys. It replaces the deployment
orchestration layer (the Gitea CD `DOCKER_HOST` flow) while leaving Cloudflare
DNS, R2 media, and cache rules unchanged.
To deploy via Coolify:
1. **Install Coolify** on a dedicated server:
```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
```
2. **Add the target server** in the dashboard via SSH (or use Localhost if
Coolify runs on the same machine as the stack).
3. **Create a Docker Compose resource** pointing at either the Git repository
(`docker-compose.prod.yml`) or the compose file contents directly.
4. **Configure environment variables** in Coolify's secret store (the values
normally held in `.env`), keeping internal URLs on compose hostnames
(`redis`, `db`).
5. **Map persistent storage** for `data/` mounts and the `redis-data` /
`postgres-data` named volumes so data survives redeploys.
6. **Assign a domain** to the nginx service so Coolify's Traefik proxy
terminates TLS for `linearvoid.tv`, while `media.linearvoid.tv` continues to
serve HLS from Cloudflare R2.
For the full walkthrough, environment-variable table, verification steps, and
cutover notes, see [COOLIFY.md](./COOLIFY.md).
### Production Setup Guide
1. **Prepare the target host**:
- Provision a Linux VPS with Docker Engine, Docker Compose, Git, and enough disk for media, HLS output, PostgreSQL, Redis persistence, and monitoring data.
- Open the ports required for HTTP/HTTPS and any restricted monitoring access you intend to expose.
2. **Clone the repository and create the environment file**:
```bash
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
cd linearvoid
cp .env.example .env
```
3. **Set production-safe `.env` values**:
- Replace placeholder secrets.
- Use compose-network hostnames for internal services, for example `redis` and `db`.
- Set strong values for `POSTGRES_PASSWORD` and `GRAFANA_PASSWORD`.
- Keep `MEDIA_DOMAIN`, R2 credentials, and Gitea registry values consistent with the external infrastructure.
- Set `MEDIA_DOMAIN=media.linearvoid.tv`.
- Set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
- If you intentionally bypass the public proxy for local-only testing, switch those values to `http://localhost`, `ws://localhost/ws`, and `http://localhost/hls/playlist.m3u8`.
4. **Create runtime directories on the server**:
```bash
mkdir -p data/playlist data/media data/hls data/chat
```
5. **Publish or verify required images in Gitea**:
- Ensure the `playout`, `chat`, and `nginx` images exist under `${CI_REGISTRY_URL}/${CI_REGISTRY_NAMESPACE}`.
- Log in to the registry from the VPS before pulling.
6. **Deploy the production stack**:
```bash
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
7. **Run post-deploy verification**:
- Check `docker compose -f docker-compose.prod.yml ps`.
- Confirm nginx, chat, and playout health endpoints respond.
- Verify that HLS output is being written to `data/hls` and that the media domain serves manifests and segments through Cloudflare.
8. **Harden and operate the deployment**:
- Put HTTPS in front of nginx using Cloudflare or another reverse proxy, with `linearvoid.tv` as canonical app host and `www.linearvoid.tv` redirected.
- Restrict Grafana and Prometheus exposure to trusted IPs or a private network.
- Run the stack under systemd or another service manager if you need auto-start behavior outside Docker restart policies.
### Production Deployment (Hetzner VPS + Cloudflare)
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
2. **Configure Cloudflare**:
- Create R2 bucket with `enam` location hint.
- Set up custom media domain (`media.linearvoid.tv`) with the cache rules from [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
- Point `linearvoid.tv` and `www.linearvoid.tv` at the app entrypoint, then redirect `www` to the apex domain.
- Use Full (strict) TLS for both app and media hostnames.
- Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management.
3. **Deploy**:
- Copy the project to the VPS.
- Use production compose: `docker compose -f docker-compose.prod.yml up -d`
- Or pull from Gitea registry: `docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d`
- Configure systemd to manage the Docker Compose stack.
4. **Monitoring**: Access Grafana at the exposed port for dashboards (included in `monitoring/grafana/dashboard.json`).
See `docs/architecture/README.md` for the full arc42 documentation and the ADRs for architectural decisions.
## Troubleshooting ## Troubleshooting
- **R2 Upload Fails**: Check credentials in `.env` and ensure the bucket exists. Verify with `aws s3 ls --endpoint-url=$R2_ENDPOINT`. - **R2 upload fails** — check `.env` creds/bucket; `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
- **Normalization Errors**: Run `backend/playout/start-playout.sh` manually to see ffprobe output. All videos must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001). - **Normalization errors** — run `backend/playout/start-playout.sh` to see ffprobe output. Video must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001).
- **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200. - **Chat won't connect** — check Redis and WS URL; `/health` should return 200.
- **Cache Not Working**: Verify Cloudflare cache rules (`.m4s` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard. - **Cache not working** — verify `.m4s` long-TTL / `.m3u8` short-TTL cache rules and hit ratio.
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use. - **Docker issues** — `docker compose logs`; check port conflicts.
- **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow. - **Admin interface** — `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `ADMIN_SESSION_TTL_HOURS` set; session-based login protects `/admin/` and `/api/admin/*`.
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist. - **Prod pull fails** — `docker login` to Gitea succeeded and image tags exist.
## Further Reading ## Further Reading
- Architecture: `docs/architecture/README.md` (arc42 template with diagrams). Architecture: `docs/architecture/README.md` · Decisions: `docs/architecture/adr/` (ADR-001…007) · Contributing: `CONTRIBUTING.md` · Overview: root `README.md`.
- Decisions: `docs/architecture/adr/` (ADR-001 to ADR-007).
- Contributing: `CONTRIBUTING.md`.
- Project Structure: Root `README.md`.
The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance.
+67 -80
View File
@@ -1,6 +1,6 @@
# Streaming Pipeline # 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`. Operational contract for Linear Void video ingest, playout, publication, caching, and browser playback. Deployment, chat, admin details live in `DEPLOYMENT.md`, `SETUP.md`, `API.md`.
## Production flow ## Production flow
@@ -20,7 +20,7 @@ The playout service is the timeline authority. It reads the ordered playlist, ru
https://media.linearvoid.tv/hls/playlist.m3u8 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`. Nginx `/hls/` is a local/test fallback, not the production origin. The SPA, admin API, and chat use `linearvoid.tv`; media uses `media.linearvoid.tv`.
## Source-media contract ## Source-media contract
@@ -30,12 +30,12 @@ Only validated files may enter the production playlist.
| ---------- | ------------------------------------------------------------------------ | | ---------- | ------------------------------------------------------------------------ |
| Container | MP4 | | Container | MP4 |
| Video | H.264/AVC, `yuv420p` | | Video | H.264/AVC, `yuv420p` |
| Resolution | 1280x720 or 1920x1080; avoid changing resolution within a playlist block | | Resolution | 1280x720 or 1920x1080; avoid resolution changes within a playlist block |
| Frame rate | Constant 30 fps | | Frame rate | Constant 30 fps |
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` | | GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
| Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s | | 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. The ingest gate uses `ffprobe` to reject incompatible codecs, variable frame rate, missing audio, invalid dimensions, or wrong keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID/object key.
Reference normalization: Reference normalization:
@@ -51,124 +51,111 @@ ffmpeg -i input.mov \
## Playlist and timeline ## Playlist and timeline
- `data/playlist/playlist.txt` contains one local media path per line; blank lines and `#` comments are ignored. - `data/playlist/playlist.txt`: one local media path per line; blank lines and `#` comments ignored.
- The service validates the complete playlist before activating a revision. - 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. - Playlist edits are atomic and get 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. - All viewers follow the same live manifest; clients never calculate the schedule independently.
- Schedule metadata must be derived from the same active playlist revision and timeline so “now/next” agrees with the video. - Schedule metadata derives from the same active revision/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. - When the last item ends, playout returns to the first unless an active schedule override says otherwise.
## Playout and HLS packaging ## 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. The playout service wraps FFmpeg, tracks the active item/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 init segment; `.ts` may be supported during migration |
| Segment target | 2 seconds (`HLS_SEGMENT_DURATION=2`) |
| Live window | 12 segments (`HLS_WINDOW_SIZE=12`), ~24 seconds |
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
| Expected latency | 15–35 seconds |
| Setting | Value | Segment boundaries must align with keyframes; timestamps must stay monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new init segment.
| ---------------- | ------------------------------------------------------------------------------------- |
| Protocol | Standard live HLS, not LL-HLS |
| Segment format | fMP4 (`.m4s`) with an initialization segment; `.ts` may be supported during migration |
| Segment target | 2 seconds (`HLS_SEGMENT_DURATION=2`) |
| Live window | 12 segments (`HLS_WINDOW_SIZE=12`), about 24 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. **Segment duration rationale**: 2s lowers latency and keeps the window small (12 × 2s = 24s) while staying above the manifest TTL (1–2s). The 2s GOP (30fps, `-g 60`) already matches the target, so segments cut cleanly on keyframes. Longer (4–6s) raises latency and window depth; shorter than 2s inflates segment/request overhead and R2 Class B ops with little gain.
**Segment duration rationale**: 2s segments were chosen to lower end-to-end latency and keep the live window small (12 × 2s = 24s) while staying well above the manifest TTL (1–2s). The 2s GOP (30fps, `-g 60 -keyint_min 60`) already matches the target, so segments cut cleanly on keyframes. Longer durations (4–6s) increase latency and window depth; shorter than 2s inflates segment/request overhead and R2 Class B operations with little latency gain. Every segment name is globally unique and never reused, including after restart or playlist loop — use a run/session ID plus a monotonically increasing media sequence:
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 ```text
hls/r-20260807T170412Z/init-000001.mp4 hls/r-20260807T170412Z/init-000001.mp4
hls/r-20260807T170412Z/seg-000001234.m4s hls/r-20260807T170412Z/seg-000001234.m4s
``` ```
This naming rule is essential: a URL advertised as immutable must never later contain different bytes. A URL advertised as immutable must never later contain different bytes.
## Safe publication to R2 ## Safe publication to R2
Publication order prevents a manifest from referencing an unavailable segment: Order prevents a manifest referencing an unavailable segment:
1. FFmpeg writes a segment to a temporary local name and closes it. 1. FFmpeg writes a segment to a temp local name and closes it.
2. The publisher uploads the initialization segment, if new, and media segment to their final immutable R2 keys. 2. Publisher uploads the init segment (if new) and media segment to final immutable R2 keys.
3. It verifies successful upload and records object size/checksum. 3. Verify upload success; record object size/checksum.
4. It renders the next media manifest referencing only confirmed objects. 4. Render the next manifest referencing only confirmed objects.
5. It uploads the manifest to a temporary key, then replaces `hls/playlist.m3u8` as the final commit step. 5. Upload the manifest to a temp key, then replace `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. Never upload an actively written segment or publish the manifest first. Use a single publisher (or fencing/leader election) so two workers can't 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. `data/hls/` is regenerable staging. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
## Cache contract ## Cache contract
| Objects | Edge policy | Browser policy | Required headers/behavior | | 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 | | `.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` | | Live `.m3u8` | Cache eligible; edge TTL 2s | 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` | | Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
Further rules: Further rules:
- Do not add query-string cache busters to segment URLs; uniqueness belongs in the path. - No query-string cache busters on segment URLs; uniqueness belongs in the path.
- Cache keys must preserve hostname and full path. Ignore only explicitly approved tracking parameters. - Cache keys preserve hostname and full path; ignore only explicitly approved tracking params.
- HLS responses require `Access-Control-Allow-Origin: https://linearvoid.tv` (or the reviewed origin allowlist), `GET`, `HEAD`, and `OPTIONS` support, and correct MIME types. - HLS responses need `Access-Control-Allow-Origin: https://linearvoid.tv` (or reviewed allowlist), `GET`/`HEAD`/`OPTIONS`, and correct MIME types.
- Enable Smart Tiered Cache for `media.linearvoid.tv` to reduce R2 reads on regional misses. - Enable Smart Tiered Cache for `media.linearvoid.tv` to cut R2 reads on regional misses.
- Purge manifests when necessary, but never purge immutable segments during normal operation. - Purge manifests when necessary, never 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. - Retain expired-window segments at least max manifest TTL + retry/grace time; practical minimum 1 hour; lifecycle deletion may run later per storage policy.
Expected validation: Validation:
```bash ```bash
curl -I https://media.linearvoid.tv/hls/playlist.m3u8 curl -I https://media.linearvoid.tv/hls/playlist.m3u8
curl -I https://media.linearvoid.tv/hls/<segment>.m4s 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. The manifest must refresh within seconds; repeated segment requests should progress `CF-Cache-Status: MISS` → `HIT`, keep the same ETag/content length, and carry the immutable cache header.
## Player behavior ## Player behavior
The SPA uses native HLS where supported and hls.js elsewhere. SPA uses native HLS where supported, hls.js elsewhere.
- Firefox has no native HLS, so it uses the hls.js/MSE path; fMP4 (`.m4s` + init segment) is supported. Autoplay requires `muted` + `playsinline`, which are already set on the `<video>` element.
- Firefox has no native HLS → hls.js/MSE path; fMP4 (`.m4s` + init) is supported. Autoplay needs `muted` + `playsinline`, already set on the `<video>` element.
- Start at the live edge with a conservative buffer; do not seek to playlist start. - 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. - Treat the manifest as the sync source; target ~3 segments behind the newest unless testing supports a smaller margin.
- Recover transient network/media errors with bounded retries and exponential backoff. - Recover transient network/media errors with bounded retries + 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. - On fatal hls.js network error, reload the manifest; on 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. - If tab suspension/drift leaves playback materially behind the target, seek forward rather than play the backlog.
- Surface a clear reconnecting state. Never silently loop stale media. - 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. - Schedule/chat timestamps should account for measured player latency, not wall-clock playout.
## Failure and recovery ## 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. The service retries an item failure with bounded backoff. Past the failure threshold it switches to a pre-normalized looping slate and reports `mode=slate`, periodically retrying normal playout, returning only after a segment and committed manifest publish successfully.
On process or host restart: On process/host restart:
- create a new run/session ID and continue with globally unique segment paths; - create a new run/session ID and continue with unique segment paths;
- resume from the schedule-derived current position when reliable state exists, otherwise start the current playlist item; - resume from schedule-derived current position when reliable state exists, else start the current playlist item;
- never overwrite old segment objects; - never overwrite old segment objects;
- keep the last valid manifest available until the replacement stream is playable; - keep the last valid manifest available until the replacement is playable;
- emit a discontinuity when timeline continuity cannot be guaranteed. - emit a discontinuity when timeline continuity isn't 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. `/health` must report more than "process running": at least mode (`live`/`slate`/`starting`/`degraded`), active item, playlist revision, run ID, last segment sequence/time, last successful R2 publication, consecutive failures, uptime. Mark unhealthy if the manifest or media sequence hasn't advanced for more than two segment durations.
## Monitoring and acceptance checks ## Monitoring and acceptance
Monitor and alert on: Monitor/alert on: manifest age and media-sequence advancement; time since last published segment; FFmpeg restarts and consecutive failures; live/slate mode; R2 upload latency/errors; segment 404/5xx and manifest/segment latency; Cloudflare cache-hit ratio, origin requests, R2 Class B ops; player startup, fatal errors, rebuffer ratio, live-edge drift; local staging disk usage.
- manifest age and media-sequence advancement; A release is streaming-ready only when: one full playlist boundary plays without timestamp errors; the manifest never references a missing object; a second viewer joins the same timeline; cache headers match the table; a forced FFmpeg failure activates slate; and normal playout recovers without reusing an object URL.
- 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 ## Required configuration
@@ -179,14 +166,14 @@ A release is streaming-ready only when one full playlist boundary has played wit
| `HLS_WINDOW_SIZE` | Manifest window; baseline `12` | | `HLS_WINDOW_SIZE` | Manifest window; baseline `12` |
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint | | `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
| `R2_BUCKET_NAME` | Media bucket | | `R2_BUCKET_NAME` | Media bucket |
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, available only to the publisher | | `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, publisher only |
| `R2_KEEP_RUNS` | Number of run folders to retain (default `2`) | | `R2_KEEP_RUNS` | Run folders to retain (default `2`) |
| `R2_MAX_RETENTION_BYTES` | Max bytes retained across run folders (10 GiB) | | `R2_MAX_RETENTION_BYTES` | Max bytes across run folders (10 GiB) |
| `MEDIA_DOMAIN` | Public R2 custom domain | | `MEDIA_DOMAIN` | Public R2 custom domain |
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL | | `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`. Production values: `MEDIA_DOMAIN=media.linearvoid.tv`, `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. Local fallback may use `http://localhost/hls/playlist.m3u8`.
## Explicitly deferred ## 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. ABR ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any requires an ADR and an update to this document.
File diff suppressed because it is too large. Load diff
+57 -126
View File
@@ -4,155 +4,101 @@ Triage order for Linear Void incidents. Work top-to-bottom — each tier depends
## Triage Order ## Triage Order
1. Edge Reachability (Cloudflare) 1. **Edge Reachability** (Cloudflare)
2. Origin Health (VPS/Docker) 2. **Origin Health** (VPS/Docker)
3. Playout Progression (HLS) 3. **Playout Progression** (HLS)
4. Chat Connectivity (WebSocket) 4. **Chat Connectivity** (WebSocket)
5. Data-Plane (Redis/DB) 5. **Data-Plane** (Redis/DB)
## 1. Edge Reachability (Cloudflare) ## 1. Edge Reachability (Cloudflare)
**Symptoms:** `linearvoid.tv` not resolving, SSL errors, 5xx from Cloudflare. **Symptoms:** `linearvoid.tv` not resolving, SSL errors, 5xx from Cloudflare.
```bash ```bash
# Check DNS
dig linearvoid.tv dig linearvoid.tv
dig www.linearvoid.tv dig www.linearvoid.tv
# Check Cloudflare status
curl -I https://linearvoid.tv/health curl -I https://linearvoid.tv/health
``` ```
**Common causes:** **Common causes:** DNS misconfiguration (A record → wrong IP); Cloudflare proxy disabled (grey cloud exposes origin); SSL certificate expired (Cloudflare → SSL/TLS); Cloudflare outage ([cloudflarestatus.com](https://www.cloudflarestatus.com)).
- DNS misconfiguration (A record pointing to wrong IP) **Mitigation:** Verify A record points `linearvoid.tv` → origin IP (proxied, orange cloud). Bypass Cloudflare temporarily:
- Cloudflare proxy disabled (grey cloud → origin exposed directly)
- SSL certificate expired (check Cloudflare dashboard → SSL/TLS)
- Cloudflare outage → check [https://www.cloudflarestatus.com](https://www.cloudflarestatus.com)
**Mitigation:** ```bash
curl -H "Host: linearvoid.tv" http://<origin-ip>/health
- Verify A record: `linearvoid.tv` → origin IP (proxied, orange cloud) ```
- Bypass Cloudflare temporarily: `curl -H "Host: linearvoid.tv" http://<origin-ip>/health`
## 2. Origin Health (VPS/Docker) ## 2. Origin Health (VPS/Docker)
**Symptoms:** Cloudflare returns 502/504, containers down. **Symptoms:** Cloudflare 502/504, containers down.
```bash ```bash
# Remote Docker (test lab)
docker -H tcp://192.168.88.40:2375 ps docker -H tcp://192.168.88.40:2375 ps
docker -H tcp://192.168.88.40:2375 logs linearvoid-nginx --tail 50 docker -H tcp://192.168.88.40:2375 logs linearvoid-nginx --tail 50
# Direct health check
curl http://192.168.88.40/health curl http://192.168.88.40/health
``` ```
**Common causes:** **Common causes:** Host disk full (`df -h`); Docker daemon stopped (`systemctl status docker`); container restart loop (`docker ps -a --filter "status=exited"`); OOM (`dmesg | grep -i oom`).
- Host out of disk space: `df -h`
- Docker daemon stopped: `systemctl status docker`
- Container restart loop: `docker ps -a --filter "status=exited"`
- Resource exhaustion (OOM): `dmesg | grep -i oom`
**Mitigation:** **Mitigation:**
- Restart stack: `docker compose -f docker-compose.prod.yml up -d` ```bash
- Free disk: `docker system prune -f` docker compose -f docker-compose.prod.yml up -d # restart stack
- Check logs per service: `docker logs linearvoid-<service> --tail 100` docker system prune -f # free disk
docker logs linearvoid-<service> --tail 100 # per-service logs
```
## 3. Playout Progression (HLS) ## 3. Playout Progression (HLS)
**Symptoms:** Viewer sees slate or buffering, playlist not advancing. **Symptoms:** Viewer sees slate or buffering, playlist not advancing.
```bash ```bash
# Check playout health curl http://192.168.88.40:8081/health # "mode": "live" or "slate"
curl http://192.168.88.40:8081/health
# Response includes "mode": "live" or "slate"
# Check HLS output
curl -I http://192.168.88.40/hls/playlist.m3u8 curl -I http://192.168.88.40/hls/playlist.m3u8
ls -la data/hls/ ls -la data/hls/
# Check playout logs
docker logs linearvoid-playout --tail 100 docker logs linearvoid-playout --tail 100
``` ```
**Common causes:** **Common causes:** FFmpeg crash → slate fallback (health `degraded`); playlist empty/missing (`cat data/playlist/playlist.txt`); media file missing (`ls data/media/`); disk full → HLS segments can't write.
- FFmpeg crash → slate fallback active (health shows `degraded`) **Mitigation:** Validate playlist entries and media paths; restart playout (`docker compose restart playout`); regenerate demo playlist if empty (see `backend/playout/start-playout.sh`).
- Playlist file empty/missing: `cat data/playlist/playlist.txt`
- Media file missing from playlist: `ls data/media/`
- Disk full → HLS segments can't write
**Mitigation:**
- Verify playlist has valid entries
- Check media files exist at paths in playlist
- Restart playout: `docker compose restart playout`
- Generate demo playlist if empty: see `backend/playout/start-playout.sh`
## 4. Chat Connectivity (WebSocket) ## 4. Chat Connectivity (WebSocket)
**Symptoms:** Chat not loading, messages not sending, presence broken. **Symptoms:** Chat not loading, messages not sending, presence broken.
```bash ```bash
# Check chat health
curl http://192.168.88.40:3000/health curl http://192.168.88.40:3000/health
# Test WebSocket
wscat -c ws://192.168.88.40/ws?username=test&room=main wscat -c ws://192.168.88.40/ws?username=test&room=main
# Check chat logs
docker logs linearvoid-chat --tail 100 docker logs linearvoid-chat --tail 100
``` ```
**Common causes:** **Common causes:** Redis down → presence/rate-limiting degraded (chat still works); DB down → messages not persisting; port not exposed (`docker port linearvoid-chat`).
- Redis down → presence/rate-limiting degraded (chat still works) **Mitigation:** Restart chat (`docker compose restart chat`); check Redis (`docker exec linearvoid-redis redis-cli ping`); check DB (`docker exec linearvoid-db pg_isready -U linearvoid`). Chat degrades gracefully without Redis — look for "Redis connection warning" in logs.
- Database down → messages not persisting
- Port not exposed: `docker port linearvoid-chat`
**Mitigation:**
- Restart chat: `docker compose restart chat`
- Check Redis: `docker exec linearvoid-redis redis-cli ping`
- Check DB: `docker exec linearvoid-db pg_isready -U linearvoid`
- Chat works without Redis (graceful degradation) — check logs for "Redis connection warning"
## 5. Data-Plane (Redis/DB) ## 5. Data-Plane (Redis/DB)
**Symptoms:** Chat works but no persistence/presence, admin stats empty. **Symptoms:** Chat works but no persistence/presence, admin stats empty.
```bash ```bash
# Redis
docker exec linearvoid-redis redis-cli ping docker exec linearvoid-redis redis-cli ping
docker exec linearvoid-redis redis-cli info memory docker exec linearvoid-redis redis-cli info memory
# PostgreSQL
docker exec linearvoid-db pg_isready -U linearvoid docker exec linearvoid-db pg_isready -U linearvoid
docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;" docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
``` ```
**Common causes:** **Common causes:** Redis OOM (check `maxmemory`); PostgreSQL connection limit; disk full on volume mount.
- Redis OOM (check `maxmemory` setting) **Mitigation:** Restart redis (`docker compose restart redis`, AOF persist enabled); restart db (`docker compose restart db`); restore from backup (see `scripts/verify-backups.sh`).
- PostgreSQL connection limit reached
- Disk full on volume mount
**Mitigation:**
- Redis: `docker compose restart redis` (AOF persist enabled)
- PostgreSQL: `docker compose restart db`
- Restore from backup: see `scripts/verify-backups.sh` for procedure
## Escalation ## Escalation
| Severity | When | Action | | Severity | When | Action |
| -------- | ------------------------------------ | -------------------------------------------------- | | -------- | ------------------------------------ | ------------------------------------------- |
| Critical | Slate active >30min, complete outage | Trigger DR plan, provision fallback host | | Critical | Slate active >30min, complete outage | Trigger DR plan, provision fallback host |
| Warning | Redis down, high CPU >30min | Investigate during business hours, restart service | | Warning | Redis down, high CPU >30min | Investigate business hours, restart service |
| Info | Single service blip | Monitor, no action unless recurring | | Info | Single service blip | Monitor; no action unless recurring |
## Post-Incident ## Post-Incident
@@ -163,67 +109,52 @@ docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
## Quarterly Disaster Recovery Drill ## Quarterly Disaster Recovery Drill
Goal: validate recovery targets under controlled host-loss simulation. Validate recovery targets under controlled host-loss simulation: **RPO ≤ 24h, RTO ≤ 60min**.
**Recovery targets:** RPO <= 24h, RTO <= 60min.
### Prerequisites ### Prerequisites
- Latest backup artifacts available from `scripts/backup-db.sh` and `scripts/backup-weekly.sh` - Latest backups from `scripts/backup-db.sh` and `scripts/backup-weekly.sh`
- Operator access to Docker host and DNS controls - Operator access to Docker host and DNS controls
- Current deployment variables available (`.env`, compose file, image tags) - Current deployment variables (`.env`, compose file, image tags)
### Drill Scope ### Scope
- Simulate complete origin loss (all app containers unavailable) - Simulate complete origin loss (all app containers unavailable)
- Rebuild stack on clean host - Rebuild stack on clean host
- Restore DB and media/playlist mounts - Restore DB and media/playlist mounts
- Verify viewer playback, chat, admin auth, monitoring targets - Verify viewer playback, chat, admin auth, monitoring targets
### Drill Steps ### Steps
1. Record drill start timestamp and on-call participants. 1. Record drill start timestamp and on-call participants.
2. Capture pre-drill baseline: 2. Capture pre-drill baseline:
- `curl -fsS http://<origin>/health` ```bash
- `curl -fsS http://<origin>:3000/health` curl -fsS http://<origin>/health
- `curl -fsS http://<origin>:8081/health` curl -fsS http://<origin>:3000/health
3. Simulate origin outage: curl -fsS http://<origin>:8081/health
- `docker compose -f docker-compose.prod.yml down` ```
4. Provision clean recovery host (or clean test-lab VM) and copy deployment bundle. 3. Simulate outage: `docker compose -f docker-compose.prod.yml down`
5. Restore required data paths: 4. Provision clean recovery host (or clean test-lab VM); copy deployment bundle.
- `data/playlist/` 5. Restore data paths: `data/playlist/`, `data/media/`, `data/chat/`, `data/hls/` (optional warm cache).
- `data/media/`
- `data/chat/`
- `data/hls/` (optional warm cache)
6. Restore database from latest backup dump. 6. Restore database from latest backup dump.
7. Start stack: 7. Start stack: `docker compose -f docker-compose.prod.yml up -d`
- `docker compose -f docker-compose.prod.yml up -d` 8. Validate health and runtime:
8. Validate service health and runtime: ```bash
- `docker ps --format "table {{.Names}}\t{{.Status}}"` docker ps --format "table {{.Names}}\t{{.Status}}"
- `curl -fsS http://<origin>/health` curl -fsS http://<origin>/health
- `curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5` curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5
- `curl -fsS http://<origin>:9090/-/ready` curl -fsS http://<origin>:9090/-/ready
9. Validate app behavior: ```
- Viewer SPA loads and HLS plays 9. Validate app behavior: viewer SPA loads and HLS plays; WebSocket chat connects with message round-trip; admin login/session endpoint succeeds.
- WebSocket chat connects and message round-trip works 10. Run backup integrity check: `scripts/verify-backups.sh`
- Admin login/session endpoint returns success 11. Record drill end timestamp; compute measured RTO/RPO.
10. Run backup integrity check:
- `scripts/verify-backups.sh`
11. Record drill end timestamp and compute measured RTO/RPO.
### Pass/Fail Criteria ### Pass/Fail Criteria
- Pass if all core services healthy and functional in <= 60 minutes. - **Pass:** all core services healthy and functional in ≤ 60 min.
- Pass if latest recoverable data point is <= 24 hours old. - **Pass:** latest recoverable data point ≤ 24 h old.
- Fail if any critical path remains degraded (playout, chat, admin auth, monitoring). - **Fail:** any critical path remains degraded (playout, chat, admin auth, monitoring).
### Evidence Template ### Evidence Template
Document each drill in incident tracker with: Record in incident tracker: 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.
- 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
@@ -6,19 +6,19 @@ Use `wrangler whoami` to verify the account ID and API token are valid, or query
## Validate Cloudflare account and API token ## Validate Cloudflare account and API token
```powershell ```bash
# Validate Cloudflare account and API token in .env # Validate Cloudflare account and API token in .env
Set-Location -Path "E:\Documents\02-Projects\07-Mentoring\Corey\linearvoid" cd /path/to/linearvoid
Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN' grep -E 'CLOUDFLARE_API_TOKEN|CLOUDFLARE_ACCOUNT_ID' .env
Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID'
# Test API token with wrangler # Test API token with wrangler
npx wrangler whoami npx wrangler whoami
# Or verify with the Workers API directly # Or verify with the Workers API directly
$env:CLOUDFLARE_API_TOKEN = (Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN' | ForEach-Object { $_.Line.Split('=')[1].Trim() }) export CLOUDFLARE_API_TOKEN="$(grep '^CLOUDFLARE_API_TOKEN=' .env | cut -d= -f2-)"
$env:CLOUDFLARE_ACCOUNT_ID = (Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID' | ForEach-Object { $_.Line.Split('=')[1].Trim() }) export CLOUDFLARE_ACCOUNT_ID="$(grep '^CLOUDFLARE_ACCOUNT_ID=' .env | cut -d= -f2-)"
# List all Workers for the account # List all Workers for the account
curl -X GET "https://api.cloudflare.com/client/v4/accounts/$env:CLOUDFLARE_ACCOUNT_ID/workers/scripts" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" curl -X GET "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json"
``` ```
File diff suppressed because it is too large. Load diff
+62 -279
View File
@@ -1,345 +1,156 @@
# Scaling and Performance Playbook # Scaling and Performance Playbook
Purpose: define repeatable method to measure performance, capture baseline data, and decide scaling actions. Purpose: define a repeatable method to measure performance, capture baselines, and decide scaling actions for the "Linear Void" streaming app. Scope: planning/execution for the "Scaling & Performance (planning only)" TODO items.
Scope: planning and execution guide for TODO items under "Scaling & Performance (planning only)".
## 1) Manifest TTL Tuning Plan ## 1) Manifest TTL Tuning Plan
Goal: tune `.m3u8` TTL to about `0.5x` segment duration while keeping playback stable. Goal: tune the `.m3u8` TTL to ~`0.5x` segment duration while keeping playback stable.
Inputs: Inputs: segment duration (`HLS_SEGMENT_DURATION`), current manifest TTL (Cloudflare cache rule), cache outcome distribution (`Cf-Cache-Status`).
- Segment duration (`HLS_SEGMENT_DURATION`)
- Current manifest TTL (Cloudflare cache rule)
- Cache outcome distribution (`Cf-Cache-Status`)
Procedure: Procedure:
1. Start from current manifest TTL (2s). 1. Start from current manifest TTL (2s).
2. Capture 24h baseline headers from repeated `playlist.m3u8` requests. 2. Capture 24h baseline headers from repeated `playlist.m3u8` requests.
3. Test candidate TTLs: 1s, 2s, 3s. 3. Test candidate TTLs: 1s, 2s, 3s.
4. Compare: 4. Compare rebuffer events (player logs), origin request increase/decrease, and HIT ratio changes.
- rebuffer events from player logs 5. Select the lowest TTL that keeps playback stable and origin load acceptable.
- origin request increase/decrease
- HIT ratio changes
5. Select lowest TTL that keeps playback stable and origin load acceptable.
Acceptance: Acceptance: no material increase in rebuffer rate; manifest staleness not observable by users.
- No material increase in rebuffer rate.
- Manifest staleness not observable by users.
## 2) TTFB Baseline Plan ## 2) TTFB Baseline Plan
Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`. Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`.
Suggested regions: Regions: US East (Virginia), US Central, US West (California/Oregon).
- US East (Virginia)
- US Central
- US West (California/Oregon)
Method: Method:
1. Use WebPageTest with repeat view enabled. 1. Use WebPageTest with repeat view enabled.
2. Run at least 5 samples per region. 2. Run at least 5 samples per region.
3. Record median and p95 TTFB. 3. Record median and p95 TTFB.
4. Store results in table (date, region, median, p95, test URL). 4. Store results in a table (date, region, median, p95, test URL).
Acceptance: Acceptance: warm-cache median < 4s in all regions; outliers documented with likely cause.
- Warm-cache median < 4s in all tested regions.
- Outliers documented with likely cause.
### TTFB Baseline Worksheet ### TTFB Baseline Worksheet
Use one row per region; keep the raw WebPageTest result URL with the summary for comparison.
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) / US Central / US West (California/Oregon): Date/time, Test URL `https://linearvoid.tv`, WebPageTest result URL, Median TTFB, p95 TTFB, Notes.
- US East (Virginia) Capture notes: use repeat view for warm-cache; keep the same browser profile and test URL across regions; note any CDN cache-rule changes or deploys between samples.
- 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 ## 3) WebSocket Load-Test Plan
Goal: characterize chat service behavior across 10 -> 100 -> 500 -> 1000 concurrent clients. Goal: characterize chat behavior across 10 → 100 → 500 → 1000 concurrent clients.
Tooling option A (Artillery): Tooling option A (Artillery):
```bash ```bash
npx artillery run scripts/loadtest/ws-chat.yml npx artillery run scripts/loadtest/ws-chat.yml
``` ```
Tooling option B (custom Go harness): Tooling option B (custom Go harness): open N concurrent WS clients, send a message every X seconds, measure round-trip latency and failures.
- open N concurrent WebSocket clients Metrics: p50/p95 message latency; error rate (connect/send/receive); dropped connections; CPU/memory for `chat`, `redis`, `nginx`.
- send message every X seconds
- measure round-trip latency and failures
Metrics to capture: Acceptance: p95 chat latency ≤ 1s at target concurrency tier; error rate ≤ 1% during steady state.
- 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 ### 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/redis/nginx, Raw results location, Conclusion, Next action.
- 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 ## 4) Admin API Benchmark Plan
Goal: measure admin API p95 and identify slow endpoints. Goal: measure admin API p95 and identify slow endpoints.
Tooling option A (`k6`): Endpoints: `/api/admin/overview`, `/api/admin/analytics`, `/api/admin/chat-log`, `/api/admin/users`.
Tooling option A (`k6`):
```bash ```bash
k6 run scripts/loadtest/admin-api.js k6 run scripts/loadtest/admin-api.js
``` ```
Tooling option B (`wrk`): Tooling option B (`wrk`):
```bash ```bash
wrk -t4 -c100 -d60s http://<host>/api/admin/overview wrk -t4 -c100 -d60s http://<host>/api/admin/overview
``` ```
Endpoints: Acceptance: p95 < 1s for primary admin endpoints; slow endpoint list with remediation notes.
- `/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 ### Admin API Benchmark Worksheet
Date/time, Environment, Commit SHA/image tag, Test URL, Tool used, Scenario duration, Concurrency/virtual users, `/api/admin/overview|analytics|chat-log|users` p95, Error rate, Slowest endpoint, Cache or DB notes, Raw results location, Conclusion, Next action.
- 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 ## 5) Cache Hit Ratio Tracking Plan
Goal: track Cloudflare cache hit ratio and alert when below threshold. Goal: track Cloudflare cache hit ratio and alert when below threshold.
Implementation outline: Implementation:
1. Pull Cloudflare GraphQL analytics on schedule. 1. Pull Cloudflare GraphQL analytics on schedule.
2. Export to Prometheus-compatible metric path. 2. Export to a Prometheus-compatible metric path.
3. Add Grafana panel with 5m/1h/24h windows. 3. Add a Grafana panel with 5m/1h/24h windows.
4. Alert when hit ratio < 90% for 15m. 4. Alert when hit ratio < 90% for 15m.
Reference exporter: Reference exporter:
- `scripts/cache-hit-ratio.js` - `scripts/cache-hit-ratio.js`
- `scripts/cache-hit-ratio.ps1` for Windows scheduled tasks / manual runs - `scripts/cache-hit-ratio.sh` for scheduled tasks / manual runs
- Example run: `CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_TAG=... CLOUDFLARE_HOSTNAME=media.linearvoid.tv node scripts/cache-hit-ratio.js` - Example run: `export 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. - Intended output: Prometheus text format on stdout, suitable for cron, a Node wrapper, or a small scrape endpoint.
Metric target: Metric target: `cache_hit_ratio >= 0.90`.
- `cache_hit_ratio >= 0.90`
### Cache Hit Ratio Worksheet ### 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.
- 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 ## 6) Capacity Trigger Rules
Define operational triggers: Operational triggers:
- Cache miss ratio > 10% for 15m - Cache miss ratio > 10% for 15m
- Chat p95 latency > 1s for 5m - Chat p95 latency > 1s for 5m
- Host CPU > 80% for 10m - Host CPU > 80% for 10m
- Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m - Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m
Action mapping: Action mapping:
- Level 1: investigate + restart non-critical components - Level 1: investigate + restart non-critical components
- Level 2: scale service resources (CPU/RAM) - Level 2: scale service resources (CPU/RAM)
- Level 3: activate standby planning path - Level 3: activate standby planning path
### Capacity Trigger Review ### 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.
- 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 ## 7) Phase 2 Service Separation Plan
Objective: separate bottlenecked services while keeping simple ops. Objective: separate bottlenecked services while keeping ops simple.
Boundaries: Boundaries:
- `playout` isolated first (CPU-heavy) - `playout` isolated first (CPU-heavy)
- `chat` + `redis` grouped for latency - `chat` + `redis` grouped for latency
- `db` isolated for IO stability - `db` isolated for IO stability
- monitoring stack optionally separated after sustained load - monitoring stack optionally separated after sustained load
Initial sizing guidance: Initial sizing:
- playout host: 4 vCPU / 8 GB - playout host: 4 vCPU / 8 GB
- chat host: 2-4 vCPU / 4-8 GB - chat host: 2–4 vCPU / 4–8 GB
- db host: 2 vCPU / 4 GB + fast disk - db host: 2 vCPU / 4 GB + fast disk
Exit criteria for Phase 2: Exit criteria: repeated trigger events over 7 days; capacity saturation despite tuning.
- repeated trigger events over 7 days
- capacity saturation despite tuning
### Phase 2 Boundary Worksheet ### 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`/`chat`/`db` target host size, Monitoring separation needed, Data/state migration notes, Implementation owner, Review decision, Next action.
- 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 ## 8) Phase 3 Warm Standby Plan
Objective: second-region resilience with DNS failover. Objective: second-region resilience with DNS failover.
Candidate layout: Layout:
- Primary: Hetzner Ashburn (US-East) - Primary: Hetzner Ashburn (US-East)
- Standby: Hetzner Hillsboro (US-West) - Standby: Hetzner Hillsboro (US-West)
Design: 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).
- 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 ### 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.
- 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 ## 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.
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 ## 10) Execution Order
1. TTFB baseline 1. TTFB baseline
2. WebSocket load profile 2. WebSocket load profile
3. Admin API benchmark 3. Admin API benchmark
@@ -349,14 +160,9 @@ For each benchmark run, capture:
7. Phase 3 standby design review 7. Phase 3 standby design review
## 11) Benchmark Run Sheet ## 11) Benchmark Run Sheet
Use the same worksheet for every baseline/re-test so results stay comparable.
Use the same worksheet for every baseline or re-test so results stay comparable. Preconditions: confirm target environment and commit SHA/image tag; confirm Cloudflare cache rules are active when testing TTFB or manifest TTL; confirm chat, admin, and playout services are healthy before load testing.
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: Run template:
@@ -373,70 +179,47 @@ Run template:
| Next action | | | Next action | |
Suggested command mapping: Suggested command mapping:
- TTFB baseline: WebPageTest repeat-view run against `https://linearvoid.tv`. - 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. - 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`. - Admin API benchmark: `k6 run scripts/loadtest/admin-api.js` or `wrk -t4 -c100 -d60s http://<host>/api/admin/overview`.
## 12) Scaling Decision Gate ## 12) Scaling Decision Gate
Treat the following as the default review gate after any benchmark batch: 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. 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. 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. 3. If CPU, memory, or Redis saturation appears first, treat the Phase 2 service boundary guidance 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. 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: Decision note: keep the smallest change that restores the target metric and record the reason in the run sheet.
- Keep the smallest change that restores the target metric and record the reason in the run sheet.
## 13) Artifact-to-Worksheet Field Mapping ## 13) Artifact-to-Worksheet Field Mapping
Use when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
Use this section when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
### WebSocket worksheet mapping ### WebSocket worksheet mapping
Artifact sources: `artifacts/<timestamp>/ws-chat-report.json`, `artifacts/<timestamp>/ws-chat-summary.txt`.
Artifact sources:
- `artifacts/<timestamp>/ws-chat-report.json`
- `artifacts/<timestamp>/ws-chat-summary.txt`
Field mapping: Field mapping:
- `Tool used` → `artillery` (or `scripts/loadtest/run-loadtests.sh -s ws`)
- `Raw results location` → artifact folder path for this run
- `Concurrency tiers reached` → `ws-chat.yml` phases (`warmup-10`, `sustain-100`, `sustain-500`, `sustain-1000`)
- `Error rate` → `vusers.failure_rate_percent` from `ws-chat-summary.txt`
- `Dropped connections` → derive from `vusers.created - vusers.completed` (use `vusers.failed` as failure signal)
- `p95 message latency` → first latency line under `summary.*` in `ws-chat-summary.txt` containing `p95=...`
- `p50 message latency` → first latency line under `summary.*` in `ws-chat-summary.txt` containing `median=...`
- Worksheet `Tool used` -> `artillery` (or `run-loadtests.ps1 -Scenario ws`) 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.
- 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 ### 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`.
Artifact sources:
- `artifacts/<timestamp>/admin-api-summary.json`
- `artifacts/<timestamp>/admin-api-output.txt`
- `artifacts/<timestamp>/admin-api-endpoint-summary.txt`
Field mapping: Field mapping:
- `Tool used` → `k6` (or `scripts/loadtest/run-loadtests.sh -s admin`)
- `Raw results location` → artifact folder path for this run
- `/api/admin/overview|analytics|chat-log|users` p95 → `overview|analytics|chat_log|users` row `P95Ms` in `admin-api-endpoint-summary.txt`
- `Error rate` → `Global failure rate` in `admin-api-endpoint-summary.txt`
- `Slowest endpoint` → endpoint row with highest `P95Ms`
- Worksheet `Tool used` -> `k6` (or `run-loadtests.ps1 -Scenario admin`) Notes: keep the raw `admin-api-summary.json` file for audits and re-parsing; if an endpoint row is missing, treat it as a test/data issue and re-run with full endpoint coverage.
- 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: Summary helper (bash): run `scripts/loadtest/summarize-artillery.sh <report.json>` after an Artillery WS run, and `scripts/loadtest/summarize-k6.sh <summary.json>` after a k6 admin run, to produce the artifact files used in the mappings above.
- 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.
+121 -121
View File
@@ -1,122 +1,122 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linear Void - 24/7 Synchronized Channel</title> <title>Linear Void - 24/7 Synchronized Channel</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/src/style.css" /> <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"> <main class="viewer-shell" aria-label="Live player and chat">
<header class="viewer-header"> <header class="viewer-header">
<div class="viewer-brand-group"> <div class="viewer-brand-group">
<div class="brand-badge" aria-hidden="true">LV</div> <div class="brand-badge" aria-hidden="true">LV</div>
<div> <div>
<h1 class="viewer-title">Linear Void</h1> <h1 class="viewer-title">Linear Void</h1>
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p> <p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
</div> </div>
</div> </div>
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true"> <button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">
Hide Chat Hide Chat
</button> </button>
</header> </header>
<section class="viewer-content" aria-label="Live stream and chat layout"> <section class="viewer-content" aria-label="Live stream and chat layout">
<section class="player-stage" aria-label="Video stage"> <section class="player-stage" aria-label="Video stage">
<video id="player" class="player" controls autoplay muted playsinline></video> <video id="player" class="player" controls autoplay muted playsinline></video>
<div id="now-playing" class="now-playing-overlay" aria-live="polite"> <div id="now-playing" class="now-playing-overlay" aria-live="polite">
<span id="np-title">Loading...</span> <span id="np-title">Loading...</span>
<span id="np-next" class="np-next"></span> <span id="np-next" class="np-next"></span>
</div> </div>
</section> </section>
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel"> <aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
<header class="chat-header"> <header class="chat-header">
<div class="chat-room-title"> <div class="chat-room-title">
<span class="chat-live-marker" aria-hidden="true">●</span> <span class="chat-live-marker" aria-hidden="true">●</span>
<span id="room-name">Main Channel</span> <span id="room-name">Main Channel</span>
</div> </div>
<div class="chat-header-actions"> <div class="chat-header-actions">
<span id="chat-user-label" class="chat-user-label" hidden></span> <span id="chat-user-label" class="chat-user-label" hidden></span>
<button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button> <button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button>
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button> <button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button>
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button> <button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
</div> </div>
</header> </header>
<div id="auth-panel" class="auth-modal" hidden> <div id="auth-panel" class="auth-modal" hidden>
<div id="auth-backdrop" class="auth-modal-backdrop"></div> <div id="auth-backdrop" class="auth-modal-backdrop"></div>
<div class="auth-modal-content"> <div class="auth-modal-content">
<button id="auth-close-btn" type="button" class="btn auth-close" aria-label="Close sign in">×</button> <button id="auth-close-btn" type="button" class="btn auth-close" aria-label="Close sign in">×</button>
<h2 class="auth-modal-title">Sign in</h2> <h2 class="auth-modal-title">Sign in</h2>
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" /> <input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" /> <input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
<div class="button-row"> <div class="button-row">
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button> <button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button> <button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
</div> </div>
<p id="auth-status" class="status-line" aria-live="polite"></p> <p id="auth-status" class="status-line" aria-live="polite"></p>
</div> </div>
</div> </div>
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div> <div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
<section id="messages" class="chat-messages" aria-live="polite"></section> <section id="messages" class="chat-messages" aria-live="polite"></section>
<section id="presence-panel" class="presence-panel"> <section id="presence-panel" class="presence-panel">
<div id="presence-list" class="presence-list"></div> <div id="presence-list" class="presence-list"></div>
</section> </section>
<div class="chat-input-area"> <div class="chat-input-area">
<div class="chat-input-row"> <div class="chat-input-row">
<label for="message-input" class="sr-only">Message input</label> <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> <button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
</div> </div>
<p class="chat-note">Synced with video • Moderation active</p> <p class="chat-note">Synced with video • Moderation active</p>
</div> </div>
</aside> </aside>
</section> </section>
</main> </main>
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive"> <div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
<div class="reconnecting-spinner"></div> <div class="reconnecting-spinner"></div>
<span id="reconnecting-text">Reconnecting...</span> <span id="reconnecting-text">Reconnecting...</span>
</div> </div>
<script> <script>
(function () { (function () {
// Load Matomo analytics when configured via the admin runtime config. // Load Matomo analytics when configured via the admin runtime config.
fetch("/api/matomo") fetch("/api/matomo")
.then(function (r) { .then(function (r) {
return r.ok ? r.json() : null; return r.ok ? r.json() : null;
}) })
.then(function (cfg) { .then(function (cfg) {
if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return; if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return;
var _paq = (window._paq = window._paq || []); var _paq = (window._paq = window._paq || []);
_paq.push(["trackPageView"]); _paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]); _paq.push(["enableLinkTracking"]);
var base = String(cfg.matomoUrl).replace(/\/+$/, ""); var base = String(cfg.matomoUrl).replace(/\/+$/, "");
_paq.push(["setTrackerUrl", base + "/matomo.php"]); _paq.push(["setTrackerUrl", base + "/matomo.php"]);
_paq.push(["setSiteId", String(cfg.matomoSiteId)]); _paq.push(["setSiteId", String(cfg.matomoSiteId)]);
var d = document; var d = document;
var g = d.createElement("script"); var g = d.createElement("script");
var s = d.getElementsByTagName("script")[0]; var s = d.getElementsByTagName("script")[0];
g.async = true; g.async = true;
g.src = base + "/matomo.js"; g.src = base + "/matomo.js";
s.parentNode.insertBefore(g, s); s.parentNode.insertBefore(g, s);
}) })
.catch(function () { .catch(function () {
/* analytics is optional */ /* analytics is optional */
}); });
})(); })();
</script> </script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+2140 -2140
View File
File diff suppressed because it is too large. Load diff
+29 -29
View File
@@ -1,29 +1,29 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.0.13", "version": "1.0.13",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:watch": "vitest" "test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^4.1.10", "@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10", "@vitest/ui": "^4.1.10",
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.2.0", "vite": "^8.2.0",
"vitest": "^4.1.10" "vitest": "^4.1.10"
}, },
"dependencies": { "dependencies": {
"hls.js": "^1.6.16" "hls.js": "^1.6.16"
} }
} }
+378 -378
View File
@@ -1,378 +1,378 @@
/// <reference types="vitest" /> /// <reference types="vitest" />
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin"; import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin";
// Mock fetch globally // Mock fetch globally
let fetchCalls: { url: string; options?: RequestInit }[] = []; let fetchCalls: { url: string; options?: RequestInit }[] = [];
let mockFetchResponse: any = { ok: true, payload: {} }; let mockFetchResponse: any = { ok: true, payload: {} };
beforeEach(() => { beforeEach(() => {
fetchCalls = []; fetchCalls = [];
globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => { globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => {
fetchCalls.push({ url, options }); fetchCalls.push({ url, options });
const payload = mockFetchResponse.payload ?? {}; const payload = mockFetchResponse.payload ?? {};
return Promise.resolve( return Promise.resolve(
new Response(JSON.stringify(payload), { new Response(JSON.stringify(payload), {
status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500, status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}), }),
); );
}) as any; }) as any;
// Mock localStorage // Mock localStorage
const storage = new Map<string, string>(); const storage = new Map<string, string>();
Object.defineProperty(window, "localStorage", { Object.defineProperty(window, "localStorage", {
value: { value: {
getItem: vi.fn((key: string) => storage.get(key) ?? null), getItem: vi.fn((key: string) => storage.get(key) ?? null),
setItem: vi.fn((key: string, val: string) => storage.set(key, val)), setItem: vi.fn((key: string, val: string) => storage.set(key, val)),
removeItem: vi.fn((key: string) => storage.delete(key)), removeItem: vi.fn((key: string) => storage.delete(key)),
}, },
writable: true, writable: true,
}); });
// Mock window.confirm/prompt // Mock window.confirm/prompt
vi.spyOn(window, "confirm").mockReturnValue(true); vi.spyOn(window, "confirm").mockReturnValue(true);
vi.spyOn(window, "prompt").mockReturnValue(null); vi.spyOn(window, "prompt").mockReturnValue(null);
vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {});
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
// Import after mocks are set // Import after mocks are set
import "../src/admin"; import "../src/admin";
describe("admin.ts initialization", () => { describe("admin.ts initialization", () => {
it("loads and exposes tab switching", () => { it("loads and exposes tab switching", () => {
// After import, init() runs on DOMContentLoaded // After import, init() runs on DOMContentLoaded
// Test that the document has admin page structure // Test that the document has admin page structure
expect(document.body).toBeDefined(); expect(document.body).toBeDefined();
}); });
}); });
describe("upload progress helpers", () => { describe("upload progress helpers", () => {
it("renders upload progress correctly", async () => { it("renders upload progress correctly", async () => {
// Set up DOM for upload page // Set up DOM for upload page
document.body.innerHTML = ` document.body.innerHTML = `
<div id="media-upload-progress-wrap" hidden> <div id="media-upload-progress-wrap" hidden>
<progress id="media-upload-progress" max="100" value="0"></progress> <progress id="media-upload-progress" max="100" value="0"></progress>
<span id="media-upload-progress-text"></span> <span id="media-upload-progress-text"></span>
</div> </div>
<div id="tab-content"></div> <div id="tab-content"></div>
`; `;
// Re-import triggers init again — but we need fresh module state // Re-import triggers init again — but we need fresh module state
// For now test internal helper access via body // For now test internal helper access via body
const wrap = document.getElementById("media-upload-progress-wrap")!; const wrap = document.getElementById("media-upload-progress-wrap")!;
const bar = document.getElementById("media-upload-progress") as HTMLProgressElement; const bar = document.getElementById("media-upload-progress") as HTMLProgressElement;
const text = document.getElementById("media-upload-progress-text")!; const text = document.getElementById("media-upload-progress-text")!;
expect(wrap.hidden).toBe(true); expect(wrap.hidden).toBe(true);
// Simulate what setUploadProgress would do // Simulate what setUploadProgress would do
wrap.hidden = false; wrap.hidden = false;
bar.max = 100; bar.max = 100;
bar.value = 50; bar.value = 50;
text.textContent = "Uploading (50%)"; text.textContent = "Uploading (50%)";
expect(wrap.hidden).toBe(false); expect(wrap.hidden).toBe(false);
expect(bar.value).toBe(50); expect(bar.value).toBe(50);
expect(text.textContent).toBe("Uploading (50%)"); expect(text.textContent).toBe("Uploading (50%)");
}); });
}); });
describe("file fingerprint", () => { describe("file fingerprint", () => {
it("generates unique fingerprints for different files", () => { it("generates unique fingerprints for different files", () => {
const f1 = new File(["a"], "video1.mp4", { lastModified: 1 }); const f1 = new File(["a"], "video1.mp4", { lastModified: 1 });
const f2 = new File(["b"], "video2.mp4", { lastModified: 2 }); const f2 = new File(["b"], "video2.mp4", { lastModified: 2 });
const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`; const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`;
const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`; const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`;
expect(fp1).not.toBe(fp2); expect(fp1).not.toBe(fp2);
expect(fp1).toBe("video1.mp4:1:1"); expect(fp1).toBe("video1.mp4:1:1");
expect(fp2).toBe("video2.mp4:1:2"); expect(fp2).toBe("video2.mp4:1:2");
}); });
}); });
describe("MAX_UPLOAD_BYTES constant", () => { describe("MAX_UPLOAD_BYTES constant", () => {
it("equals 2GB", () => { it("equals 2GB", () => {
expect(2 * 1024 * 1024 * 1024).toBe(2147483648); expect(2 * 1024 * 1024 * 1024).toBe(2147483648);
}); });
}); });
describe("API calls on tab load", () => { describe("API calls on tab load", () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="tab-content"><div id="overview-content"></div></div> <div id="tab-content"><div id="overview-content"></div></div>
<nav class="admin-nav"> <nav class="admin-nav">
<button data-tab="dashboard">Dashboard</button> <button data-tab="dashboard">Dashboard</button>
<button data-tab="analytics">Analytics</button> <button data-tab="analytics">Analytics</button>
<button data-tab="playlist">Playlist</button> <button data-tab="playlist">Playlist</button>
<button data-tab="schedule">Schedule</button> <button data-tab="schedule">Schedule</button>
<button data-tab="system">System</button> <button data-tab="system">System</button>
<button data-tab="upload">Upload</button> <button data-tab="upload">Upload</button>
<button data-tab="users">Users</button> <button data-tab="users">Users</button>
<button data-tab="moderation">Moderation</button> <button data-tab="moderation">Moderation</button>
<button data-tab="chat-log">Chat Log</button> <button data-tab="chat-log">Chat Log</button>
</nav> </nav>
<div id="overview-stats"></div> <div id="overview-stats"></div>
<div id="analytics-stats"></div> <div id="analytics-stats"></div>
<div id="playlist-editor"></div> <div id="playlist-editor"></div>
<div id="schedule-list"></div> <div id="schedule-list"></div>
<div id="system-summary"></div> <div id="system-summary"></div>
<div id="media-upload-progress-wrap" hidden> <div id="media-upload-progress-wrap" hidden>
<progress id="media-upload-progress"></progress> <progress id="media-upload-progress"></progress>
<span id="media-upload-progress-text"></span> <span id="media-upload-progress-text"></span>
</div> </div>
<div id="media-table-body"></div> <div id="media-table-body"></div>
<div id="users-table-body"></div> <div id="users-table-body"></div>
<div id="moderation-log-table-body"></div> <div id="moderation-log-table-body"></div>
<div id="chat-log-table-body"></div> <div id="chat-log-table-body"></div>
<div id="chat-log-pagination"></div> <div id="chat-log-pagination"></div>
<div id="config-editor"></div> <div id="config-editor"></div>
<span id="restart-status"></span> <span id="restart-status"></span>
<div id="schedule-override-list"></div> <div id="schedule-override-list"></div>
`; `;
}); });
it("can be imported without crashing", () => { it("can be imported without crashing", () => {
// admin.ts initialization should not throw // admin.ts initialization should not throw
expect(true).toBe(true); expect(true).toBe(true);
}); });
}); });
describe("admin data refresh", () => { describe("admin data refresh", () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="overview-status"></div> <div id="overview-status"></div>
<span id="stat-viewers"></span> <span id="stat-viewers"></span>
<span id="stat-stored-messages"></span> <span id="stat-stored-messages"></span>
<span id="stat-moderation-actions"></span> <span id="stat-moderation-actions"></span>
<span id="stat-media-runtime"></span> <span id="stat-media-runtime"></span>
<span id="stat-media-count"></span> <span id="stat-media-count"></span>
<span id="system-stored-messages"></span> <span id="system-stored-messages"></span>
<span id="system-redis-clients"></span> <span id="system-redis-clients"></span>
<span id="system-redis-enabled"></span> <span id="system-redis-enabled"></span>
<span id="system-redis-memory"></span> <span id="system-redis-memory"></span>
<span id="system-db-driver"></span> <span id="system-db-driver"></span>
<span id="system-generated-at"></span> <span id="system-generated-at"></span>
<div id="analytics-status"></div> <div id="analytics-status"></div>
<span id="analytics-total-messages"></span> <span id="analytics-total-messages"></span>
<span id="analytics-messages-24h"></span> <span id="analytics-messages-24h"></span>
<span id="analytics-distinct-rooms"></span> <span id="analytics-distinct-rooms"></span>
<span id="analytics-moderation-actions"></span> <span id="analytics-moderation-actions"></span>
<span id="analytics-generated-at"></span> <span id="analytics-generated-at"></span>
<table><tbody id="analytics-table-body"></tbody></table> <table><tbody id="analytics-table-body"></tbody></table>
`; `;
}); });
it("renders overview stats from the admin overview API", async () => { it("renders overview stats from the admin overview API", async () => {
mockFetchResponse = { mockFetchResponse = {
ok: true, ok: true,
payload: { payload: {
viewers: 12, viewers: 12,
storedMessages: 345, storedMessages: 345,
moderationActions: 9, moderationActions: 9,
mediaTotalRuntime: "01:23:45", mediaTotalRuntime: "01:23:45",
mediaCount: 7, mediaCount: 7,
redisClients: 4, redisClients: 4,
redisEnabled: true, redisEnabled: true,
redisMemoryUsed: "18 MB", redisMemoryUsed: "18 MB",
databaseDriver: "sqlite", databaseDriver: "sqlite",
generatedAt: "2026-08-08T12:00:00Z", generatedAt: "2026-08-08T12:00:00Z",
}, },
}; };
await refreshOverview(); await refreshOverview();
expect(document.getElementById("stat-viewers")?.textContent).toBe("12"); expect(document.getElementById("stat-viewers")?.textContent).toBe("12");
expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345"); expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345");
expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes"); expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes");
expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z"); expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z");
}); });
it("renders analytics rows from the analytics API", async () => { it("renders analytics rows from the analytics API", async () => {
mockFetchResponse = { mockFetchResponse = {
ok: true, ok: true,
payload: { payload: {
totalMessages: 1000, totalMessages: 1000,
messagesLast24Hours: 75, messagesLast24Hours: 75,
distinctRooms: 3, distinctRooms: 3,
moderationActions: 11, moderationActions: 11,
generatedAt: "2026-08-08T12:30:00Z", generatedAt: "2026-08-08T12:30:00Z",
topRooms: [ topRooms: [
{ room: "main", messageCount: 55 }, { room: "main", messageCount: 55 },
{ room: "lobby", messageCount: 20 }, { room: "lobby", messageCount: 20 },
], ],
}, },
}; };
await refreshAnalytics(); await refreshAnalytics();
expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000"); expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000");
expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75"); 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("main");
expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby"); expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby");
}); });
}); });
describe("playlist management", () => { describe("playlist management", () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="playlist-status"></div> <div id="playlist-status"></div>
<textarea id="playlist-content">file '/app/media/demo1.mp4' <textarea id="playlist-content">file '/app/media/demo1.mp4'
file '/app/media/demo2.mp4' file '/app/media/demo2.mp4'
</textarea> </textarea>
<ul id="playlist-sortable"></ul> <ul id="playlist-sortable"></ul>
<div id="playlist-warnings" hidden></div> <div id="playlist-warnings" hidden></div>
`; `;
}); });
it("saves playlist via PUT request", async () => { it("saves playlist via PUT request", async () => {
mockFetchResponse = { ok: true, payload: {} }; mockFetchResponse = { ok: true, payload: {} };
await savePlaylist(); await savePlaylist();
const putCall = fetchCalls.find((c) => c.options?.method === "PUT"); const putCall = fetchCalls.find((c) => c.options?.method === "PUT");
expect(putCall).toBeDefined(); expect(putCall).toBeDefined();
expect(putCall?.url).toBe("/api/admin/playlist"); expect(putCall?.url).toBe("/api/admin/playlist");
expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" }); expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" });
const body = JSON.parse(putCall?.options?.body as string); const body = JSON.parse(putCall?.options?.body as string);
expect(body.content).toContain("demo1.mp4"); expect(body.content).toContain("demo1.mp4");
}); });
it("sets error status on save failure", async () => { it("sets error status on save failure", async () => {
mockFetchResponse = { ok: false, status: 500, payload: {} }; mockFetchResponse = { ok: false, status: 500, payload: {} };
await savePlaylist(); await savePlaylist();
const status = document.getElementById("playlist-status"); const status = document.getElementById("playlist-status");
expect(status?.textContent).toContain("Failed"); expect(status?.textContent).toContain("Failed");
}); });
it("renders sortable playlist with indexes and paths", () => { it("renders sortable playlist with indexes and paths", () => {
renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n"); renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n");
const list = document.getElementById("playlist-sortable")!; const list = document.getElementById("playlist-sortable")!;
expect(list.children).toHaveLength(2); expect(list.children).toHaveLength(2);
expect(list.innerHTML).toContain("demo1.mp4"); expect(list.innerHTML).toContain("demo1.mp4");
expect(list.innerHTML).toContain("demo2.mp4"); expect(list.innerHTML).toContain("demo2.mp4");
expect(list.querySelector(".sortable-index")?.textContent).toBe("1"); expect(list.querySelector(".sortable-index")?.textContent).toBe("1");
expect(list.querySelector("[data-remove]")).toBeTruthy(); expect(list.querySelector("[data-remove]")).toBeTruthy();
}); });
it("skips comment and blank lines in sortable", () => { it("skips comment and blank lines in sortable", () => {
renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n"); renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n");
const list = document.getElementById("playlist-sortable")!; const list = document.getElementById("playlist-sortable")!;
expect(list.children).toHaveLength(1); expect(list.children).toHaveLength(1);
expect(list.innerHTML).toContain("demo1.mp4"); expect(list.innerHTML).toContain("demo1.mp4");
}); });
}); });
describe("admin accessibility", () => { describe("admin accessibility", () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ` document.body.innerHTML = `
<nav class="admin-nav" aria-label="Admin navigation"> <nav class="admin-nav" aria-label="Admin navigation">
<button data-tab="dashboard" aria-current="page">Dashboard</button> <button data-tab="dashboard" aria-current="page">Dashboard</button>
<button data-tab="playlist">Playlist</button> <button data-tab="playlist">Playlist</button>
<button data-tab="users">Users</button> <button data-tab="users">Users</button>
<button data-tab="moderation">Moderation</button> <button data-tab="moderation">Moderation</button>
</nav> </nav>
<div id="tab-content"></div> <div id="tab-content"></div>
`; `;
}); });
it("nav has aria-label for screen readers", () => { it("nav has aria-label for screen readers", () => {
const nav = document.querySelector(".admin-nav"); const nav = document.querySelector(".admin-nav");
expect(nav?.getAttribute("aria-label")).toBe("Admin navigation"); expect(nav?.getAttribute("aria-label")).toBe("Admin navigation");
}); });
it("active tab has aria-current attribute", () => { it("active tab has aria-current attribute", () => {
const active = document.querySelector("[aria-current]"); const active = document.querySelector("[aria-current]");
expect(active).toBeTruthy(); expect(active).toBeTruthy();
expect(active?.getAttribute("aria-current")).toBe("page"); expect(active?.getAttribute("aria-current")).toBe("page");
}); });
it("all nav buttons are focusable", () => { it("all nav buttons are focusable", () => {
const buttons = document.querySelectorAll(".admin-nav button"); const buttons = document.querySelectorAll(".admin-nav button");
buttons.forEach((btn) => { buttons.forEach((btn) => {
expect(btn.getAttribute("tabindex")).toBeNull(); expect(btn.getAttribute("tabindex")).toBeNull();
expect(btn.getAttribute("disabled")).toBeNull(); expect(btn.getAttribute("disabled")).toBeNull();
}); });
}); });
}); });
describe("admin responsive layout", () => { describe("admin responsive layout", () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ` document.body.innerHTML = `
<nav class="admin-nav"><button data-tab="dashboard">Dashboard</button></nav> <nav class="admin-nav"><button data-tab="dashboard">Dashboard</button></nav>
<div id="tab-content"><div id="overview-content">Overview data</div></div> <div id="tab-content"><div id="overview-content">Overview data</div></div>
`; `;
}); });
it("tab content is present and visible", () => { it("tab content is present and visible", () => {
const tabContent = document.getElementById("tab-content"); const tabContent = document.getElementById("tab-content");
expect(tabContent).toBeTruthy(); expect(tabContent).toBeTruthy();
const style = window.getComputedStyle(tabContent!); const style = window.getComputedStyle(tabContent!);
expect(style.display).not.toBe("none"); expect(style.display).not.toBe("none");
}); });
it("admin nav contains essential tabs", () => { it("admin nav contains essential tabs", () => {
const buttons = document.querySelectorAll(".admin-nav button"); const buttons = document.querySelectorAll(".admin-nav button");
const tabs = Array.from(buttons).map((b) => b.textContent?.trim()); const tabs = Array.from(buttons).map((b) => b.textContent?.trim());
expect(tabs).toContain("Dashboard"); expect(tabs).toContain("Dashboard");
}); });
}); });
describe("admin data refresh failures", () => { describe("admin data refresh failures", () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="overview-status"></div> <div id="overview-status"></div>
<div id="analytics-status"></div> <div id="analytics-status"></div>
`; `;
}); });
it("sets error status when overview fails", async () => { it("sets error status when overview fails", async () => {
mockFetchResponse = { ok: false, status: 500, payload: {} }; mockFetchResponse = { ok: false, status: 500, payload: {} };
await refreshOverview(); await refreshOverview();
expect(document.getElementById("overview-status")?.textContent).toContain("Failed"); expect(document.getElementById("overview-status")?.textContent).toContain("Failed");
}); });
it("sets error status when analytics fails", async () => { it("sets error status when analytics fails", async () => {
mockFetchResponse = { ok: false, status: 500, payload: {} }; mockFetchResponse = { ok: false, status: 500, payload: {} };
await refreshAnalytics(); await refreshAnalytics();
expect(document.getElementById("analytics-status")?.textContent).toContain("Failed"); expect(document.getElementById("analytics-status")?.textContent).toContain("Failed");
}); });
}); });
describe("playlist status badges", () => { describe("playlist status badges", () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = `<ul id="playlist-sortable"></ul>`; document.body.innerHTML = `<ul id="playlist-sortable"></ul>`;
}); });
it("renders badges for every media status", () => { it("renders badges for every media status", () => {
const statusMap = new Map<string, string>([ const statusMap = new Map<string, string>([
["/app/media/ready.mp4", "ready"], ["/app/media/ready.mp4", "ready"],
["/app/media/needs.mp4", "needs_normalization"], ["/app/media/needs.mp4", "needs_normalization"],
["/app/media/normalizing.mp4", "normalizing"], ["/app/media/normalizing.mp4", "normalizing"],
["/app/media/failed.mp4", "normalization_failed"], ["/app/media/failed.mp4", "normalization_failed"],
["/app/media/other.mp4", "weird"], ["/app/media/other.mp4", "weird"],
]); ]);
renderPlaylistSortable( renderPlaylistSortable(
"file '/app/media/ready.mp4'\nfile '/app/media/needs.mp4'\nfile '/app/media/normalizing.mp4'\nfile '/app/media/failed.mp4'\nfile '/app/media/other.mp4'\nfile '/app/media/nopath.mp4'\n", "file '/app/media/ready.mp4'\nfile '/app/media/needs.mp4'\nfile '/app/media/normalizing.mp4'\nfile '/app/media/failed.mp4'\nfile '/app/media/other.mp4'\nfile '/app/media/nopath.mp4'\n",
statusMap, statusMap,
); );
const list = document.getElementById("playlist-sortable")!; const list = document.getElementById("playlist-sortable")!;
expect(list.innerHTML).toContain("badge-ok"); expect(list.innerHTML).toContain("badge-ok");
expect(list.innerHTML).toContain("badge-warn"); expect(list.innerHTML).toContain("badge-warn");
expect(list.innerHTML).toContain("badge-info"); expect(list.innerHTML).toContain("badge-info");
expect(list.innerHTML).toContain("badge-danger"); expect(list.innerHTML).toContain("badge-danger");
expect(list.innerHTML).toContain("badge-muted"); // default + unknown fall back to muted expect(list.innerHTML).toContain("badge-muted"); // default + unknown fall back to muted
expect(list.children).toHaveLength(6); expect(list.children).toHaveLength(6);
}); });
}); });
+23 -23
View File
@@ -1,23 +1,23 @@
/// <reference types="vitest" /> /// <reference types="vitest" />
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { setupCounter } from "../src/counter"; import { setupCounter } from "../src/counter";
describe("setupCounter", () => { describe("setupCounter", () => {
it("initializes the button with count 0", () => { it("initializes the button with count 0", () => {
const btn = document.createElement("button"); const btn = document.createElement("button");
setupCounter(btn); setupCounter(btn);
expect(btn.innerHTML).toBe("Count is 0"); expect(btn.innerHTML).toBe("Count is 0");
}); });
it("increments the counter on each click", () => { it("increments the counter on each click", () => {
const btn = document.createElement("button"); const btn = document.createElement("button");
setupCounter(btn); setupCounter(btn);
btn.click(); btn.click();
expect(btn.innerHTML).toBe("Count is 1"); expect(btn.innerHTML).toBe("Count is 1");
btn.click(); btn.click();
btn.click(); btn.click();
expect(btn.innerHTML).toBe("Count is 3"); expect(btn.innerHTML).toBe("Count is 3");
}); });
}); });
+598 -598
View File
File diff suppressed because it is too large. Load diff
+519 -519
View File
File diff suppressed because it is too large. Load diff
+1244 -1244
View File
File diff suppressed because it is too large. Load diff
+20 -20
View File
@@ -1,20 +1,20 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
environment: "jsdom", environment: "jsdom",
include: ["src/**/*.test.ts"], include: ["src/**/*.test.ts"],
coverage: { coverage: {
provider: "v8", provider: "v8",
reporter: ["text", "text-summary"], reporter: ["text", "text-summary"],
include: ["src/**/*.ts"], include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts"], exclude: ["src/**/*.test.ts"],
thresholds: { thresholds: {
statements: 20, statements: 20,
branches: 15, branches: 15,
functions: 15, functions: 15,
lines: 20, lines: 20,
}, },
}, },
}, },
}); });
+6 -6
View File
@@ -1,6 +1,6 @@
name = "linearvoid-frontend" name = "linearvoid-frontend"
compatibility_date = "2025-08-01" compatibility_date = "2025-08-01"
[assets] [assets]
directory = "./dist" directory = "./dist"
not_found_handling = "single-page-application" not_found_handling = "single-page-application"
+20 -20
View File
@@ -1,21 +1,21 @@
# Stage 1: Build frontend # Stage 1: Build frontend
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /build WORKDIR /build
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci RUN npm ci
COPY frontend/ . COPY frontend/ .
RUN npm run build RUN npm run build
# Stage 2: Nginx with bundled frontend # Stage 2: Nginx with bundled frontend
FROM nginx:alpine FROM nginx:alpine
COPY infrastructure/nginx/nginx.conf /etc/nginx/nginx.conf COPY infrastructure/nginx/nginx.conf /etc/nginx/nginx.conf
# Copy prebuilt frontend from builder stage # Copy prebuilt frontend from builder stage
COPY --from=builder /build/dist/ /srv/frontend/ COPY --from=builder /build/dist/ /srv/frontend/
# Create HLS directory # Create HLS directory
RUN mkdir -p /srv/hls RUN mkdir -p /srv/hls
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+284 -284
View File
@@ -1,285 +1,285 @@
events { events {
worker_connections 1024; worker_connections 1024;
} }
http { http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream;
resolver 127.0.0.11 ipv6=off valid=30s; resolver 127.0.0.11 ipv6=off valid=30s;
map_hash_bucket_size 128; map_hash_bucket_size 128;
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default upgrade; default upgrade;
'' close; '' close;
} }
map $http_origin $cors_origin { map $http_origin $cors_origin {
default ""; default "";
"https://linearvoid.tv" $http_origin; "https://linearvoid.tv" $http_origin;
"https://www.linearvoid.tv" $http_origin; "https://www.linearvoid.tv" $http_origin;
"https://linearvoid-frontend.zwitschi82.workers.dev" $http_origin; "https://linearvoid-frontend.zwitschi82.workers.dev" $http_origin;
"~^https://[a-zA-Z0-9.-]+\.linearvoid\.tv$" $http_origin; "~^https://[a-zA-Z0-9.-]+\.linearvoid\.tv$" $http_origin;
} }
server { server {
listen 80; listen 80;
server_name www.linearvoid.tv; server_name www.linearvoid.tv;
return 301 https://linearvoid.tv$request_uri; return 301 https://linearvoid.tv$request_uri;
} }
server { server {
listen 80 default_server; listen 80 default_server;
server_name linearvoid.tv localhost 127.0.0.1; server_name linearvoid.tv localhost 127.0.0.1;
# Resumable admin upload chunks are 8MB by default; allow safe headroom. # Resumable admin upload chunks are 8MB by default; allow safe headroom.
client_max_body_size 64m; client_max_body_size 64m;
root /srv/frontend; root /srv/frontend;
index index.html; index index.html;
location = /admin { location = /admin {
return 301 /admin/; return 301 /admin/;
} }
location = /admin/login { location = /admin/login {
return 301 /admin/login/; return 301 /admin/login/;
} }
location = /_admin_auth { location = /_admin_auth {
internal; internal;
set $chat_upstream http://chat:3000; set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream/api/admin/session; proxy_pass $chat_upstream/api/admin/session;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_pass_request_body off; proxy_pass_request_body off;
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie; proxy_set_header Cookie $http_cookie;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location /admin/login/ { location /admin/login/ {
try_files $uri $uri/ /admin/login/index.html; try_files $uri $uri/ /admin/login/index.html;
} }
location /admin/ { location /admin/ {
auth_request /_admin_auth; auth_request /_admin_auth;
error_page 401 = @admin_login_redirect; error_page 401 = @admin_login_redirect;
try_files $uri $uri/ /admin/index.html; try_files $uri $uri/ /admin/index.html;
} }
location /assets/ { location /assets/ {
try_files $uri =404; try_files $uri =404;
} }
# Serve static frontend # Serve static frontend
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Proxy chat WebSocket # Proxy chat WebSocket
location /ws { location /ws {
set $chat_upstream http://chat:3000; set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream; proxy_pass $chat_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location = /api/admin/login { location = /api/admin/login {
if ($request_method = OPTIONS) { if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin; add_header Access-Control-Allow-Origin $cors_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization"; add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Allow-Credentials true; add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Max-Age 86400; add_header Access-Control-Max-Age 86400;
return 204; return 204;
} }
set $chat_upstream http://chat:3000; set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream; proxy_pass $chat_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie; proxy_set_header Cookie $http_cookie;
add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always; add_header Access-Control-Allow-Credentials true always;
} }
location = /api/admin/logout { location = /api/admin/logout {
if ($request_method = OPTIONS) { if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin; add_header Access-Control-Allow-Origin $cors_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization"; add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Allow-Credentials true; add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Max-Age 86400; add_header Access-Control-Max-Age 86400;
return 204; return 204;
} }
set $chat_upstream http://chat:3000; set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream; proxy_pass $chat_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie; proxy_set_header Cookie $http_cookie;
add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always; add_header Access-Control-Allow-Credentials true always;
} }
location = /api/admin/session { location = /api/admin/session {
if ($request_method = OPTIONS) { if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin; add_header Access-Control-Allow-Origin $cors_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization"; add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Allow-Credentials true; add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Max-Age 86400; add_header Access-Control-Max-Age 86400;
return 204; return 204;
} }
set $chat_upstream http://chat:3000; set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream; proxy_pass $chat_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie; proxy_set_header Cookie $http_cookie;
add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always; add_header Access-Control-Allow-Credentials true always;
} }
location /api/admin/ { location /api/admin/ {
if ($request_method = OPTIONS) { if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin; add_header Access-Control-Allow-Origin $cors_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization"; add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Allow-Credentials true; add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Max-Age 86400; add_header Access-Control-Max-Age 86400;
return 204; return 204;
} }
auth_request /_admin_auth; auth_request /_admin_auth;
error_page 401 = @admin_api_unauthorized; error_page 401 = @admin_api_unauthorized;
set $chat_upstream http://chat:3000; set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream; proxy_pass $chat_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie; proxy_set_header Cookie $http_cookie;
add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always; add_header Access-Control-Allow-Credentials true always;
} }
location /api/ { location /api/ {
if ($request_method = OPTIONS) { if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin; add_header Access-Control-Allow-Origin $cors_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization"; add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Allow-Credentials true; add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Max-Age 86400; add_header Access-Control-Max-Age 86400;
return 204; return 204;
} }
set $chat_upstream http://chat:3000; set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream; proxy_pass $chat_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always; add_header Access-Control-Allow-Credentials true always;
} }
location = /metrics { location = /metrics {
set $chat_upstream http://chat:3000; set $chat_upstream http://chat:3000;
proxy_pass $chat_upstream; proxy_pass $chat_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location @admin_api_unauthorized { location @admin_api_unauthorized {
add_header Content-Type application/json; add_header Content-Type application/json;
return 401 '{"error":"unauthorized"}'; return 401 '{"error":"unauthorized"}';
} }
location @admin_login_redirect { location @admin_login_redirect {
return 302 /admin/login/; return 302 /admin/login/;
} }
location = /hls/playlist.m3u8 { location = /hls/playlist.m3u8 {
if ($request_method = OPTIONS) { if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS"; add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Max-Age 86400; add_header Access-Control-Max-Age 86400;
return 204; return 204;
} }
alias /srv/hls/playlist.m3u8; alias /srv/hls/playlist.m3u8;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0"; add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
} }
location = /hls/init.mp4 { location = /hls/init.mp4 {
if ($request_method = OPTIONS) { if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS"; add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Max-Age 86400; add_header Access-Control-Max-Age 86400;
return 204; return 204;
} }
alias /srv/hls/init.mp4; alias /srv/hls/init.mp4;
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
} }
location ~* ^/hls/(.+\.(ts|m4s))$ { location ~* ^/hls/(.+\.(ts|m4s))$ {
if ($request_method = OPTIONS) { if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS"; add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Max-Age 86400; add_header Access-Control-Max-Age 86400;
return 204; return 204;
} }
alias /srv/hls/$1; alias /srv/hls/$1;
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
} }
# Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN. # Local/dev fallback HLS origin. Production media should use MEDIA_DOMAIN.
location /hls/ { location /hls/ {
if ($request_method = OPTIONS) { if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS"; add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Max-Age 86400; add_header Access-Control-Max-Age 86400;
return 204; return 204;
} }
alias /srv/hls/; alias /srv/hls/;
add_header Cache-Control "public, max-age=60"; add_header Cache-Control "public, max-age=60";
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
} }
# Health checks — aggregated from upstream services # Health checks — aggregated from upstream services
location /health { location /health {
default_type application/json; default_type application/json;
return 200 '{"status":"ok","service":"nginx","timestamp":"$time_iso8601"}'; return 200 '{"status":"ok","service":"nginx","timestamp":"$time_iso8601"}';
} }
location /nginx-status { location /nginx-status {
stub_status on; stub_status on;
allow 127.0.0.1; allow 127.0.0.1;
allow 172.16.0.0/12; allow 172.16.0.0/12;
allow 192.168.0.0/16; allow 192.168.0.0/16;
deny all; deny all;
} }
} }
} }
+1148
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"vitest": "^4.1.11"
}
}
+106 -106
View File
@@ -1,106 +1,106 @@
#!/usr/bin/env node #!/usr/bin/env node
const apiToken = process.env.CLOUDFLARE_API_TOKEN; const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const zoneTag = process.env.CLOUDFLARE_ZONE_TAG; const zoneTag = process.env.CLOUDFLARE_ZONE_TAG;
const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv"; const hostname = process.env.CLOUDFLARE_HOSTNAME || "linearvoid.tv";
const windowMinutes = Number.parseInt(process.env.CLOUDFLARE_CACHE_HIT_RATIO_WINDOW_MINUTES || "60", 10); 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"; const endpoint = process.env.CLOUDFLARE_GRAPHQL_ENDPOINT || "https://api.cloudflare.com/client/v4/graphql";
if (!apiToken || !zoneTag) { if (!apiToken || !zoneTag) {
console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG"); console.error("Missing required environment variables: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_TAG");
process.exit(1); process.exit(1);
} }
const end = new Date(); const end = new Date();
const start = new Date(end.getTime() - windowMinutes * 60 * 1000); const start = new Date(end.getTime() - windowMinutes * 60 * 1000);
const iso = (value) => value.toISOString(); const iso = (value) => value.toISOString();
const query = ` const query = `
query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) { query CacheHitRatio($zoneTag: string, $start: Time, $end: Time, $hostname: String) {
viewer { viewer {
zones(filter: { zoneTag: $zoneTag }) { zones(filter: { zoneTag: $zoneTag }) {
requests: httpRequestsAdaptiveGroups( requests: httpRequestsAdaptiveGroups(
limit: 1000 limit: 1000
filter: { filter: {
datetime_geq: $start datetime_geq: $start
datetime_lt: $end datetime_lt: $end
clientRequestHTTPHost: $hostname clientRequestHTTPHost: $hostname
requestSource: "eyeball" requestSource: "eyeball"
} }
) { ) {
count count
dimensions { dimensions {
cacheStatus cacheStatus
} }
} }
} }
} }
} }
`; `;
async function main() { async function main() {
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${apiToken}`, Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
Accept: "application/json", Accept: "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
query, query,
variables: { variables: {
zoneTag, zoneTag,
hostname, hostname,
start: iso(start), start: iso(start),
end: iso(end), end: iso(end),
}, },
}), }),
}); });
const payload = await response.json(); const payload = await response.json();
if (!response.ok || payload.errors) { if (!response.ok || payload.errors) {
const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`; const message = payload.errors ? JSON.stringify(payload.errors) : `HTTP ${response.status}`;
throw new Error(message); throw new Error(message);
} }
const groups = payload?.data?.viewer?.zones?.[0]?.requests || []; const groups = payload?.data?.viewer?.zones?.[0]?.requests || [];
let totalRequests = 0; let totalRequests = 0;
let hitRequests = 0; let hitRequests = 0;
for (const group of groups) { for (const group of groups) {
const count = Number(group?.count || 0); const count = Number(group?.count || 0);
const status = String(group?.dimensions?.cacheStatus || "").toUpperCase(); const status = String(group?.dimensions?.cacheStatus || "").toUpperCase();
totalRequests += count; totalRequests += count;
if (status === "HIT") { if (status === "HIT") {
hitRequests += count; hitRequests += count;
} }
} }
const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0; const ratio = totalRequests > 0 ? hitRequests / totalRequests : 0;
console.log( console.log(
`# HELP linearvoid_cache_hit_ratio Cloudflare cache hit ratio for the selected hostname and time window.`, `# 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(`# TYPE linearvoid_cache_hit_ratio gauge`);
console.log( console.log(
`linearvoid_cache_hit_ratio{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${ratio.toFixed(6)}`, `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(`# 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(`# TYPE linearvoid_cache_hit_requests_total counter`);
console.log( console.log(
`linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`, `linearvoid_cache_hit_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${hitRequests}`,
); );
console.log( console.log(
`# HELP linearvoid_cache_total_requests_total Cloudflare total eyeball requests counted in the time window.`, `# 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(`# TYPE linearvoid_cache_total_requests_total counter`);
console.log( console.log(
`linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`, `linearvoid_cache_total_requests_total{zone="${zoneTag}",hostname="${hostname}",window_minutes="${windowMinutes}"} ${totalRequests}`,
); );
} }
main().catch((error) => { main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error)); console.error(error instanceof Error ? error.message : String(error));
process.exit(1); process.exit(1);
}); });
+8 -8
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env pwsh #!/usr/bin/env pwsh
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js' $nodeScript = Join-Path $scriptDir 'cache-hit-ratio.js'
& node $nodeScript @args & node $nodeScript @args
exit $LASTEXITCODE exit $LASTEXITCODE
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# cache-hit-ratio.sh — Thin wrapper that runs the Node.js cache-hit-ratio tool.
# Mirrors scripts/cache-hit-ratio.ps1.
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec node "$script_dir/cache-hit-ratio.js" "$@"
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# check-env.sh — Verify the deployment environment for Linear Void.
# Mirrors scripts/check-env.ps1.
#
# Checks, against the target Docker host (default test lab 192.168.88.40):
# 1. Docker Engine reachability over the TCP socket (port 2375).
# 2. Coolify helper stack (proxy/Traefik, sentinel, cloudflared).
# 3. Linear Void containers (playout, chat, nginx, redis, db, prometheus, grafana).
# 4. Public endpoints (app, HLS manifest, admin).
# 5. Health endpoints exposed by the services.
#
# Usage:
# ./scripts/check-env.sh
# ./scripts/check-env.sh -t 192.168.88.40
# ./scripts/check-env.sh -t 192.168.88.40 -p 2375 --only-containers
#
# Exit code is 0 when all required checks pass, 1 otherwise.
set -uo pipefail
TARGET=192.168.88.40
DOCKER_PORT=2375
ONLY_CONTAINERS=0
ONLY_ENDPOINTS=0
TIMEOUT=10
while [[ $# -gt 0 ]]; do
case "$1" in
-t|--target) TARGET="$2"; shift 2;;
-p|--docker-port) DOCKER_PORT="$2"; shift 2;;
--only-containers) ONLY_CONTAINERS=1; shift;;
--only-endpoints) ONLY_ENDPOINTS=1; shift;;
--timeout) TIMEOUT="$2"; shift 2;;
*) echo "Unknown option: $1" >&2; exit 2;;
esac
done
export DOCKER_HOST="tcp://${TARGET}:${DOCKER_PORT}"
FAILED=0
fail() { printf ' [FAIL] %s\n' "$1"; FAILED=1; }
ok() { printf ' [OK] %s\n' "$1"; }
warn() { printf ' [WARN] %s\n' "$1"; }
echo "== Linear Void environment check =="
echo "Target Docker host : $DOCKER_HOST"
echo "Date : $(date -Iseconds)"
# ---------------------------------------------------------------------------
# 1. Docker Engine reachability
# ---------------------------------------------------------------------------
if [[ "$ONLY_ENDPOINTS" -eq 0 ]]; then
echo ""
echo "-- Docker Engine (${TARGET}:${DOCKER_PORT}) --"
if ver=$(docker version --format '{{.Server.Version}}' 2>&1) && [[ "$ver" =~ [0-9] ]]; then
ok "Docker Engine reachable (server version $ver)"
else
fail "Docker Engine not reachable: $ver"
fi
# -------------------------------------------------------------------------
# 2. Coolify helper stack
# -------------------------------------------------------------------------
echo ""
echo "-- Coolify managed-server helpers --"
for c in coolify-proxy coolify-sentinel coolify-cloudflared; do
if state=$(docker inspect "$c" --format '{{.State.Status}} ({{.State.Health.Status}})' 2>/dev/null); then
if [[ "$state" =~ running.*(healthy|running) ]]; then ok "coolify helper: $c -> $state"; else warn "coolify helper: $c -> $state"; fi
else
warn "coolify helper: $c -> NOT FOUND (is this a Coolify-managed server?)"
fi
done
# -------------------------------------------------------------------------
# 3. Linear Void containers
# -------------------------------------------------------------------------
echo ""
echo "-- Linear Void containers --"
required=(linearvoid-playout linearvoid-chat linearvoid-nginx linearvoid-redis linearvoid-db linearvoid-prometheus linearvoid-grafana)
running="$(docker ps --format '{{.Names}}' 2>/dev/null)"
all="$(docker ps -a --format '{{.Names}}\t{{.Status}}' 2>/dev/null)"
for name in "${required[@]}"; do
if grep -qx "$name" <<<"$running"; then
ok "container: $name is running"
elif grep -q "^$name[[:space:]]" <<<"$all"; then
status="$(grep "^$name[[:space:]]" <<<"$all" | head -1)"
fail "container: $name EXISTS but not running -> $status"
else
fail "container: $name MISSING"
fi
done
fi
# ---------------------------------------------------------------------------
# 4. Health endpoints (via Docker host)
# ---------------------------------------------------------------------------
if [[ "$ONLY_CONTAINERS" -eq 0 ]]; then
echo ""
echo "-- Health endpoints --"
checks=(
"chat /health|http://${TARGET}:3000/health"
"nginx /health|http://${TARGET}:8081/health"
"prometheus|http://${TARGET}:9090/-/healthy"
"grafana /api/health|http://${TARGET}:3001/api/health"
)
for entry in "${checks[@]}"; do
name="${entry%%|*}"; url="${entry#*|}"
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" "$url" 2>/dev/null)
if [[ "$code" == "200" ]]; then ok "$name -> 200"; elif [[ "$code" =~ ^[0-9]{3}$ ]]; then warn "$name -> $code"; else fail "$name -> unreachable"; fi
done
# -------------------------------------------------------------------------
# 5. Public endpoints (routed through the Docker host for reliable TLS)
# -------------------------------------------------------------------------
echo ""
echo "-- Public endpoints (via Docker host) --"
pub=(
"linearvoid.tv app|https://linearvoid.tv/"
"media HLS manifest|https://media.linearvoid.tv/hls/playlist.m3u8"
"coolify dashboard|https://coolify.allucanget.biz/"
)
for entry in "${pub[@]}"; do
name="${entry%%|*}"; url="${entry#*|}"
code=$(docker run --rm curlimages/curl:latest -sS -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" -L "$url" 2>/dev/null | tr -d '[:space:]')
if [[ "$code" =~ ^[0-9]{3}$ ]]; then ok "$name -> HTTP $code"; else warn "$name -> $code"; fi
done
fi
# ---------------------------------------------------------------------------
echo ""
if [[ "$FAILED" -ne 0 ]]; then echo "RESULT: FAILED"; exit 1; fi
echo "RESULT: OK"
exit 0
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# check-tokens.sh — Verify that the credentials in the environment actually work.
# Mirrors scripts/check-tokens.ps1.
#
# Reads .env (or a supplied file) and validates access against live services:
# 1. Gitea container registry (CI_REGISTRY_TOKEN / CI_REGISTRY_USER).
# 2. Cloudflare API token(s) (CLOUDFLARE_API_TOKEN, and any R2 admin token).
# 3. R2/S3 bucket access (R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY) — lists buckets.
# 4. Linear Void admin login (ADMIN_USERNAME / ADMIN_PASSWORD) against the app origin.
#
# NOTE: Runs the checks through the target Docker host (default 192.168.88.40) so
# network + TLS work regardless of this machine's proxy/firewall. Uses the
# curlimages/curl and amazon/aws-cli images, which are pulled on demand.
#
# Usage:
# ./scripts/check-tokens.sh
# ./scripts/check-tokens.sh -e .env
# ./scripts/check-tokens.sh -t 192.168.88.40
#
# Exit code 0 = all credentials valid; 1 = at least one check failed.
set -uo pipefail
ENV_FILE=""
TARGET=192.168.88.40
DOCKER_PORT=2375
TIMEOUT=20
while [[ $# -gt 0 ]]; do
case "$1" in
-e|--env-file) ENV_FILE="$2"; shift 2;;
-t|--target) TARGET="$2"; shift 2;;
-p|--docker-port) DOCKER_PORT="$2"; shift 2;;
--timeout) TIMEOUT="$2"; shift 2;;
*) echo "Unknown option: $1" >&2; exit 2;;
esac
done
export DOCKER_HOST="tcp://${TARGET}:${DOCKER_PORT}"
FAILED=0
fail() { printf ' [FAIL] %s\n' "$1"; FAILED=1; }
ok() { printf ' [OK] %s\n' "$1"; }
warn() { printf ' [WARN] %s\n' "$1"; }
# Resolve env file: explicit, or $PWD/.env, or repo root.
if [[ -z "$ENV_FILE" ]]; then
for cand in "$PWD/.env" "$(dirname "$PWD")/.env"; do
if [[ -f "$cand" ]]; then ENV_FILE="$cand"; break; fi
done
fi
if [[ -z "$ENV_FILE" || ! -f "$ENV_FILE" ]]; then
echo "No .env file found. Pass one with -e." >&2
exit 1
fi
# Parse KEY=VALUE lines into an associative array.
declare -A envs
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^[[:space:]]*([A-Za-z0-9_]+)[[:space:]]*=[[:space:]]*(.*)[[:space:]]*$ ]]; then
envs["${BASH_REMATCH[1]}"]="${BASH_REMATCH[2]}"
fi
done < "$ENV_FILE"
get_val() { [[ -n "${envs[$1]+x}" ]] && echo "${envs[$1]}"; }
# Pull helper images once.
echo "Preparing helper images (curl, aws-cli) on $TARGET..."
docker pull curlimages/curl:latest >/dev/null 2>&1 || true
docker pull amazon/aws-cli:latest >/dev/null 2>&1 || true
# dc_curl URL [bearer] [token] — run curl via the curlimages/curl container.
dc_curl() {
local url="$1" bearer="${2:-}" token="${3:-}"
local -a args=(run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT")
[[ -n "$bearer" ]] && args+=( -H "Authorization: Bearer $bearer" )
[[ -n "$token" ]] && args+=( -H "Authorization: token $token" )
args+=( "$url" )
docker "${args[@]}" 2>&1 | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'
}
# ---------------------------------------------------------------------------
echo "== Linear Void credential / token check =="
echo "Env file : $ENV_FILE"
echo "Docker host: $DOCKER_HOST"
# --- 1. Gitea registry token ---
echo ""
echo "-- Gitea container registry --"
reg_user="$(get_val CI_REGISTRY_USER)"
reg_token="$(get_val CI_REGISTRY_TOKEN)"
if [[ -n "$reg_user" && -n "$reg_token" ]]; then
# Non-sensitive probe: /api/v1/user returns a scope message for token-based creds.
out="$(dc_curl 'https://git.allucanget.biz/api/v1/user' "" "$reg_token")"
if [[ "$out" =~ token.does.not.have.at.least.one.of.required.scope || "$out" =~ '"login"' || "$out" =~ invalid_grant ]]; then
if [[ "$out" =~ invalid_grant|Unauthorized|invalid ]]; then
fail "Gitea registry token rejected ($reg_user): $out"
else
ok "Gitea registry token valid for '$reg_user' (scope: write:package/write:repository)"
fi
else
warn "Gitea registry token response unexpected: $out"
fi
else
warn "CI_REGISTRY_USER / CI_REGISTRY_TOKEN not set; skipping Gitea registry check"
fi
# --- 2. Cloudflare API token ---
echo ""
echo "-- Cloudflare API tokens --"
cf_token="$(get_val CLOUDFLARE_API_TOKEN)"
if [[ -n "$cf_token" ]]; then
out="$(dc_curl 'https://api.cloudflare.com/client/v4/user/tokens/verify' "$cf_token")"
if [[ "$out" =~ '"success":true' ]]; then
if [[ "$out" =~ '"status":"active"' ]]; then ok 'Cloudflare API token valid and ACTIVE'; else warn "Cloudflare API token valid but status: $out"; fi
else
fail "Cloudflare API token rejected: $out"
fi
else
warn "CLOUDFLARE_API_TOKEN not set; skipping Cloudflare check"
fi
# --- 3. Cloudflare R2 / S3 bucket access ---
echo ""
echo "-- Cloudflare R2 (S3-compatible) --"
r2_key="$(get_val R2_ACCESS_KEY_ID)"
r2_secret="$(get_val R2_SECRET_ACCESS_KEY)"
r2_ep="$(get_val R2_ENDPOINT)"
bucket="$(get_val R2_BUCKET_NAME)"
# Use the admin key pair (which has ListBuckets) when present, else the regular key.
list_k="$(get_val R2_ADMIN_ACCESS_KEY_ID)"; list_s="$(get_val R2_ADMIN_SECRET_ACCESS_KEY)"
if [[ -z "$list_k" || -z "$list_s" ]]; then list_k="$r2_key"; list_s="$r2_secret"; fi
if [[ -n "$list_k" && -n "$list_s" && -n "$r2_ep" ]]; then
out="$(docker run --rm --entrypoint sh amazon/aws-cli:latest -c "export AWS_ACCESS_KEY_ID='$list_k'; export AWS_SECRET_ACCESS_KEY='$list_s'; aws s3 ls --endpoint-url '$r2_ep' --region auto 2>&1" 2>&1)"
if grep -q "$bucket" <<<"$out"; then ok "R2 bucket listing OK, '$bucket' present"
elif grep -q 'AccessDenied' <<<"$out"; then fail "R2 listing denied (no ListBuckets permission): $out"
elif grep -qE '^[^[:space:]]+[[:space:]]+' <<<"$out"; then warn "R2 listing returned (unexpected): $out"
else fail "R2 access failed: $out"; fi
else
warn "R2 credentials / endpoint not fully set; skipping R2 check"
fi
# Verify the specific application bucket is reachable with the primary key (read).
if [[ -n "$r2_key" && -n "$r2_secret" && -n "$r2_ep" && -n "$bucket" ]]; then
out="$(docker run --rm --entrypoint sh amazon/aws-cli:latest -c "export AWS_ACCESS_KEY_ID='$r2_key'; export AWS_SECRET_ACCESS_KEY='$r2_secret'; aws s3 ls s3://$bucket/ --endpoint-url '$r2_ep' --region auto --max-keys 5 2>&1 | head -5" 2>&1)"
if grep -q 'AccessDenied' <<<"$out"; then fail "Bucket '$bucket' read denied with primary key"
elif [[ -z "$out" ]]; then ok "Bucket '$bucket' reachable (empty listing, expected)"
elif grep -q 'NoSuchBucket' <<<"$out"; then fail "Bucket '$bucket' NOT FOUND"
else ok "Bucket '$bucket' reachable"; fi
elif [[ -z "$bucket" ]]; then
warn "R2_BUCKET_NAME not set; skipping bucket-level check"
fi
# --- 4. Linear Void admin login ---
echo ""
echo "-- Linear Void admin login --"
admin_user="$(get_val ADMIN_USERNAME)"
admin_pass="$(get_val ADMIN_PASSWORD)"
if [[ -n "$admin_user" && -n "$admin_pass" ]]; then
# Hit the chat backend directly (public origin is caught by nginx SPA fallback).
url="http://${TARGET}:3000/api/admin/login"
body="{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}"
wrong_body="{\"username\":\"$admin_user\",\"password\":\"__definitely_wrong__\"}"
ok_code="$(docker run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT" -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null | tr -d '[:space:]')"
wrong_code="$(docker run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT" -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$wrong_body" "$url" 2>/dev/null | tr -d '[:space:]')"
if [[ "$ok_code" == "200" && "$wrong_code" == "401" ]]; then ok "Admin credentials valid (correct=200, wrong=401)"
else fail "Admin login unexpected (correct=$ok_code, wrong=$wrong_code)"; fi
else
warn "ADMIN_USERNAME / ADMIN_PASSWORD not set; skipping admin check"
fi
# ---------------------------------------------------------------------------
echo ""
if [[ "$FAILED" -ne 0 ]]; then echo "RESULT: FAILED"; exit 1; fi
echo "RESULT: OK (no failing checks)"
exit 0
+20 -49
View File
@@ -2,66 +2,42 @@
These files back the [scaling/performance playbook](../../docs/operations/scaling-performance-playbook.md). These files back the [scaling/performance playbook](../../docs/operations/scaling-performance-playbook.md).
## WebSocket chat ## WebSocket chat (artillery)
Run with an explicit WebSocket URL, for example: Run with an explicit WebSocket URL, capturing output to an artifact file:
```bash ```bash
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml export WS_URL='wss://linearvoid.tv/ws?username=loadtest&room=main'
``` mkdir -p artifacts
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 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: Summarize key counters, rates, and available percentile stats from a saved Artillery JSON report:
```powershell ```bash
pwsh -File scripts/loadtest/summarize-artillery.ps1 -ReportFile artifacts/ws-chat-report.json scripts/loadtest/summarize-artillery.sh artifacts/ws-chat-report.json
``` ```
## Admin API ## Admin API (k6)
Run with a cookie or session header that can access admin endpoints: Run with a cookie or session header that can access admin endpoints, capturing machine-readable metrics and a text summary:
```bash ```bash
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run scripts/loadtest/admin-api.js export ADMIN_BASE_URL='https://linearvoid.tv'
``` export ADMIN_SESSION_COOKIE='session=...'
mkdir -p artifacts
Capture machine-readable metrics and a text summary: k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | tee artifacts/admin-api-output.txt
```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. 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: Summarize endpoint p95 and failure rates from a saved k6 JSON summary:
```powershell ```bash
pwsh -File scripts/loadtest/summarize-k6.ps1 -SummaryFile artifacts/admin-api-summary.json scripts/loadtest/summarize-k6.sh artifacts/admin-api-summary.json
``` ```
## PowerShell helper ## Runner helper
Use the helper to run one scenario or both and automatically write outputs into a dated artifact folder: Use the helper to run one scenario or both and automatically write outputs into a dated artifact folder:
@@ -69,16 +45,11 @@ Use the helper to run one scenario or both and automatically write outputs into
- WebSocket runs also emit `ws-chat-summary.txt` with connection/error counters and available summary percentiles. - 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. - Admin runs also emit `admin-api-endpoint-summary.txt` with per-endpoint p95/error values.
```powershell ```bash
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario ws -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main' scripts/loadtest/run-loadtests.sh -s ws -w 'wss://linearvoid.tv/ws?username=loadtest&room=main'
``` scripts/loadtest/run-loadtests.sh -s admin -a 'https://linearvoid.tv' -c 'session=...'
scripts/loadtest/run-loadtests.sh -s all -w 'wss://linearvoid.tv/ws?username=loadtest&room=main' \
```powershell -a 'https://linearvoid.tv' -c 'session=...'
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 ## Notes
+58 -58
View File
@@ -1,58 +1,58 @@
import http from "k6/http"; import http from "k6/http";
import { check, sleep } from "k6"; import { check, sleep } from "k6";
const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv"; const baseUrl = __ENV.ADMIN_BASE_URL || "https://linearvoid.tv";
const adminCookie = __ENV.ADMIN_SESSION_COOKIE || ""; const adminCookie = __ENV.ADMIN_SESSION_COOKIE || "";
const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {}; const requestParams = adminCookie ? { headers: { Cookie: adminCookie } } : {};
const endpoints = [ const endpoints = [
{ name: "overview", path: "/api/admin/overview" }, { name: "overview", path: "/api/admin/overview" },
{ name: "analytics", path: "/api/admin/analytics" }, { name: "analytics", path: "/api/admin/analytics" },
{ name: "chat_log", path: "/api/admin/chat-log" }, { name: "chat_log", path: "/api/admin/chat-log" },
{ name: "users", path: "/api/admin/users" }, { name: "users", path: "/api/admin/users" },
]; ];
export const options = { export const options = {
stages: [ stages: [
{ duration: "30s", target: 10 }, { duration: "30s", target: 10 },
{ duration: "60s", target: 50 }, { duration: "60s", target: 50 },
{ duration: "60s", target: 100 }, { duration: "60s", target: 100 },
{ duration: "30s", target: 0 }, { duration: "30s", target: 0 },
], ],
thresholds: { thresholds: {
http_req_duration: ["p(95)<1000"], http_req_duration: ["p(95)<1000"],
http_req_failed: ["rate<0.01"], http_req_failed: ["rate<0.01"],
"http_req_duration{endpoint:overview}": ["p(95)<1000"], "http_req_duration{endpoint:overview}": ["p(95)<1000"],
"http_req_duration{endpoint:analytics}": ["p(95)<1000"], "http_req_duration{endpoint:analytics}": ["p(95)<1000"],
"http_req_duration{endpoint:chat_log}": ["p(95)<1000"], "http_req_duration{endpoint:chat_log}": ["p(95)<1000"],
"http_req_duration{endpoint:users}": ["p(95)<1000"], "http_req_duration{endpoint:users}": ["p(95)<1000"],
"http_req_failed{endpoint:overview}": ["rate<0.01"], "http_req_failed{endpoint:overview}": ["rate<0.01"],
"http_req_failed{endpoint:analytics}": ["rate<0.01"], "http_req_failed{endpoint:analytics}": ["rate<0.01"],
"http_req_failed{endpoint:chat_log}": ["rate<0.01"], "http_req_failed{endpoint:chat_log}": ["rate<0.01"],
"http_req_failed{endpoint:users}": ["rate<0.01"], "http_req_failed{endpoint:users}": ["rate<0.01"],
}, },
}; };
export default function () { export default function () {
const requests = endpoints.map((endpoint) => [ const requests = endpoints.map((endpoint) => [
"GET", "GET",
`${baseUrl}${endpoint.path}`, `${baseUrl}${endpoint.path}`,
null, null,
{ {
...requestParams, ...requestParams,
tags: { tags: {
endpoint: endpoint.name, endpoint: endpoint.name,
}, },
}, },
]); ]);
const responses = http.batch(requests); const responses = http.batch(requests);
responses.forEach((response, index) => { responses.forEach((response, index) => {
const endpointPath = endpoints[index].path; const endpointPath = endpoints[index].path;
check(response, { check(response, {
[`status is 200 for ${endpointPath}`]: (r) => r.status === 200, [`status is 200 for ${endpointPath}`]: (r) => r.status === 200,
}); });
}); });
sleep(1); sleep(1);
} }
+98 -98
View File
@@ -1,99 +1,99 @@
#!/usr/bin/env pwsh #!/usr/bin/env pwsh
param( param(
[ValidateSet('ws', 'admin', 'all')] [ValidateSet('ws', 'admin', 'all')]
[string]$Scenario = 'all', [string]$Scenario = 'all',
[string]$ArtifactRoot = 'artifacts', [string]$ArtifactRoot = 'artifacts',
[string]$WsUrl = $env:WS_URL, [string]$WsUrl = $env:WS_URL,
[string]$AdminBaseUrl = $env:ADMIN_BASE_URL, [string]$AdminBaseUrl = $env:ADMIN_BASE_URL,
[string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE [string]$AdminSessionCookie = $env:ADMIN_SESSION_COOKIE
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-Path (Join-Path $scriptDir '..\..') $repoRoot = Resolve-Path (Join-Path $scriptDir '..\..')
$wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml' $wsScenarioPath = Join-Path $scriptDir 'ws-chat.yml'
$wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1' $wsSummaryScriptPath = Join-Path $scriptDir 'summarize-artillery.ps1'
$adminScriptPath = Join-Path $scriptDir 'admin-api.js' $adminScriptPath = Join-Path $scriptDir 'admin-api.js'
$adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1' $adminSummaryScriptPath = Join-Path $scriptDir 'summarize-k6.ps1'
if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) { if ([System.IO.Path]::IsPathRooted($ArtifactRoot)) {
$artifactBase = $ArtifactRoot $artifactBase = $ArtifactRoot
} else { } else {
$artifactBase = Join-Path $repoRoot $ArtifactRoot $artifactBase = Join-Path $repoRoot $ArtifactRoot
} }
if ($Scenario -in @('ws', 'all')) { if ($Scenario -in @('ws', 'all')) {
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) { if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
throw 'npx is required for ws scenario but was not found in PATH.' throw 'npx is required for ws scenario but was not found in PATH.'
} }
} }
if ($Scenario -in @('admin', 'all')) { if ($Scenario -in @('admin', 'all')) {
if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) { if (-not (Get-Command k6 -ErrorAction SilentlyContinue)) {
throw 'k6 is required for admin scenario but was not found in PATH.' throw 'k6 is required for admin scenario but was not found in PATH.'
} }
} }
$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss' $timestamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
$artifactDir = Join-Path $artifactBase $timestamp $artifactDir = Join-Path $artifactBase $timestamp
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
function Invoke-WebSocketLoadTest { function Invoke-WebSocketLoadTest {
param( param(
[string]$TargetWsUrl, [string]$TargetWsUrl,
[string]$OutputPath [string]$OutputPath
) )
if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) { if ([string]::IsNullOrWhiteSpace($TargetWsUrl)) {
throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.' throw 'WS_URL is required for ws scenario. Pass -WsUrl or set $env:WS_URL.'
} }
$env:WS_URL = $TargetWsUrl $env:WS_URL = $TargetWsUrl
& npx artillery run $wsScenarioPath --output $OutputPath & npx artillery run $wsScenarioPath --output $OutputPath
} }
function Invoke-AdminApiLoadTest { function Invoke-AdminApiLoadTest {
param( param(
[string]$BaseUrl, [string]$BaseUrl,
[string]$SessionCookie, [string]$SessionCookie,
[string]$SummaryPath, [string]$SummaryPath,
[string]$OutputPath [string]$OutputPath
) )
if ([string]::IsNullOrWhiteSpace($BaseUrl)) { if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.' throw 'ADMIN_BASE_URL is required for admin scenario. Pass -AdminBaseUrl or set $env:ADMIN_BASE_URL.'
} }
if ([string]::IsNullOrWhiteSpace($SessionCookie)) { if ([string]::IsNullOrWhiteSpace($SessionCookie)) {
throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.' throw 'ADMIN_SESSION_COOKIE is required for admin scenario. Pass -AdminSessionCookie or set $env:ADMIN_SESSION_COOKIE.'
} }
$env:ADMIN_BASE_URL = $BaseUrl $env:ADMIN_BASE_URL = $BaseUrl
$env:ADMIN_SESSION_COOKIE = $SessionCookie $env:ADMIN_SESSION_COOKIE = $SessionCookie
& k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath & k6 run --summary-export $SummaryPath $adminScriptPath | Tee-Object -FilePath $OutputPath
} }
if ($Scenario -in @('ws', 'all')) { if ($Scenario -in @('ws', 'all')) {
if (-not (Test-Path $wsSummaryScriptPath)) { if (-not (Test-Path $wsSummaryScriptPath)) {
throw "Missing summary helper: $wsSummaryScriptPath" throw "Missing summary helper: $wsSummaryScriptPath"
} }
$wsOutput = Join-Path $artifactDir 'ws-chat-report.json' $wsOutput = Join-Path $artifactDir 'ws-chat-report.json'
$wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt' $wsSummary = Join-Path $artifactDir 'ws-chat-summary.txt'
Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput Invoke-WebSocketLoadTest -TargetWsUrl $WsUrl -OutputPath $wsOutput
& pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary & pwsh -File $wsSummaryScriptPath -ReportFile $wsOutput | Tee-Object -FilePath $wsSummary
} }
if ($Scenario -in @('admin', 'all')) { if ($Scenario -in @('admin', 'all')) {
if (-not (Test-Path $adminSummaryScriptPath)) { if (-not (Test-Path $adminSummaryScriptPath)) {
throw "Missing summary helper: $adminSummaryScriptPath" throw "Missing summary helper: $adminSummaryScriptPath"
} }
$adminSummary = Join-Path $artifactDir 'admin-api-summary.json' $adminSummary = Join-Path $artifactDir 'admin-api-summary.json'
$adminOutput = Join-Path $artifactDir 'admin-api-output.txt' $adminOutput = Join-Path $artifactDir 'admin-api-output.txt'
$adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt' $adminEndpointSummary = Join-Path $artifactDir 'admin-api-endpoint-summary.txt'
Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput Invoke-AdminApiLoadTest -BaseUrl $AdminBaseUrl -SessionCookie $AdminSessionCookie -SummaryPath $adminSummary -OutputPath $adminOutput
& pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary & pwsh -File $adminSummaryScriptPath -SummaryFile $adminSummary | Tee-Object -FilePath $adminEndpointSummary
} }
Write-Host "Artifacts written to $artifactDir" Write-Host "Artifacts written to $artifactDir"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# run-loadtests.sh — Run an artillery (ws) and/or k6 (admin) load-test scenario
# and write outputs into a dated artifacts folder.
# Mirrors scripts/loadtest/run-loadtests.ps1.
#
# Usage:
# ./scripts/loadtest/run-loadtests.sh -s ws -w 'wss://...'
# ./scripts/loadtest/run-loadtests.sh -s admin -a 'https://...' -c 'session=...'
# ./scripts/loadtest/run-loadtests.sh -s all -w 'wss://...' -a 'https://...' -c 'session=...'
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
SCENARIO="all"
ARTIFACT_ROOT="artifacts"
WS_URL="${WS_URL:-}"
ADMIN_BASE_URL="${ADMIN_BASE_URL:-}"
ADMIN_SESSION_COOKIE="${ADMIN_SESSION_COOKIE:-}"
usage() {
echo "Usage: $0 [-s ws|admin|all] [-w ws_url] [-a admin_base_url] [-c admin_session_cookie] [-r artifact_root]" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
-s) SCENARIO="$2"; shift 2;;
-w) WS_URL="$2"; shift 2;;
-a) ADMIN_BASE_URL="$2"; shift 2;;
-c) ADMIN_SESSION_COOKIE="$2"; shift 2;;
-r) ARTIFACT_ROOT="$2"; shift 2;;
-h|--help) usage;;
*) echo "Unknown option: $1" >&2; usage;;
esac
done
case "$SCENARIO" in ws|admin|all) ;; *) echo "Scenario must be ws|admin|all" >&2; exit 2;; esac
if [[ "$SCENARIO" == ws || "$SCENARIO" == all ]] && ! command -v npx >/dev/null 2>&1; then
echo "npx is required for ws scenario but was not found in PATH." >&2; exit 1
fi
if [[ "$SCENARIO" == admin || "$SCENARIO" == all ]] && ! command -v k6 >/dev/null 2>&1; then
echo "k6 is required for admin scenario but was not found in PATH." >&2; exit 1
fi
if [[ "$ARTIFACT_ROOT" = /* ]]; then artifact_base="$ARTIFACT_ROOT"; else artifact_base="$repo_root/$ARTIFACT_ROOT"; fi
artifact_dir="$artifact_base/$(date +%Y-%m-%d_%H%M%S)"
mkdir -p "$artifact_dir"
if [[ "$SCENARIO" == ws || "$SCENARIO" == all ]]; then
[[ -n "$WS_URL" ]] || { echo "WS_URL is required for ws scenario. Pass -w or set \$WS_URL." >&2; exit 1; }
ws_out="$artifact_dir/ws-chat-report.json"
ws_sum="$artifact_dir/ws-chat-summary.txt"
echo "== Running artillery ws scenario =="
WS_URL="$WS_URL" npx artillery run "$script_dir/ws-chat.yml" --output "$ws_out"
"$script_dir/summarize-artillery.sh" "$ws_out" | tee "$ws_sum"
fi
if [[ "$SCENARIO" == admin || "$SCENARIO" == all ]]; then
[[ -n "$ADMIN_BASE_URL" ]] || { echo "ADMIN_BASE_URL is required for admin scenario. Pass -a or set \$ADMIN_BASE_URL." >&2; exit 1; }
[[ -n "$ADMIN_SESSION_COOKIE" ]] || { echo "ADMIN_SESSION_COOKIE is required for admin scenario. Pass -c or set \$ADMIN_SESSION_COOKIE." >&2; exit 1; }
admin_sum="$artifact_dir/admin-api-summary.json"
admin_out="$artifact_dir/admin-api-output.txt"
admin_ep_sum="$artifact_dir/admin-api-endpoint-summary.txt"
echo "== Running k6 admin scenario =="
ADMIN_BASE_URL="$ADMIN_BASE_URL" ADMIN_SESSION_COOKIE="$ADMIN_SESSION_COOKIE" \
k6 run --summary-export "$admin_sum" "$script_dir/admin-api.js" | tee "$admin_out"
"$script_dir/summarize-k6.sh" "$admin_sum" | tee "$admin_ep_sum"
fi
echo "Artifacts written to $artifact_dir"
+115 -115
View File
@@ -1,115 +1,115 @@
#!/usr/bin/env pwsh #!/usr/bin/env pwsh
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ReportFile [string]$ReportFile
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
if (-not (Test-Path $ReportFile)) { if (-not (Test-Path $ReportFile)) {
throw "Report file not found: $ReportFile" throw "Report file not found: $ReportFile"
} }
$report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100 $report = Get-Content -Raw -Path $ReportFile | ConvertFrom-Json -Depth 100
$aggregate = $report.aggregate $aggregate = $report.aggregate
if (-not $aggregate) { if (-not $aggregate) {
throw 'Artillery report is missing aggregate data.' throw 'Artillery report is missing aggregate data.'
} }
function Get-PropertyValue { function Get-PropertyValue {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[object]$Object, [object]$Object,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Name [string]$Name
) )
if (-not $Object) { if (-not $Object) {
return $null return $null
} }
$property = $Object.PSObject.Properties[$Name] $property = $Object.PSObject.Properties[$Name]
if ($property) { if ($property) {
return $property.Value return $property.Value
} }
return $null return $null
} }
function Format-StatLine { function Format-StatLine {
param( param(
[string]$Prefix, [string]$Prefix,
[object]$Stats [object]$Stats
) )
if (-not $Stats) { if (-not $Stats) {
return $null return $null
} }
$parts = @() $parts = @()
foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) { foreach ($name in @('count', 'min', 'max', 'mean', 'median', 'p95', 'p99')) {
$value = Get-PropertyValue -Object $Stats -Name $name $value = Get-PropertyValue -Object $Stats -Name $name
if ($null -ne $value) { if ($null -ne $value) {
$parts += "$name=$value" $parts += "$name=$value"
} }
} }
if ($parts.Count -eq 0) { if ($parts.Count -eq 0) {
return $null return $null
} }
return "$Prefix: $($parts -join ', ')" return "$Prefix: $($parts -join ', ')"
} }
$counters = Get-PropertyValue -Object $aggregate -Name 'counters' $counters = Get-PropertyValue -Object $aggregate -Name 'counters'
$rates = Get-PropertyValue -Object $aggregate -Name 'rates' $rates = Get-PropertyValue -Object $aggregate -Name 'rates'
$summaries = Get-PropertyValue -Object $aggregate -Name 'summaries' $summaries = Get-PropertyValue -Object $aggregate -Name 'summaries'
$created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0) $created = [double](Get-PropertyValue -Object $counters -Name 'vusers.created' ?? 0)
$failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0) $failed = [double](Get-PropertyValue -Object $counters -Name 'vusers.failed' ?? 0)
$completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0) $completed = [double](Get-PropertyValue -Object $counters -Name 'vusers.completed' ?? 0)
$errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 } $errorRate = if ($created -gt 0) { [math]::Round(($failed / $created) * 100, 2) } else { 0 }
Write-Output '=== Artillery WebSocket Summary ===' Write-Output '=== Artillery WebSocket Summary ==='
Write-Output "source: $ReportFile" Write-Output "source: $ReportFile"
$timestamp = Get-PropertyValue -Object $report -Name 'timestamp' $timestamp = Get-PropertyValue -Object $report -Name 'timestamp'
if ($timestamp) { if ($timestamp) {
Write-Output "timestamp: $timestamp" Write-Output "timestamp: $timestamp"
} }
if ($null -ne $created) { if ($null -ne $created) {
Write-Output "vusers.created: $created" Write-Output "vusers.created: $created"
} }
if ($null -ne $completed) { if ($null -ne $completed) {
Write-Output "vusers.completed: $completed" Write-Output "vusers.completed: $completed"
} }
if ($null -ne $failed) { if ($null -ne $failed) {
Write-Output "vusers.failed: $failed" Write-Output "vusers.failed: $failed"
} }
Write-Output "vusers.failure_rate_percent: $errorRate" Write-Output "vusers.failure_rate_percent: $errorRate"
if ($counters) { if ($counters) {
foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) { foreach ($metric in @('ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time')) {
$value = Get-PropertyValue -Object $counters -Name $metric $value = Get-PropertyValue -Object $counters -Name $metric
if ($null -ne $value) { if ($null -ne $value) {
Write-Output "$metric: $value" Write-Output "$metric: $value"
} }
} }
} }
if ($rates) { if ($rates) {
foreach ($property in $rates.PSObject.Properties) { foreach ($property in $rates.PSObject.Properties) {
Write-Output "rate.$($property.Name): $($property.Value)" Write-Output "rate.$($property.Name): $($property.Value)"
} }
} }
if ($summaries) { if ($summaries) {
foreach ($property in $summaries.PSObject.Properties) { foreach ($property in $summaries.PSObject.Properties) {
$line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value $line = Format-StatLine -Prefix "summary.$($property.Name)" -Stats $property.Value
if ($line) { if ($line) {
Write-Output $line Write-Output $line
} }
} }
} }
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# summarize-artillery.sh — Extract counters, rates and percentile stats from an
# Artillery JSON report. Mirrors scripts/loadtest/summarize-artillery.ps1.
# Usage: ./scripts/loadtest/summarize-artillery.sh <artillery-report.json>
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <artillery-report.json>" >&2
exit 2
fi
report_file="$1"
[[ -f "$report_file" ]] || { echo "Report file not found: $report_file" >&2; exit 1; }
REPORT_FILE="$report_file" node <<'EOF'
const fs = require('fs');
const report = JSON.parse(fs.readFileSync(process.env.REPORT_FILE, 'utf8'));
const aggregate = report.aggregate;
if (!aggregate) throw new Error('Artillery report is missing aggregate data.');
const get = (obj, name) => (obj && obj[name] !== undefined) ? obj[name] : undefined;
const counters = aggregate.counters || {};
const rates = aggregate.rates || {};
const summaries = aggregate.summaries || {};
const created = Number(get(counters, 'vusers.created') || 0);
const failed = Number(get(counters, 'vusers.failed') || 0);
const completed = Number(get(counters, 'vusers.completed') || 0);
const errorRate = created > 0 ? +((failed / created) * 100).toFixed(2) : 0;
console.log('=== Artillery WebSocket Summary ===');
console.log(`source: ${process.env.REPORT_FILE}`);
if (report.timestamp) console.log(`timestamp: ${report.timestamp}`);
console.log(`vusers.created: ${created}`);
console.log(`vusers.completed: ${completed}`);
console.log(`vusers.failed: ${failed}`);
console.log(`vusers.failure_rate_percent: ${errorRate}`);
for (const metric of ['ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time']) {
const v = get(counters, metric);
if (v !== undefined) console.log(`${metric}: ${v}`);
}
for (const [k, v] of Object.entries(rates || {})) console.log(`rate.${k}: ${v}`);
const fmt = (prefix, stats) => {
if (!stats) return;
const parts = [];
for (const name of ['count', 'min', 'max', 'mean', 'median', 'p95', 'p99']) {
const v = get(stats, name);
if (v !== undefined) parts.push(`${name}=${v}`);
}
if (parts.length) console.log(`${prefix}: ${parts.join(', ')}`);
};
for (const [k, v] of Object.entries(summaries)) fmt(`summary.${k}`, v);
EOF
+68 -68
View File
@@ -1,68 +1,68 @@
#!/usr/bin/env pwsh #!/usr/bin/env pwsh
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$SummaryFile [string]$SummaryFile
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
if (-not (Test-Path $SummaryFile)) { if (-not (Test-Path $SummaryFile)) {
throw "Summary file not found: $SummaryFile" throw "Summary file not found: $SummaryFile"
} }
$summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json $summary = Get-Content -Raw -Path $SummaryFile | ConvertFrom-Json
$metrics = $summary.metrics $metrics = $summary.metrics
if (-not $metrics) { if (-not $metrics) {
throw 'Invalid k6 summary file: missing metrics object.' throw 'Invalid k6 summary file: missing metrics object.'
} }
$endpoints = @('overview', 'analytics', 'chat_log', 'users') $endpoints = @('overview', 'analytics', 'chat_log', 'users')
function Get-MetricValue { function Get-MetricValue {
param( param(
[object]$Metric, [object]$Metric,
[string]$Key [string]$Key
) )
if (-not $Metric) { if (-not $Metric) {
return $null return $null
} }
if (-not $Metric.values) { if (-not $Metric.values) {
return $null return $null
} }
$value = $Metric.values.$Key $value = $Metric.values.$Key
if ($null -ne $value) { if ($null -ne $value) {
return [double]$value return [double]$value
} }
return $null return $null
} }
$globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)' $globalDuration = Get-MetricValue -Metric $metrics.http_req_duration -Key 'p(95)'
$globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate' $globalFailRate = Get-MetricValue -Metric $metrics.http_req_failed -Key 'rate'
Write-Output "k6 Summary: $SummaryFile" Write-Output "k6 Summary: $SummaryFile"
if ($null -ne $globalDuration) { if ($null -ne $globalDuration) {
Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration) Write-Output ("Global p95 duration (ms): {0:N2}" -f $globalDuration)
} }
if ($null -ne $globalFailRate) { if ($null -ne $globalFailRate) {
Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate) Write-Output ("Global failure rate: {0:P2}" -f $globalFailRate)
} }
Write-Output '' Write-Output ''
$rows = foreach ($endpoint in $endpoints) { $rows = foreach ($endpoint in $endpoints) {
$durationMetricName = "http_req_duration{endpoint:$endpoint}" $durationMetricName = "http_req_duration{endpoint:$endpoint}"
$failMetricName = "http_req_failed{endpoint:$endpoint}" $failMetricName = "http_req_failed{endpoint:$endpoint}"
$durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)' $durationP95 = Get-MetricValue -Metric $metrics.$durationMetricName -Key 'p(95)'
$failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate' $failRate = Get-MetricValue -Metric $metrics.$failMetricName -Key 'rate'
[PSCustomObject]@{ [PSCustomObject]@{
Endpoint = $endpoint Endpoint = $endpoint
P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null } P95Ms = if ($null -ne $durationP95) { [math]::Round($durationP95, 2) } else { $null }
FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null } FailurePct = if ($null -ne $failRate) { [math]::Round($failRate * 100, 4) } else { $null }
} }
} }
$rows | Format-Table -AutoSize | Out-String | Write-Output $rows | Format-Table -AutoSize | Out-String | Write-Output
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# summarize-k6.sh — Extract per-endpoint p95 and failure rates from a k6 JSON
# summary export. Mirrors scripts/loadtest/summarize-k6.ps1.
# Usage: ./scripts/loadtest/summarize-k6.sh <k6-summary.json>
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <k6-summary.json>" >&2
exit 2
fi
summary_file="$1"
[[ -f "$summary_file" ]] || { echo "Summary file not found: $summary_file" >&2; exit 1; }
SUMMARY_FILE="$summary_file" node <<'EOF'
const fs = require('fs');
const summary = JSON.parse(fs.readFileSync(process.env.SUMMARY_FILE, 'utf8'));
const metrics = summary.metrics;
if (!metrics) throw new Error('Invalid k6 summary file: missing metrics object.');
const val = (m, key) => (m && m.values && m.values[key] !== undefined) ? Number(m.values[key]) : undefined;
const globalDuration = val(metrics.http_req_duration, 'p(95)');
const globalFailRate = val(metrics.http_req_failed, 'rate');
console.log(`k6 Summary: ${process.env.SUMMARY_FILE}`);
if (globalDuration !== undefined) console.log(`Global p95 duration (ms): ${globalDuration.toFixed(2)}`);
if (globalFailRate !== undefined) console.log(`Global failure rate: ${(globalFailRate * 100).toFixed(2)}%`);
console.log('');
const endpoints = ['overview', 'analytics', 'chat_log', 'users'];
const rows = endpoints.map((ep) => {
const dP95 = val(metrics[`http_req_duration{endpoint:${ep}}`], 'p(95)');
const fRate = val(metrics[`http_req_failed{endpoint:${ep}}`], 'rate');
return {
Endpoint: ep,
P95Ms: dP95 !== undefined ? dP95.toFixed(2) : 'n/a',
FailurePct: fRate !== undefined ? (fRate * 100).toFixed(4) : 'n/a',
};
});
const pad = (s, n) => String(s).padEnd(n);
console.log(pad('ENDPOINT', 12) + pad('P95_MS', 10) + 'FAILURE_PCT');
for (const r of rows) console.log(pad(r.Endpoint, 12) + pad(r.P95Ms, 10) + r.FailurePct);
EOF
+28 -28
View File
@@ -1,28 +1,28 @@
config: config:
target: "{{ $processEnvironment.WS_URL }}" target: "{{ $processEnvironment.WS_URL }}"
phases: phases:
- duration: 60 - duration: 60
arrivalRate: 10 arrivalRate: 10
name: warmup-10 name: warmup-10
- duration: 120 - duration: 120
arrivalRate: 100 arrivalRate: 100
name: sustain-100 name: sustain-100
- duration: 180 - duration: 180
arrivalRate: 500 arrivalRate: 500
name: sustain-500 name: sustain-500
- duration: 240 - duration: 240
arrivalRate: 1000 arrivalRate: 1000
name: sustain-1000 name: sustain-1000
engines: engines:
ws: {} ws: {}
scenarios: scenarios:
- engine: ws - engine: ws
name: chat-presence-and-send name: chat-presence-and-send
flow: flow:
- think: 1 - think: 1
- send: - send:
text: '{"type":"ping","text":"loadtest"}' text: '{"type":"ping","text":"loadtest"}'
- think: 1 - think: 1
- send: - send:
text: '{"type":"ping","text":"loadtest-2"}' text: '{"type":"ping","text":"loadtest-2"}'
- think: 1 - think: 1