refactor: modularize chat backend, update docs for .m4s segments
This commit is contained in:
1 parent
46e878bef6
commit
b3a10ba3ef
24 files changed
+3770
-2780
No files matched your search
@@ -41,6 +41,7 @@ out/
|
|||||||
# FFmpeg temporary files
|
# FFmpeg temporary files
|
||||||
*.ts.tmp
|
*.ts.tmp
|
||||||
*.m3u8.tmp
|
*.m3u8.tmp
|
||||||
|
*.m4s.tmp
|
||||||
hls/
|
hls/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,15 @@
|
|||||||
- **CI pipeline**: Removed redundant `release` job (handled by `release.yml`).
|
- **CI pipeline**: Removed redundant `release` job (handled by `release.yml`).
|
||||||
- **Tag format**: Standardized on `*.*.*` (e.g. `1.0.9`), removed `v` prefix from existing tags.
|
- **Tag format**: Standardized on `*.*.*` (e.g. `1.0.9`), removed `v` prefix from existing tags.
|
||||||
- **DEPLOYMENT.md**: Documented parallel CI/CD pipeline flow with workflow table.
|
- **DEPLOYMENT.md**: Documented parallel CI/CD pipeline flow with workflow table.
|
||||||
|
- **Runtime checks**: Executed normalization edge-case tests (MOV, MKV, VFR, 5.1 input) and concat continuity verification in running playout environment.
|
||||||
|
- **Incident runbook**: Added quarterly disaster recovery drill procedure with prerequisites, outage simulation, restore validation, and evidence template.
|
||||||
|
- **Manual ops checklist**: Added `docs/operations/cloudflare-uptimerobot-checklist.md` to execute and record Cloudflare + UptimeRobot dashboard tasks with evidence capture.
|
||||||
|
- **Scaling playbook**: Added `docs/operations/scaling-performance-playbook.md` with benchmark methods, trigger thresholds, and Phase 2/3 scaling decision flow.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Prometheus config mount**: Switched prod from bind mounts to named volume `prometheus-config`, populated via `docker run` on remote host. Fixes `not a directory` error when bind source path doesn't exist on deployment host.
|
- **Prometheus config mount**: Switched prod from bind mounts to named volume `prometheus-config`, populated via `docker run` on remote host. Fixes `not a directory` error when bind source path doesn't exist on deployment host.
|
||||||
|
- **Viewer mobile overflow**: Replaced `100vw` with `100%` on full-screen viewer containers to remove horizontal overflow on mobile viewports.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses:
|
The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses:
|
||||||
|
|
||||||
- Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat _(planned production; currently test lab at 192.168.88.40)_.
|
- Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat _(planned production; currently test lab at 192.168.88.40)_.
|
||||||
- Cloudflare R2 + CDN with aggressive immutable caching of HLS `.ts` segments ($0 egress).
|
- Cloudflare R2 + CDN with aggressive immutable caching of `.m4s` segments ($0 egress).
|
||||||
- Lightweight static frontend and WebSocket chat backend.
|
- Lightweight static frontend and WebSocket chat backend.
|
||||||
- Gitea self-hosted CI/CD (Actions) and Container Registry.
|
- Gitea self-hosted CI/CD (Actions) and Container Registry.
|
||||||
|
|
||||||
@@ -211,6 +211,7 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
|
|||||||
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
|
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
|
||||||
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
|
||||||
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
|
||||||
|
- [Scaling Playbook](docs/operations/scaling-performance-playbook.md) - Performance test method, thresholds, and scaling decision flow.
|
||||||
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
|
||||||
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
|
||||||
|
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ flowchart LR
|
|||||||
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
|
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
|
||||||
|
|
||||||
6. **Apply cache behavior**:
|
6. **Apply cache behavior**:
|
||||||
- Configure the `.ts` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
- Configure the `.m4s` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
|
||||||
- Enable Smart Tiered Cache for the R2-backed hostname.
|
- Enable Smart Tiered Cache for the R2-backed hostname.
|
||||||
- If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone.
|
- If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone.
|
||||||
|
|
||||||
@@ -235,6 +235,8 @@ flowchart LR
|
|||||||
|
|
||||||
Use this section for full edge setup (DNS, Pages, cache, security, and monitoring).
|
Use this section for full edge setup (DNS, Pages, cache, security, and monitoring).
|
||||||
|
|
||||||
|
Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
||||||
|
|
||||||
### 1. DNS Setup
|
### 1. DNS Setup
|
||||||
|
|
||||||
1. Add apex record for `linearvoid.tv` pointing to Cloudflare Pages target.
|
1. Add apex record for `linearvoid.tv` pointing to Cloudflare Pages target.
|
||||||
@@ -262,7 +264,12 @@ Configure Cloudflare Cache Rules:
|
|||||||
- Cache: eligible
|
- Cache: eligible
|
||||||
- Edge TTL: `2s`
|
- Edge TTL: `2s`
|
||||||
- Revalidation: enabled
|
- Revalidation: enabled
|
||||||
3. Validate behavior using response headers:
|
3. Static assets (`*.js`, `*.css`):
|
||||||
|
- Cache: eligible
|
||||||
|
- Edge TTL: `1y`
|
||||||
|
- Browser TTL: long-lived
|
||||||
|
- Cache key: include full path
|
||||||
|
4. Validate behavior using response headers:
|
||||||
- `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
|
- `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
|
||||||
|
|
||||||
Reference: `infrastructure/cloudflare/cache-rules.md`.
|
Reference: `infrastructure/cloudflare/cache-rules.md`.
|
||||||
@@ -564,7 +571,7 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs
|
|||||||
- **R2 Upload Fails**: Check credentials in `.env` and ensure the bucket exists. Verify with `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
|
- **R2 Upload Fails**: Check credentials in `.env` and ensure the bucket exists. Verify with `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
|
||||||
- **Normalization Errors**: Run `backend/playout/start-playout.sh` manually to see ffprobe output. All videos must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001).
|
- **Normalization Errors**: Run `backend/playout/start-playout.sh` manually to see ffprobe output. All videos must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001).
|
||||||
- **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
|
- **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
|
||||||
- **Cache Not Working**: Verify Cloudflare cache rules (`.ts` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
|
- **Cache Not Working**: Verify Cloudflare cache rules (`.m4s` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
|
||||||
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
|
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
|
||||||
- **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow.
|
- **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow.
|
||||||
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
|
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionStore manages admin authentication sessions.
|
||||||
|
type SessionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionStore creates a new session store.
|
||||||
|
func NewSessionStore() *SessionStore {
|
||||||
|
return &SessionStore{
|
||||||
|
sessions: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken creates a cryptographically random 64-char hex session token.
|
||||||
|
func GenerateToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if a session token is valid and not expired.
|
||||||
|
// Returns true if valid, false otherwise. Expired sessions are cleaned up.
|
||||||
|
func (s *SessionStore) Validate(token string) bool {
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
expiresAt, ok := s.sessions[token]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
delete(s.sessions, token)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stores a session token with expiry.
|
||||||
|
func (s *SessionStore) Add(token string, expiresAt time.Time) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sessions[token] = expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes a session token.
|
||||||
|
func (s *SessionStore) Remove(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.sessions, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountActive returns the number of unexpired sessions.
|
||||||
|
func (s *SessionStore) CountActive() int {
|
||||||
|
now := time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for token, expiresAt := range s.sessions {
|
||||||
|
if now.After(expiresAt) {
|
||||||
|
delete(s.sessions, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s.sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CookieSecure determines if the session cookie should have Secure flag.
|
||||||
|
func CookieSecure(r *http.Request) bool {
|
||||||
|
if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
|
||||||
|
return strings.EqualFold(forwardedProto, "https")
|
||||||
|
}
|
||||||
|
return r.TLS != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCookie writes the admin session cookie.
|
||||||
|
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Secure: CookieSecure(r),
|
||||||
|
Expires: expiresAt,
|
||||||
|
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearCookie removes the admin session cookie.
|
||||||
|
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Secure: CookieSecure(r),
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenFromCookie extracts the session token from the request cookie.
|
||||||
|
func GetTokenFromCookie(r *http.Request, cookieName string) (string, bool) {
|
||||||
|
cookie, err := r.Cookie(cookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return cookie.Value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAdmin returns middleware that checks for a valid admin session.
|
||||||
|
func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := GetTokenFromCookie(r, cookieName)
|
||||||
|
if !ok || !store.Validate(token) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defaults and constants
|
||||||
|
const (
|
||||||
|
AdminSessionCookieName = "linearvoid_admin_session"
|
||||||
|
MaxAdminUploadBytes int64 = 2 * 1024 * 1024 * 1024
|
||||||
|
DefaultAdminChunkSize int64 = 8 * 1024 * 1024
|
||||||
|
MaxAdminChunkSize int64 = 32 * 1024 * 1024
|
||||||
|
DefaultRateLimitMax = 10
|
||||||
|
DefaultRateLimitWindow = 10 * time.Second
|
||||||
|
DefaultAdminSessionTTL = 12 * time.Hour
|
||||||
|
DefaultChatPort = "3000"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlaylistPath returns the playlist file path from env or default.
|
||||||
|
func PlaylistPath() string {
|
||||||
|
p := strings.TrimSpace(os.Getenv("PLAYLIST_PATH"))
|
||||||
|
if p == "" {
|
||||||
|
return "/app/playlist/playlist.txt"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaDir returns the media directory from env or default.
|
||||||
|
func MediaDir() string {
|
||||||
|
d := strings.TrimSpace(os.Getenv("MEDIA_DIR"))
|
||||||
|
if d == "" {
|
||||||
|
return "/app/media"
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUsername returns the admin username from env or default.
|
||||||
|
func AdminUsername() string {
|
||||||
|
u := strings.TrimSpace(os.Getenv("ADMIN_USERNAME"))
|
||||||
|
if u == "" {
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPassword returns the admin password from env or default.
|
||||||
|
func AdminPassword() string {
|
||||||
|
p := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD"))
|
||||||
|
if p == "" {
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSessionTTL returns the admin session TTL from env or default.
|
||||||
|
func AdminSessionTTL() time.Duration {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS"))
|
||||||
|
if raw == "" {
|
||||||
|
return DefaultAdminSessionTTL
|
||||||
|
}
|
||||||
|
hours, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || hours <= 0 {
|
||||||
|
return DefaultAdminSessionTTL
|
||||||
|
}
|
||||||
|
return time.Duration(hours) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitMax returns the chat rate limit max messages per window.
|
||||||
|
func RateLimitMax() int {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
|
||||||
|
if raw == "" {
|
||||||
|
return DefaultRateLimitMax
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return DefaultRateLimitMax
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitWindow returns the chat rate limit window duration.
|
||||||
|
func RateLimitWindow() time.Duration {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_WINDOW_S"))
|
||||||
|
if raw == "" {
|
||||||
|
return DefaultRateLimitWindow
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return DefaultRateLimitWindow
|
||||||
|
}
|
||||||
|
return time.Duration(v) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPort returns the chat service port from env or default.
|
||||||
|
func ChatPort() string {
|
||||||
|
p := strings.TrimSpace(os.Getenv("CHAT_PORT"))
|
||||||
|
if p == "" {
|
||||||
|
return DefaultChatPort
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisAddress returns the Redis address from env or default.
|
||||||
|
func RedisAddress() string {
|
||||||
|
a := strings.TrimSpace(os.Getenv("REDIS_URL"))
|
||||||
|
if a == "" {
|
||||||
|
return "redis:6379"
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseURL returns the database URL from env (empty = SQLite default).
|
||||||
|
func DatabaseURL() string {
|
||||||
|
return strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DBClient wraps gorm.DB for testability.
|
||||||
|
type DBClient struct {
|
||||||
|
*gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006).
|
||||||
|
// Returns the client and the driver name.
|
||||||
|
func InitDB(databaseURL string) (*DBClient, string, error) {
|
||||||
|
var (
|
||||||
|
gormDB *gorm.DB
|
||||||
|
err error
|
||||||
|
driver = "sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case databaseURL == "":
|
||||||
|
gormDB, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
|
||||||
|
case strings.HasPrefix(databaseURL, "sqlite://"):
|
||||||
|
sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://")
|
||||||
|
if sqlitePath == "" {
|
||||||
|
sqlitePath = "chat.db"
|
||||||
|
}
|
||||||
|
gormDB, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
||||||
|
case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"):
|
||||||
|
driver = "postgres"
|
||||||
|
gormDB, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, driver, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
||||||
|
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
||||||
|
|
||||||
|
return &DBClient{DB: gormDB}, driver, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisClient wraps the Redis client for testability.
|
||||||
|
// Interface defined for mocking in tests.
|
||||||
|
type RedisClient interface {
|
||||||
|
Ping(ctx context.Context) error
|
||||||
|
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||||
|
Get(ctx context.Context, key string) (string, error)
|
||||||
|
Del(ctx context.Context, keys ...string) error
|
||||||
|
Incr(ctx context.Context, key string) (int64, error)
|
||||||
|
Decr(ctx context.Context, key string) (int64, error)
|
||||||
|
Expire(ctx context.Context, key string, expiration time.Duration) error
|
||||||
|
Exists(ctx context.Context, keys ...string) (int64, error)
|
||||||
|
Publish(ctx context.Context, channel string, message interface{}) error
|
||||||
|
PSubscribe(ctx context.Context, channels ...string) PubSub
|
||||||
|
Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error)
|
||||||
|
Info(ctx context.Context, sections ...string) (string, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PubSub wraps Redis pub/sub subscription.
|
||||||
|
type PubSub interface {
|
||||||
|
ReceiveMessage(ctx context.Context) (*PubSubMessage, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PubSubMessage represents a received pub/sub message.
|
||||||
|
type PubSubMessage struct {
|
||||||
|
Channel string
|
||||||
|
Payload string
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message represents a chat message (persisted in SQL DB per ADR-006)
|
||||||
|
type Message struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Room string `json:"room"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Type string `json:"type"` // "public", "private", "system", "moderation"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModerationLog for audit trail (immutable per ADR-005)
|
||||||
|
type ModerationLog struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Action string `json:"action"` // "mute", "ban", "delete", "timeout"
|
||||||
|
Target string `json:"target"`
|
||||||
|
Moderator string `json:"moderator"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media stores admin media library metadata (filesystem + DB model per ADR-006)
|
||||||
|
type Media struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Filename string `gorm:"index" json:"filename"`
|
||||||
|
OriginalName string `json:"originalName"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
SizeBytes int64 `json:"sizeBytes"`
|
||||||
|
DurationSeconds int `json:"durationSeconds"`
|
||||||
|
Status string `gorm:"index" json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
||||||
|
type ScheduleOverride struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
MediaID uint `json:"mediaId"`
|
||||||
|
StartTime time.Time `json:"startTime"`
|
||||||
|
EndTime time.Time `json:"endTime"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presence tracks online users per room (using Redis per ADR-007)
|
||||||
|
type Presence struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Room string `json:"room"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedisWrapper wraps go-redis client to implement RedisClient interface.
|
||||||
|
type RedisWrapper struct {
|
||||||
|
client *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRedisWrapper creates a RedisWrapper from a go-redis client.
|
||||||
|
func NewRedisWrapper(client *redis.Client) *RedisWrapper {
|
||||||
|
return &RedisWrapper{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007).
|
||||||
|
// Returns nil if Redis is unavailable (non-fatal).
|
||||||
|
func InitRedis(redisAddress string) *RedisWrapper {
|
||||||
|
var options *redis.Options
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if strings.Contains(redisAddress, "://") {
|
||||||
|
options, err = redis.ParseURL(redisAddress)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Redis configuration warning: %v (rate limiting and presence will be disabled)", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
options = &redis.Options{Addr: redisAddress}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := redis.NewClient(options)
|
||||||
|
if err := client.Ping(context.Background()).Err(); err != nil {
|
||||||
|
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
|
||||||
|
client.Close()
|
||||||
|
// Start auto-reconnect goroutine
|
||||||
|
go redisReconnectLoop(redisAddress, options)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
|
||||||
|
return &RedisWrapper{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redisReconnectLoop attempts to reconnect to Redis with exponential backoff.
|
||||||
|
func redisReconnectLoop(addr string, baseOptions *redis.Options) {
|
||||||
|
backoff := 2 * time.Second
|
||||||
|
maxBackoff := 60 * time.Second
|
||||||
|
for {
|
||||||
|
time.Sleep(backoff)
|
||||||
|
testClient := redis.NewClient(baseOptions)
|
||||||
|
if err := testClient.Ping(context.Background()).Err(); err != nil {
|
||||||
|
log.Printf("Redis reconnect failed: %v (retry in %v)", err, backoff)
|
||||||
|
backoff *= 2
|
||||||
|
if backoff > maxBackoff {
|
||||||
|
backoff = maxBackoff
|
||||||
|
}
|
||||||
|
testClient.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Reconnected — caller must replace their reference.
|
||||||
|
// This is a best-effort background reconnect; the wrapper
|
||||||
|
// returned by InitRedis remains nil until a new InitRedis call.
|
||||||
|
log.Println("Redis reconnected successfully (background)")
|
||||||
|
testClient.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping checks Redis connectivity.
|
||||||
|
func (r *RedisWrapper) Ping(ctx context.Context) error {
|
||||||
|
if r == nil || r.client == nil {
|
||||||
|
return redis.ErrClosed
|
||||||
|
}
|
||||||
|
return r.client.Ping(ctx).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a key with TTL.
|
||||||
|
func (r *RedisWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||||
|
return r.client.Set(ctx, key, value, expiration).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a key.
|
||||||
|
func (r *RedisWrapper) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
return r.client.Get(ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Del removes keys.
|
||||||
|
func (r *RedisWrapper) Del(ctx context.Context, keys ...string) error {
|
||||||
|
return r.client.Del(ctx, keys...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incr increments a key.
|
||||||
|
func (r *RedisWrapper) Incr(ctx context.Context, key string) (int64, error) {
|
||||||
|
return r.client.Incr(ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decr decrements a key.
|
||||||
|
func (r *RedisWrapper) Decr(ctx context.Context, key string) (int64, error) {
|
||||||
|
return r.client.Decr(ctx, key).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire sets TTL on a key.
|
||||||
|
func (r *RedisWrapper) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||||
|
return r.client.Expire(ctx, key, expiration).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if keys exist.
|
||||||
|
func (r *RedisWrapper) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||||
|
return r.client.Exists(ctx, keys...).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish publishes a message to a channel.
|
||||||
|
func (r *RedisWrapper) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||||
|
return r.client.Publish(ctx, channel, message).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PSubscribe subscribes to channels by pattern.
|
||||||
|
func (r *RedisWrapper) PSubscribe(ctx context.Context, channels ...string) PubSub {
|
||||||
|
ps := r.client.PSubscribe(ctx, channels...)
|
||||||
|
return &redisPubSubWrapper{ps: ps}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan scans keys matching a pattern.
|
||||||
|
func (r *RedisWrapper) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||||
|
return r.client.Scan(ctx, cursor, match, count).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info returns Redis server info.
|
||||||
|
func (r *RedisWrapper) Info(ctx context.Context, sections ...string) (string, error) {
|
||||||
|
return r.client.Info(ctx, sections...).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the Redis connection.
|
||||||
|
func (r *RedisWrapper) Close() error {
|
||||||
|
if r == nil || r.client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// redisPubSubWrapper wraps go-redis PubSub.
|
||||||
|
type redisPubSubWrapper struct {
|
||||||
|
ps *redis.PubSub
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisPubSubWrapper) ReceiveMessage(ctx context.Context) (*PubSubMessage, error) {
|
||||||
|
msg, err := w.ps.ReceiveMessage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PubSubMessage{Channel: msg.Channel, Payload: msg.Payload}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *redisPubSubWrapper) Close() error {
|
||||||
|
return w.ps.Close()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
+70
-2727
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,385 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatDuration converts seconds to HH:MM:SS string.
|
||||||
|
func FormatDuration(seconds int) string {
|
||||||
|
if seconds < 0 {
|
||||||
|
seconds = 0
|
||||||
|
}
|
||||||
|
hours := seconds / 3600
|
||||||
|
minutes := (seconds % 3600) / 60
|
||||||
|
remainingSeconds := seconds % 60
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizationResult holds ffprobe validation output for admin uploads.
|
||||||
|
type NormalizationResult struct {
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateNormalization runs ffprobe against a media file and checks
|
||||||
|
// conformance with the Linear Void normalization profile (ADR-001).
|
||||||
|
func ValidateNormalization(mediaPath string) NormalizationResult {
|
||||||
|
result := NormalizationResult{Valid: true, Errors: []string{}}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := exec.CommandContext(ctx,
|
||||||
|
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", mediaPath,
|
||||||
|
).CombinedOutput()
|
||||||
|
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return NormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return NormalizationResult{Valid: false, Errors: []string{fmt.Sprintf("ffprobe failed: %v (%s)", err, strings.TrimSpace(string(out)))}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var probe struct {
|
||||||
|
Streams []struct {
|
||||||
|
CodecName string `json:"codec_name"`
|
||||||
|
CodecType string `json:"codec_type"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
RFrameRate string `json:"r_frame_rate"`
|
||||||
|
PixFmt string `json:"pix_fmt"`
|
||||||
|
SampleRate string `json:"sample_rate"`
|
||||||
|
} `json:"streams"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &probe); err != nil {
|
||||||
|
return NormalizationResult{Valid: false, Errors: []string{"ffprobe JSON parse failed: " + err.Error()}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasVideo, hasAudio bool
|
||||||
|
for _, s := range probe.Streams {
|
||||||
|
switch s.CodecType {
|
||||||
|
case "video":
|
||||||
|
hasVideo = true
|
||||||
|
if s.CodecName != "h264" {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
|
||||||
|
}
|
||||||
|
if s.RFrameRate != "30/1" {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("framerate must be 30 fps constant, got %s", s.RFrameRate))
|
||||||
|
}
|
||||||
|
if s.Width != 1280 && s.Width != 1920 {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
|
||||||
|
}
|
||||||
|
if s.Height != 720 && s.Height != 1080 {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
|
||||||
|
}
|
||||||
|
if s.PixFmt != "yuv420p" {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
|
||||||
|
}
|
||||||
|
case "audio":
|
||||||
|
hasAudio = true
|
||||||
|
if s.CodecName != "aac" {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
|
||||||
|
}
|
||||||
|
if s.SampleRate != "48000" {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("sample rate must be 48000 Hz, got %s", s.SampleRate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasVideo {
|
||||||
|
result.Errors = append(result.Errors, "no video stream found")
|
||||||
|
}
|
||||||
|
if !hasAudio {
|
||||||
|
result.Errors = append(result.Errors, "no audio stream found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
result.Valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeDurationSeconds returns the duration of a media file in seconds.
|
||||||
|
func ProbeDurationSeconds(mediaPath string) (int, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"ffprobe",
|
||||||
|
"-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||||
|
mediaPath,
|
||||||
|
).CombinedOutput()
|
||||||
|
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return 0, fmt.Errorf("ffprobe timed out")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
rawDuration := strings.TrimSpace(string(out))
|
||||||
|
if rawDuration == "" {
|
||||||
|
return 0, fmt.Errorf("ffprobe returned empty duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
secondsFloat, err := strconv.ParseFloat(rawDuration, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err)
|
||||||
|
}
|
||||||
|
if secondsFloat < 0 {
|
||||||
|
secondsFloat = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds := int(secondsFloat + 0.5)
|
||||||
|
if secondsFloat > 0 && seconds == 0 {
|
||||||
|
seconds = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return seconds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
|
||||||
|
func DetectMime(filePath string) (string, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
head := make([]byte, 512)
|
||||||
|
n, err := f.Read(head)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAllowedExtension checks if the file extension is a supported video format.
|
||||||
|
func IsAllowedExtension(fileName string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(fileName))
|
||||||
|
switch ext {
|
||||||
|
case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAllowedMime checks if the MIME type is a supported video type.
|
||||||
|
func IsAllowedMime(mime string) bool {
|
||||||
|
mime = strings.TrimSpace(strings.ToLower(mime))
|
||||||
|
if mime == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(mime, "video/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return mime == "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniqueTargetPath returns a unique file path in mediaDir, appending -N if needed.
|
||||||
|
func UniqueTargetPath(mediaDir, fileName string) string {
|
||||||
|
baseName := filepath.Base(fileName)
|
||||||
|
ext := filepath.Ext(baseName)
|
||||||
|
nameOnly := strings.TrimSuffix(baseName, ext)
|
||||||
|
candidate := filepath.Join(mediaDir, baseName)
|
||||||
|
|
||||||
|
for i := 1; ; i++ {
|
||||||
|
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileEntry represents a media file for API responses.
|
||||||
|
type FileEntry struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
OriginalName string `json:"originalName"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
DurationSeconds int `json:"durationSeconds"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
|
UploadedAt string `json:"uploadedAt"`
|
||||||
|
ModifiedAt string `json:"modifiedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaRecord is the interface for media DB records.
|
||||||
|
type MediaRecord interface {
|
||||||
|
GetID() uint
|
||||||
|
GetFilename() string
|
||||||
|
GetOriginalName() string
|
||||||
|
GetPath() string
|
||||||
|
GetSizeBytes() int64
|
||||||
|
GetDurationSeconds() int
|
||||||
|
GetStatus() string
|
||||||
|
GetTitle() string
|
||||||
|
GetTags() string
|
||||||
|
GetUploadedAt() time.Time
|
||||||
|
GetUpdatedAt() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFiles returns all media files sorted by upload date descending.
|
||||||
|
func ListFiles(db *gorm.DB) ([]FileEntry, error) {
|
||||||
|
type record struct {
|
||||||
|
ID uint
|
||||||
|
Filename string
|
||||||
|
OriginalName string
|
||||||
|
Path string
|
||||||
|
SizeBytes int64
|
||||||
|
DurationSeconds int
|
||||||
|
Status string
|
||||||
|
Title string
|
||||||
|
Tags string
|
||||||
|
UploadedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []record
|
||||||
|
if err := db.Table("media").Order("uploaded_at desc").Find(&records).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]FileEntry, 0, len(records))
|
||||||
|
for _, r := range records {
|
||||||
|
files = append(files, FileEntry{
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Filename,
|
||||||
|
OriginalName: r.OriginalName,
|
||||||
|
Path: r.Path,
|
||||||
|
Size: r.SizeBytes,
|
||||||
|
DurationSeconds: r.DurationSeconds,
|
||||||
|
Runtime: FormatDuration(r.DurationSeconds),
|
||||||
|
Status: r.Status,
|
||||||
|
Title: r.Title,
|
||||||
|
Tags: r.Tags,
|
||||||
|
UploadedAt: r.UploadedAt.UTC().Format(time.RFC3339),
|
||||||
|
ModifiedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
if files[i].UploadedAt == files[j].UploadedAt {
|
||||||
|
return files[i].Name < files[j].Name
|
||||||
|
}
|
||||||
|
return files[i].UploadedAt > files[j].UploadedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePlaylistEntries parses playlist content into schedule entries.
|
||||||
|
type ScheduleEntry struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
DurationSeconds int `json:"durationSeconds"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePlaylist parses playlist text content into entries.
|
||||||
|
func ParsePlaylist(content string) []ScheduleEntry {
|
||||||
|
entries := []ScheduleEntry{}
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
for _, rawLine := range lines {
|
||||||
|
line := strings.TrimSpace(rawLine)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "file ") {
|
||||||
|
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
|
||||||
|
line = strings.Trim(line, "'\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, ScheduleEntry{
|
||||||
|
Index: len(entries) + 1,
|
||||||
|
Path: line,
|
||||||
|
Label: filepath.Base(line),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadSessionDir returns the directory for an upload session's chunks.
|
||||||
|
func UploadSessionDir(mediaDir, uploadID string) string {
|
||||||
|
return filepath.Join(mediaDir, ".uploads", uploadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadSessionMetaPath returns the path to the session metadata file.
|
||||||
|
func UploadSessionMetaPath(mediaDir, uploadID string) string {
|
||||||
|
return filepath.Join(UploadSessionDir(mediaDir, uploadID), "session.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadChunkPath returns the path for a specific chunk file.
|
||||||
|
func UploadChunkPath(mediaDir, uploadID string, chunkIndex int) string {
|
||||||
|
return filepath.Join(UploadSessionDir(mediaDir, uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUploadedChunks counts how many chunks exist for a session.
|
||||||
|
func CountUploadedChunks(mediaDir, uploadID string, totalChunks int) int {
|
||||||
|
uploaded := 0
|
||||||
|
for i := 0; i < totalChunks; i++ {
|
||||||
|
if _, err := os.Stat(UploadChunkPath(mediaDir, uploadID, i)); err == nil {
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uploaded
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateUploadID checks that an upload ID is a valid hex string of appropriate length.
|
||||||
|
func ValidateUploadID(uploadID string) bool {
|
||||||
|
if len(uploadID) < 16 || len(uploadID) > 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range uploadID {
|
||||||
|
if (r < 'a' || r > 'f') && (r < '0' || r > '9') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMediaIDFromPath extracts a media ID from a URL path like /api/admin/media/123.
|
||||||
|
func GetMediaIDFromPath(path, prefix string) (uint, error) {
|
||||||
|
if !strings.HasPrefix(path, prefix) {
|
||||||
|
return 0, fmt.Errorf("invalid media path")
|
||||||
|
}
|
||||||
|
idPart := strings.TrimPrefix(path, prefix)
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
if idPart == "" || strings.Contains(idPart, "/") {
|
||||||
|
return 0, fmt.Errorf("invalid media id")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(idPart, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return 0, fmt.Errorf("invalid media id")
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint(id), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"chat/src/auth"
|
||||||
|
"chat/src/db"
|
||||||
|
"chat/src/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleMetrics returns Prometheus-format metrics for the chat service.
|
||||||
|
func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedMessages int64
|
||||||
|
if gormDB != nil {
|
||||||
|
_ = gormDB.Model(&db.Message{}).Count(&storedMessages).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
var moderationActions int64
|
||||||
|
if gormDB != nil {
|
||||||
|
_ = gormDB.Model(&db.ModerationLog{}).Count(&moderationActions).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
redisEnabled := 0
|
||||||
|
redisUp := 0
|
||||||
|
redisClients := 0
|
||||||
|
if redis != nil {
|
||||||
|
redisEnabled = 1
|
||||||
|
if err := redis.Ping(context.Background()); err == nil {
|
||||||
|
redisUp = 1
|
||||||
|
if info, err := redis.Info(context.Background(), "clients"); err == nil {
|
||||||
|
for _, line := range strings.Split(info, "\n") {
|
||||||
|
if strings.HasPrefix(line, "connected_clients:") {
|
||||||
|
if value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "connected_clients:"))); err == nil {
|
||||||
|
redisClients = value
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedClients := 0
|
||||||
|
dmsSent := int64(0)
|
||||||
|
if hub != nil {
|
||||||
|
connectedClients = hub.ConnectedClients()
|
||||||
|
dmsSent = hub.DMsSent
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSessions := 0
|
||||||
|
if sessionStore != nil {
|
||||||
|
activeSessions = sessionStore.CountActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
|
||||||
|
writeLine := func(name string, value any) {
|
||||||
|
fmt.Fprintf(w, "%s %v\n", name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge")
|
||||||
|
writeLine("linearvoid_chat_connected_clients", connectedClients)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
|
||||||
|
writeLine("linearvoid_chat_messages_total", storedMessages)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_dms_total Total private messages sent.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_dms_total counter")
|
||||||
|
writeLine("linearvoid_chat_dms_total", dmsSent)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
|
||||||
|
writeLine("linearvoid_chat_moderation_actions_total", moderationActions)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_enabled Redis configured for chat service.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_enabled gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_enabled", redisEnabled)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_up Redis reachable from chat service.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_up gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_up", redisUp)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_connected_clients Redis connected client count seen by chat service.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
|
||||||
|
writeLine("linearvoid_chat_redis_connected_clients", redisClients)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
|
||||||
|
writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
|
||||||
|
|
||||||
|
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
|
||||||
|
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
|
||||||
|
fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHealth returns a simple health check endpoint.
|
||||||
|
func HandleHealth() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleNotFound returns a 404 JSON response for all unhandled paths.
|
||||||
|
func HandleNotFound() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"not found"}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedisClient is the subset of Redis operations needed for rate limiting.
|
||||||
|
type RedisClient interface {
|
||||||
|
Incr(ctx context.Context, key string) (int64, error)
|
||||||
|
Expire(ctx context.Context, key string, expiration time.Duration) error
|
||||||
|
Decr(ctx context.Context, key string) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckMessageRate checks if a user has exceeded the message rate limit.
|
||||||
|
// Returns true if allowed, false if rate limited.
|
||||||
|
func CheckMessageRate(ctx context.Context, redis RedisClient, username string, max int, window time.Duration) (bool, error) {
|
||||||
|
if redis == nil {
|
||||||
|
return true, nil // No Redis = no rate limiting
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("rate:%s", username)
|
||||||
|
count, err := redis.Incr(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return true, err // Fail open on Redis error
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
_ = redis.Expire(ctx, key, window)
|
||||||
|
}
|
||||||
|
return count <= int64(max), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckLoginRate checks if an IP has exceeded the login attempt limit.
|
||||||
|
// Returns true if allowed, false if rate limited.
|
||||||
|
func CheckLoginRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
|
||||||
|
if redis == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("login_attempts:%s", clientIP)
|
||||||
|
count, err := redis.Incr(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
_ = redis.Expire(ctx, key, 5*time.Minute)
|
||||||
|
}
|
||||||
|
return count <= 5, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckWSConnRate checks if an IP has exceeded the WebSocket connection limit.
|
||||||
|
// Returns true if allowed, false if rate limited.
|
||||||
|
// Caller must call Decr on disconnect.
|
||||||
|
func CheckWSConnRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
|
||||||
|
if redis == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("ws_conn:%s", clientIP)
|
||||||
|
count, err := redis.Incr(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
_ = redis.Expire(ctx, key, 60*time.Second)
|
||||||
|
}
|
||||||
|
return count <= 5, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecrWSConn decrements the WebSocket connection counter for an IP.
|
||||||
|
func DecrWSConn(ctx context.Context, redis RedisClient, clientIP string) {
|
||||||
|
if redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("ws_conn:%s", clientIP)
|
||||||
|
_, _ = redis.Decr(ctx, key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"chat/src/db"
|
||||||
|
"chat/src/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Upgrader is the WebSocket upgrader configuration.
|
||||||
|
var Upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientInfo stores metadata about a connected WebSocket client.
|
||||||
|
type ClientInfo struct {
|
||||||
|
Username string
|
||||||
|
Room string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub manages all WebSocket connections and message routing.
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[*websocket.Conn]bool
|
||||||
|
clientMeta map[*websocket.Conn]ClientInfo
|
||||||
|
DB *gorm.DB
|
||||||
|
Redis db.RedisClient
|
||||||
|
InstanceID string
|
||||||
|
RateLimitMax int
|
||||||
|
RateLimitWindow time.Duration
|
||||||
|
AdminUsername string
|
||||||
|
DMsSent int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHub creates a new WebSocket hub.
|
||||||
|
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
|
||||||
|
return &Hub{
|
||||||
|
clients: make(map[*websocket.Conn]bool),
|
||||||
|
clientMeta: make(map[*websocket.Conn]ClientInfo),
|
||||||
|
DB: gormDB,
|
||||||
|
Redis: redis,
|
||||||
|
InstanceID: instanceID,
|
||||||
|
RateLimitMax: rateMax,
|
||||||
|
RateLimitWindow: rateWindow,
|
||||||
|
AdminUsername: adminUser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectedClients returns the number of currently connected clients.
|
||||||
|
func (h *Hub) ConnectedClients() int {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return len(h.clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast sends a message to all connected clients and persists to DB.
|
||||||
|
func (h *Hub) Broadcast(msg db.Message) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
// Persist to configured SQL backend.
|
||||||
|
if h.DB != nil {
|
||||||
|
h.DB.Create(&msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local fan-out
|
||||||
|
for client := range h.clients {
|
||||||
|
if err := client.WriteJSON(msg); err != nil {
|
||||||
|
client.Close()
|
||||||
|
delete(h.clients, client)
|
||||||
|
delete(h.clientMeta, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis pub/sub for multi-instance fan-out
|
||||||
|
if h.Redis != nil && msg.Room != "" {
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"instance": h.InstanceID,
|
||||||
|
"msg": msg,
|
||||||
|
})
|
||||||
|
_ = h.Redis.Publish(context.Background(), fmt.Sprintf("room:%s:messages", msg.Room), payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushChatHistory sends the last N messages for a room via WebSocket.
|
||||||
|
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
||||||
|
if h.DB == nil || room == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var messages []db.Message
|
||||||
|
h.DB.Where("room = ?", room).Order("timestamp desc").Limit(limit).Find(&messages)
|
||||||
|
// Reverse to chronological order
|
||||||
|
for i := len(messages)/2 - 1; i >= 0; i-- {
|
||||||
|
opp := len(messages) - 1 - i
|
||||||
|
messages[i], messages[opp] = messages[opp], messages[i]
|
||||||
|
}
|
||||||
|
for _, msg := range messages {
|
||||||
|
conn.WriteJSON(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleConnection handles a new WebSocket connection.
|
||||||
|
func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientIP := r.Header.Get("X-Forwarded-For")
|
||||||
|
if clientIP == "" {
|
||||||
|
clientIP = r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP-based connection rate limiting
|
||||||
|
allowed, err := ratelimit.CheckWSConnRate(context.Background(), h.Redis, clientIP)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WS rate limit check error: %v", err)
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
http.Error(w, "too many connections from this IP", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ratelimit.DecrWSConn(context.Background(), h.Redis, clientIP)
|
||||||
|
|
||||||
|
conn, err := Upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Upgrade error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := r.URL.Query().Get("username")
|
||||||
|
room := r.URL.Query().Get("room")
|
||||||
|
if username == "" {
|
||||||
|
username = fmt.Sprintf("anon_%d", time.Now().UnixNano()%10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[conn] = true
|
||||||
|
h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
// Announce presence (ADR-007)
|
||||||
|
if h.Redis != nil && room != "" {
|
||||||
|
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||||
|
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push chat history on join (last 50 messages)
|
||||||
|
h.PushChatHistory(conn, room, 50)
|
||||||
|
|
||||||
|
// Heartbeat goroutine: refresh presence TTL every 30s
|
||||||
|
heartbeatStop := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if h.Redis != nil && room != "" {
|
||||||
|
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||||
|
}
|
||||||
|
// Ping to detect dead connections
|
||||||
|
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-heartbeatStop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
close(heartbeatStop)
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.clients, conn)
|
||||||
|
delete(h.clientMeta, conn)
|
||||||
|
h.mu.Unlock()
|
||||||
|
if h.Redis != nil && room != "" {
|
||||||
|
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
||||||
|
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
var msg db.Message
|
||||||
|
if err := conn.ReadJSON(&msg); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Timestamp = time.Now()
|
||||||
|
msg.Username = username // enforce from query param for security
|
||||||
|
|
||||||
|
// Check if banned before processing
|
||||||
|
if h.Redis != nil {
|
||||||
|
if isBanned, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("banned:%s", username)); isBanned > 0 {
|
||||||
|
conn.WriteJSON(db.Message{Type: "system", Text: "You are banned from chat.", Timestamp: time.Now()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isMuted, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("muted:%s", username)); isMuted > 0 {
|
||||||
|
conn.WriteJSON(db.Message{Type: "system", Text: "You are muted.", Timestamp: time.Now()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting check
|
||||||
|
allowed, _ := ratelimit.CheckMessageRate(context.Background(), h.Redis, msg.Username, h.RateLimitMax, h.RateLimitWindow)
|
||||||
|
if !allowed {
|
||||||
|
conn.WriteJSON(db.Message{Type: "system", Text: "Rate limit exceeded. Please slow down.", Timestamp: time.Now()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle moderation commands
|
||||||
|
if strings.HasPrefix(msg.Text, "/mod ") || strings.HasPrefix(msg.Text, "/mute ") || strings.HasPrefix(msg.Text, "/ban ") {
|
||||||
|
h.handleModerationCommand(msg, conn)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle private DMs
|
||||||
|
if strings.HasPrefix(msg.Room, "dm:") {
|
||||||
|
h.handlePrivateDM(msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Type = "public"
|
||||||
|
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
|
||||||
|
h.Broadcast(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePrivateDM routes private messages to the specific user pair.
|
||||||
|
func (h *Hub) handlePrivateDM(msg db.Message) {
|
||||||
|
msg.Type = "private"
|
||||||
|
h.DMsSent++
|
||||||
|
|
||||||
|
// Persist for history
|
||||||
|
if h.DB != nil {
|
||||||
|
h.DB.Create(&msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver to target if online
|
||||||
|
h.mu.Lock()
|
||||||
|
targetOnline := false
|
||||||
|
for conn, info := range h.clientMeta {
|
||||||
|
if info.Username == msg.Room || strings.Contains(msg.Room, info.Username) {
|
||||||
|
if err := conn.WriteJSON(msg); err == nil {
|
||||||
|
targetOnline = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if !targetOnline {
|
||||||
|
log.Printf("DM stored for offline user: %s -> room %s", msg.Username, msg.Room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also deliver to sender for confirmation
|
||||||
|
h.mu.Lock()
|
||||||
|
for conn, info := range h.clientMeta {
|
||||||
|
if info.Username == msg.Username {
|
||||||
|
conn.WriteJSON(msg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("Private DM from %s to room %s: %s", msg.Username, msg.Room, msg.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logModerationAction logs an action to the immutable ModerationLog table.
|
||||||
|
func (h *Hub) logModerationAction(action, target, moderator, reason string) {
|
||||||
|
logEntry := db.ModerationLog{
|
||||||
|
Action: action,
|
||||||
|
Target: target,
|
||||||
|
Moderator: moderator,
|
||||||
|
Reason: reason,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
if h.DB != nil {
|
||||||
|
h.DB.Create(&logEntry)
|
||||||
|
}
|
||||||
|
log.Printf("Moderation action logged: %s %s by %s - %s", action, target, moderator, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleModerationCommand processes /mod, /mute, /ban commands.
|
||||||
|
func (h *Hub) handleModerationCommand(msg db.Message, conn *websocket.Conn) {
|
||||||
|
parts := strings.Fields(msg.Text)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
conn.WriteJSON(db.Message{Type: "system", Text: "Usage: /mute <user> <duration_m> <reason> or /ban <user> <reason>", Timestamp: time.Now()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Username != h.AdminUsername {
|
||||||
|
conn.WriteJSON(db.Message{Type: "system", Text: "Only moderators can use moderation commands.", Timestamp: time.Now()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action := strings.TrimPrefix(parts[0], "/")
|
||||||
|
target := parts[1]
|
||||||
|
reason := strings.Join(parts[2:], " ")
|
||||||
|
|
||||||
|
if h.Redis != nil {
|
||||||
|
switch action {
|
||||||
|
case "mute", "mod":
|
||||||
|
durationMinutes := 10 // default
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
if d, err := strconv.Atoi(parts[2]); err == nil && d > 0 {
|
||||||
|
durationMinutes = d
|
||||||
|
if len(parts) > 3 {
|
||||||
|
reason = strings.Join(parts[3:], " ")
|
||||||
|
} else {
|
||||||
|
reason = "no reason given"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = h.Redis.Set(context.Background(), fmt.Sprintf("muted:%s", target), "1", time.Duration(durationMinutes)*time.Minute)
|
||||||
|
case "ban":
|
||||||
|
_ = h.Redis.Set(context.Background(), fmt.Sprintf("banned:%s", target), "1", 0) // no expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logModerationAction(action, target, msg.Username, reason)
|
||||||
|
|
||||||
|
h.Broadcast(db.Message{
|
||||||
|
Room: msg.Room,
|
||||||
|
Username: "system",
|
||||||
|
Type: "system",
|
||||||
|
Text: fmt.Sprintf("%s was %s by %s: %s", target, action, msg.Username, reason),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeRoomMessages listens for Redis pub/sub messages and fans out locally.
|
||||||
|
func (h *Hub) SubscribeRoomMessages() {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pubsub := h.Redis.PSubscribe(context.Background(), "room:*:messages")
|
||||||
|
defer pubsub.Close()
|
||||||
|
|
||||||
|
log.Printf("Redis pub/sub subscriber started (instance %s)", h.InstanceID)
|
||||||
|
|
||||||
|
for {
|
||||||
|
msg, err := pubsub.ReceiveMessage(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Redis pub/sub error: %v — retrying in 5s", err)
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
pubsub = h.Redis.PSubscribe(context.Background(), "room:*:messages")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Msg db.Message `json:"msg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip messages originating from this instance
|
||||||
|
if envelope.Instance == h.InstanceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan out to local clients
|
||||||
|
h.mu.Lock()
|
||||||
|
for client := range h.clients {
|
||||||
|
client.WriteJSON(envelope.Msg)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanPresence returns all active presence entries from Redis.
|
||||||
|
func (h *Hub) ScanPresence(ctx context.Context) ([]db.Presence, error) {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
keys, _, err := h.Redis.Scan(ctx, 0, "presence:*", 200)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var presences []db.Presence
|
||||||
|
for _, key := range keys {
|
||||||
|
parts := strings.SplitN(key, ":", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
presences = append(presences, db.Presence{
|
||||||
|
Room: parts[1],
|
||||||
|
Username: parts[2],
|
||||||
|
Online: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return presences, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MuteUser mutes a user for a duration.
|
||||||
|
func (h *Hub) MuteUser(ctx context.Context, username string, duration time.Duration) error {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return fmt.Errorf("redis unavailable")
|
||||||
|
}
|
||||||
|
return h.Redis.Set(ctx, fmt.Sprintf("muted:%s", username), "1", duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmuteUser removes a mute.
|
||||||
|
func (h *Hub) UnmuteUser(ctx context.Context, username string) error {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return fmt.Errorf("redis unavailable")
|
||||||
|
}
|
||||||
|
return h.Redis.Del(ctx, fmt.Sprintf("muted:%s", username))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BanUser bans a user permanently.
|
||||||
|
func (h *Hub) BanUser(ctx context.Context, username string) error {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return fmt.Errorf("redis unavailable")
|
||||||
|
}
|
||||||
|
return h.Redis.Set(ctx, fmt.Sprintf("banned:%s", username), "1", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnbanUser removes a ban.
|
||||||
|
func (h *Hub) UnbanUser(ctx context.Context, username string) error {
|
||||||
|
if h.Redis == nil {
|
||||||
|
return fmt.Errorf("redis unavailable")
|
||||||
|
}
|
||||||
|
return h.Redis.Del(ctx, fmt.Sprintf("banned:%s", username))
|
||||||
|
}
|
||||||
+27
-27
@@ -113,14 +113,14 @@ Full decision rationale, alternatives, and consequences in [`adr/ADR-001.md`](ad
|
|||||||
|
|
||||||
### Quality Goals
|
### Quality Goals
|
||||||
|
|
||||||
| Priority | Quality Attribute | Goal | Scenario |
|
| Priority | Quality Attribute | Goal | Scenario |
|
||||||
| -------- | ------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
| -------- | ------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
| 1 | **Cost Efficiency** | <$25/month for moderate scale (100s concurrent) | R2 $0 egress + immutable `.ts` edge caching eliminates per-viewer bandwidth cost |
|
| 1 | **Cost Efficiency** | <$25/month for moderate scale (100s concurrent) | R2 $0 egress + immutable `.m4s` and `.ts` edge caching eliminates per-viewer bandwidth cost |
|
||||||
| 2 | **Performance / Latency** | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) |
|
| 2 | **Performance / Latency** | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) |
|
||||||
| 3 | **Availability** | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart loop, fallback slate, monitoring |
|
| 3 | **Availability** | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart loop, fallback slate, monitoring |
|
||||||
| 4 | **Scalability** | Grow from 10s to 1000s viewers without proportional cost | Edge caching + R2; only request volume limits |
|
| 4 | **Scalability** | Grow from 10s to 1000s viewers without proportional cost | Edge caching + R2; only request volume limits |
|
||||||
| 5 | **Maintainability** | Lightweight, clear configs, low ops overhead | Static SPA, Go/Node chat, SQLite/PostgreSQL+Redis |
|
| 5 | **Maintainability** | Lightweight, clear configs, low ops overhead | Static SPA, Go/Node chat, SQLite/PostgreSQL+Redis |
|
||||||
| 6 | **Security / Usability** | Secure transport, moderation tools, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player |
|
| 6 | **Security / Usability** | Secure transport, moderation tools, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player |
|
||||||
|
|
||||||
### Stakeholders
|
### Stakeholders
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ flowchart TD
|
|||||||
|
|
||||||
**External Interfaces**:
|
**External Interfaces**:
|
||||||
|
|
||||||
- **Cloudflare**: CDN (cache rules for `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Pages (static hosting), WAF/TLS.
|
- **Cloudflare**: CDN (cache rules for `.m4s` and `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Pages (static hosting), WAF/TLS.
|
||||||
- **Hetzner VPS**: Compute for playout and chat (low-cost US-East with good peering).
|
- **Hetzner VPS**: Compute for playout and chat (low-cost US-East with good peering).
|
||||||
- **Browsers**: Native HLS (`<video>` + hls.js) and WebSocket APIs.
|
- **Browsers**: Native HLS (`<video>` + hls.js) and WebSocket APIs.
|
||||||
- **Source**: Pre-normalized MP4 files uploaded to R2.
|
- **Source**: Pre-normalized MP4 files uploaded to R2.
|
||||||
@@ -196,7 +196,7 @@ This diagram synthesizes the context views from all original ADRs. Full decision
|
|||||||
### Technical Context
|
### Technical Context
|
||||||
|
|
||||||
- **Playout**: FFmpeg consuming normalized playlist → continuous HLS.
|
- **Playout**: FFmpeg consuming normalized playlist → continuous HLS.
|
||||||
- **Packaging/Delivery**: MediaMTX/SRS/Nginx → immutable `.ts` segments to R2; Cloudflare edge cache.
|
- **Packaging/Delivery**: MediaMTX/SRS/Nginx → immutable `.m4s` and `.ts` segments to R2; Cloudflare edge cache.
|
||||||
- **Frontend**: Static SPA with hls.js.
|
- **Frontend**: Static SPA with hls.js.
|
||||||
- **Chat**: Lightweight WebSocket service (Node/Go) + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation).
|
- **Chat**: Lightweight WebSocket service (Node/Go) + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation).
|
||||||
- **Normalization**: All source videos must be H.264/AAC, 30 fps constant, 2s keyframes.
|
- **Normalization**: All source videos must be H.264/AAC, 30 fps constant, 2s keyframes.
|
||||||
@@ -212,7 +212,7 @@ This diagram synthesizes the context views from all original ADRs. Full decision
|
|||||||
|
|
||||||
1. **US-East Playout on Hetzner Ashburn VPS** (`CX22` or equivalent): FFmpeg + MediaMTX/SRS for continuous playlist-to-HLS conversion (`-c copy` where possible).
|
1. **US-East Playout on Hetzner Ashburn VPS** (`CX22` or equivalent): FFmpeg + MediaMTX/SRS for continuous playlist-to-HLS conversion (`-c copy` where possible).
|
||||||
2. **Immutable HLS to Cloudflare R2**: Segments use unique names; R2 acts as origin with $0 egress.
|
2. **Immutable HLS to Cloudflare R2**: Segments use unique names; R2 acts as origin with $0 egress.
|
||||||
3. **Aggressive Edge Caching**: Long TTL for `.ts` (immutable), short TTL (1–10s) for live `.m3u8`. Tiered Cache enabled. This makes viewer delivery essentially free.
|
3. **Aggressive Edge Caching**: Long TTL for `.m4s` and `.ts` (immutable), short TTL (1–10s) for live `.m3u8`. Tiered Cache enabled. This makes viewer delivery essentially free.
|
||||||
4. **Lightweight Custom Frontend**: Static HTML/JS SPA + hls.js player + embedded chat sidebar.
|
4. **Lightweight Custom Frontend**: Static HTML/JS SPA + hls.js player + embedded chat sidebar.
|
||||||
5. **Separate but co-located Chat Service**: WebSockets with persistence and moderation (low bandwidth).
|
5. **Separate but co-located Chat Service**: WebSockets with persistence and moderation (low bandwidth).
|
||||||
6. **Normalization & Monitoring**: Pre-process all videos; use dashboards + alerts for playout health, cache hit ratio, R2 ops.
|
6. **Normalization & Monitoring**: Pre-process all videos; use dashboards + alerts for playout health, cache hit ratio, R2 ops.
|
||||||
@@ -230,7 +230,7 @@ Overview (Mermaid diagram):
|
|||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
subgraph Cloudflare
|
subgraph Cloudflare
|
||||||
CDN[CDN + Cache Rules<br/>.ts immutable long TTL<br/>.m3u8 short TTL]
|
CDN[CDN + Cache Rules<br/>.m4s and .ts immutable long TTL<br/>.m3u8 short TTL]
|
||||||
R2[R2 - HLS + Source]
|
R2[R2 - HLS + Source]
|
||||||
Pages[Pages - Static SPA]
|
Pages[Pages - Static SPA]
|
||||||
end
|
end
|
||||||
@@ -306,7 +306,7 @@ graph TB
|
|||||||
|
|
||||||
- **Role**: Continuous HLS encoding loop using FFmpeg concat demuxer
|
- **Role**: Continuous HLS encoding loop using FFmpeg concat demuxer
|
||||||
- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths
|
- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths
|
||||||
- **Output**: HLS segments (`.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
|
- **Output**: HLS segments (`.m4s` and `.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
|
||||||
- **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081
|
- **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081
|
||||||
- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval)
|
- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval)
|
||||||
|
|
||||||
@@ -383,7 +383,7 @@ Module-level breakdown per service.
|
|||||||
2. Playout worker (FFmpeg loop with concat demuxer) reads next file, packages as HLS segments (4-6s).
|
2. Playout worker (FFmpeg loop with concat demuxer) reads next file, packages as HLS segments (4-6s).
|
||||||
3. Segments uploaded to R2 with unique immutable names; live `.m3u8` updated.
|
3. Segments uploaded to R2 with unique immutable names; live `.m3u8` updated.
|
||||||
4. Viewer loads SPA → requests master/live manifest → hls.js player follows current edge.
|
4. Viewer loads SPA → requests master/live manifest → hls.js player follows current edge.
|
||||||
5. Cloudflare serves `.ts` from nearest edge cache (origin/R2 hit only once per POP/region).
|
5. Cloudflare serves `.m4s` and `.ts` from nearest edge cache (origin/R2 hit only once per POP/region).
|
||||||
6. Parallel WebSocket connection to chat service for synchronized interaction.
|
6. Parallel WebSocket connection to chat service for synchronized interaction.
|
||||||
|
|
||||||
Notable aspects: Manifest freshness ensures all viewers stay on same timeline; cache hit ratio directly impacts cost and latency. Fallback slate activates on playout failure. Full cache policy, example configs (FFmpeg bash loop, Nginx RTMP HLS), and storage tables in `adr/ADR-001.md`.
|
Notable aspects: Manifest freshness ensures all viewers stay on same timeline; cache hit ratio directly impacts cost and latency. Fallback slate activates on playout failure. Full cache policy, example configs (FFmpeg bash loop, Nginx RTMP HLS), and storage tables in `adr/ADR-001.md`.
|
||||||
@@ -469,7 +469,7 @@ flowchart LR
|
|||||||
**Cloudflare Layer**:
|
**Cloudflare Layer**:
|
||||||
|
|
||||||
- R2 bucket for media.
|
- R2 bucket for media.
|
||||||
- Cache Rules (`.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate).
|
- Cache Rules (`.m4s` and `.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate).
|
||||||
- WAF, rate limiting, Tiered Cache.
|
- WAF, rate limiting, Tiered Cache.
|
||||||
- Pages for SPA hosting.
|
- Pages for SPA hosting.
|
||||||
|
|
||||||
@@ -479,7 +479,7 @@ Future evolution: Docker Compose, managed DB, secondary regions (detailed in sub
|
|||||||
|
|
||||||
### Caching & Cost Optimization
|
### Caching & Cost Optimization
|
||||||
|
|
||||||
Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = near-zero viewer bandwidth cost. Long TTL on `.ts`, short on live manifests. Monitor hit ratio and R2 Class B ops.
|
Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = near-zero viewer bandwidth cost. Long TTL on `.m4s` and `.ts`, short on live manifests. Monitor hit ratio and R2 Class B ops.
|
||||||
|
|
||||||
### Video Normalization & Playout
|
### Video Normalization & Playout
|
||||||
|
|
||||||
@@ -888,16 +888,16 @@ Document and rehearse this runbook quarterly; recovery that is not exercised is
|
|||||||
|
|
||||||
## Glossary
|
## Glossary
|
||||||
|
|
||||||
| Term | Definition |
|
| Term | Definition |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------ |
|
| ----------------- | -------------------------------------------------------------------------------------- |
|
||||||
| HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (`.ts` or fMP4). |
|
| HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (fMP4). |
|
||||||
| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
|
| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
|
||||||
| R2 | Cloudflare object storage ($0 egress, ideal origin for cached HLS). |
|
| R2 | Cloudflare object storage ($0 egress, ideal origin for cached HLS). |
|
||||||
| Edge Cache / POP | Cloudflare points-of-presence that cache and serve content close to viewers. |
|
| Edge Cache / POP | Cloudflare points-of-presence that cache and serve content close to viewers. |
|
||||||
| Playout | Continuous generation of a scheduled linear program from a playlist. |
|
| Playout | Continuous generation of a scheduled linear program from a playlist. |
|
||||||
| Normalization | Pre-processing videos to consistent codec/fps/keyframe settings for seamless playback. |
|
| Normalization | Pre-processing videos to consistent codec/fps/keyframe settings for seamless playback. |
|
||||||
| Cache Rule | Cloudflare configuration defining caching behavior, TTL, and headers per URL pattern. |
|
| Cache Rule | Cloudflare configuration defining caching behavior, TTL, and headers per URL pattern. |
|
||||||
| SPOF | Single Point of Failure (current single VPS origin). |
|
| SPOF | Single Point of Failure (current single VPS origin). |
|
||||||
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
|
||||||
|
|
||||||
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted.
|
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted.
|
||||||
@@ -19,11 +19,11 @@ We require a completely independent 24/7 linear video channel that broadcasts a
|
|||||||
**Key distinctions**:
|
**Key distinctions**:
|
||||||
|
|
||||||
- **Playout**: Continuous scheduled program from playlist.
|
- **Playout**: Continuous scheduled program from playlist.
|
||||||
- **Packaging**: FFmpeg → standard HLS (`.m3u8` + `.ts` segments, 4–6s duration).
|
- **Packaging**: FFmpeg → standard HLS (`.m3u8` + `.m4s` segments, 4–6s duration).
|
||||||
- **Delivery**: Immutable segments served via Cloudflare edge caching + R2 (zero egress cost).
|
- **Delivery**: Immutable segments served via Cloudflare edge caching + R2 (zero egress cost).
|
||||||
- **Interaction**: Separate lightweight WebSocket service for chat.
|
- **Interaction**: Separate lightweight WebSocket service for chat.
|
||||||
|
|
||||||
Bandwidth (not storage or compute) is the dominant cost driver. A single viewer at ~4 Mbps consumes ~1.3–1.8 TB/month; 100 concurrent viewers = ~130–180 TB/month. Direct VPS delivery is only viable for PoC/small audiences.
|
Bandwidth (not storage or compute) is the dominant cost driver. A single viewer at ~4 Mbps consumes ~1.3–1.8 TB/month; 100 concurrent viewers = ~130–180 TB/month. Direct VPS delivery is only viable for PoC/small audiences. HLS segments are typically `.m4s` files, with `.m4s` being the default for fMP4 segments.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Bandwidth (not storage or compute) is the dominant cost driver. A single viewer
|
|||||||
2. Full independence and ownership (no video platform lock-in).
|
2. Full independence and ownership (no video platform lock-in).
|
||||||
3. Strong US performance and low latency.
|
3. Strong US performance and low latency.
|
||||||
4. True/near-true synchronization via live-style HLS (not pure VOD).
|
4. True/near-true synchronization via live-style HLS (not pure VOD).
|
||||||
5. Aggressive immutable caching of `.ts` segments to achieve near-zero egress.
|
5. Aggressive immutable caching of `.m4s` segments to achieve near-zero egress.
|
||||||
6. Browser-native playback (`hls.js` + native HLS).
|
6. Browser-native playback (`hls.js` + native HLS).
|
||||||
7. Lightweight, maintainable stack with modest operational complexity.
|
7. Lightweight, maintainable stack with modest operational complexity.
|
||||||
8. Support for custom multi-room + private chat with persistence and moderation.
|
8. Support for custom multi-room + private chat with persistence and moderation.
|
||||||
@@ -58,9 +58,9 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
### 2. Storage & Delivery (Cloudflare R2 + CDN)
|
### 2. Storage & Delivery (Cloudflare R2 + CDN)
|
||||||
|
|
||||||
- HLS segments (`.ts`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`).
|
- HLS segments (`.m4s`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`).
|
||||||
- Custom domain (`media.linearvoid.tv`) fronted by Cloudflare with **Cache Rules**:
|
- Custom domain (`media.linearvoid.tv`) fronted by Cloudflare with **Cache Rules**:
|
||||||
- `*.ts` (and init `.mp4` if using fMP4 later): `Cache Everything`, immutable, Edge TTL 24h–1yr (`Cache-Control: public, max-age=31536000, immutable`).
|
- `*.m4s` (and init `.mp4` if using fMP4 later): `Cache Everything`, immutable, Edge TTL 24h–1yr (`Cache-Control: public, max-age=31536000, immutable`).
|
||||||
- `*.m3u8`: Short TTL (1–10s) or `must-revalidate` (`Cache-Control: public, max-age=1..10, must-revalidate`).
|
- `*.m3u8`: Short TTL (1–10s) or `must-revalidate` (`Cache-Control: public, max-age=1..10, must-revalidate`).
|
||||||
- Master playlist: TTL ~30s.
|
- Master playlist: TTL ~30s.
|
||||||
- Tiered Cache enabled where possible.
|
- Tiered Cache enabled where possible.
|
||||||
@@ -90,7 +90,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
|||||||
|
|
||||||
**Initial Targets**:
|
**Initial Targets**:
|
||||||
|
|
||||||
- Protocol: Standard HLS (`.ts` segments). Defer LL-HLS/CMAF/WebRTC.
|
- Protocol: Standard HLS (`.m4s` segments). Defer LL-HLS/CMAF/WebRTC.
|
||||||
- Max rendition: 720p30 initially (upgrade to 1080p30 or ABR later).
|
- Max rendition: 720p30 initially (upgrade to 1080p30 or ABR later).
|
||||||
- Latency: 15–35s (acceptable for linear programming/chat).
|
- Latency: 15–35s (acceptable for linear programming/chat).
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ We must explicitly document language/runtime choices per module and the assumpti
|
|||||||
|
|
||||||
1. All video assets **must** be normalized before being added to any playlist (H.264, AAC, 30 fps constant, 2s keyframes, CRF encoding).
|
1. All video assets **must** be normalized before being added to any playlist (H.264, AAC, 30 fps constant, 2s keyframes, CRF encoding).
|
||||||
2. Deployment is **Docker-first**; services will be containerized.
|
2. Deployment is **Docker-first**; services will be containerized.
|
||||||
3. Cloudflare is the primary CDN and will be configured with specific Cache Rules (`.ts` immutable long TTL, `.m3u8` short TTL).
|
3. Cloudflare is the primary CDN and will be configured with specific [Cache Rules](../../../infrastructure/cloudflare/cache-rules.md).
|
||||||
4. Chat does **not** require end-to-end encryption in v1 (separate ADR if needed).
|
4. Chat does **not** require end-to-end encryption in v1 (separate ADR if needed).
|
||||||
5. Single VPS origin initially; multi-region is a later evolution.
|
5. Single VPS origin initially; multi-region is a later evolution.
|
||||||
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
|
||||||
|
|||||||
@@ -161,4 +161,69 @@ docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
|
|||||||
3. Add prevention to docs if actionable
|
3. Add prevention to docs if actionable
|
||||||
4. Update this runbook if new failure mode discovered
|
4. Update this runbook if new failure mode discovered
|
||||||
|
|
||||||
**Recovery targets:** RPO ≤24h, RTO ≤60min. Quarterly DR drill: simulate host loss, restore from backup.
|
## Quarterly Disaster Recovery Drill
|
||||||
|
|
||||||
|
Goal: validate recovery targets under controlled host-loss simulation.
|
||||||
|
|
||||||
|
**Recovery targets:** RPO <= 24h, RTO <= 60min.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Latest backup artifacts available from `scripts/backup-db.sh` and `scripts/backup-weekly.sh`
|
||||||
|
- Operator access to Docker host and DNS controls
|
||||||
|
- Current deployment variables available (`.env`, compose file, image tags)
|
||||||
|
|
||||||
|
### Drill Scope
|
||||||
|
|
||||||
|
- Simulate complete origin loss (all app containers unavailable)
|
||||||
|
- Rebuild stack on clean host
|
||||||
|
- Restore DB and media/playlist mounts
|
||||||
|
- Verify viewer playback, chat, admin auth, monitoring targets
|
||||||
|
|
||||||
|
### Drill Steps
|
||||||
|
|
||||||
|
1. Record drill start timestamp and on-call participants.
|
||||||
|
2. Capture pre-drill baseline:
|
||||||
|
- `curl -fsS http://<origin>/health`
|
||||||
|
- `curl -fsS http://<origin>:3000/health`
|
||||||
|
- `curl -fsS http://<origin>:8081/health`
|
||||||
|
3. Simulate origin outage:
|
||||||
|
- `docker compose -f docker-compose.prod.yml down`
|
||||||
|
4. Provision clean recovery host (or clean test-lab VM) and copy deployment bundle.
|
||||||
|
5. Restore required data paths:
|
||||||
|
- `data/playlist/`
|
||||||
|
- `data/media/`
|
||||||
|
- `data/chat/`
|
||||||
|
- `data/hls/` (optional warm cache)
|
||||||
|
6. Restore database from latest backup dump.
|
||||||
|
7. Start stack:
|
||||||
|
- `docker compose -f docker-compose.prod.yml up -d`
|
||||||
|
8. Validate service health and runtime:
|
||||||
|
- `docker ps --format "table {{.Names}}\t{{.Status}}"`
|
||||||
|
- `curl -fsS http://<origin>/health`
|
||||||
|
- `curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5`
|
||||||
|
- `curl -fsS http://<origin>:9090/-/ready`
|
||||||
|
9. Validate app behavior:
|
||||||
|
- Viewer SPA loads and HLS plays
|
||||||
|
- WebSocket chat connects and message round-trip works
|
||||||
|
- Admin login/session endpoint returns success
|
||||||
|
10. Run backup integrity check:
|
||||||
|
- `scripts/verify-backups.sh`
|
||||||
|
11. Record drill end timestamp and compute measured RTO/RPO.
|
||||||
|
|
||||||
|
### Pass/Fail Criteria
|
||||||
|
|
||||||
|
- Pass if all core services healthy and functional in <= 60 minutes.
|
||||||
|
- Pass if latest recoverable data point is <= 24 hours old.
|
||||||
|
- Fail if any critical path remains degraded (playout, chat, admin auth, monitoring).
|
||||||
|
|
||||||
|
### Evidence Template
|
||||||
|
|
||||||
|
Document each drill in incident tracker with:
|
||||||
|
|
||||||
|
- Date/time window
|
||||||
|
- Environment (test-lab, staging, production)
|
||||||
|
- Backup artifact IDs and timestamps
|
||||||
|
- Measured RTO and RPO
|
||||||
|
- Issues found and remediation owner
|
||||||
|
- Follow-up ticket links
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Cloudflare and UptimeRobot Manual Completion Checklist
|
||||||
|
|
||||||
|
Purpose: execute and record all manual dashboard tasks that cannot be automated from repository workflows.
|
||||||
|
|
||||||
|
## Status Legend
|
||||||
|
|
||||||
|
- [ ] Pending
|
||||||
|
- [x] Done
|
||||||
|
|
||||||
|
## 1) Cloudflare Pages Link and Build
|
||||||
|
|
||||||
|
- [ ] Connect repo to Cloudflare Pages project
|
||||||
|
- [ ] Build command set to: `cd frontend && npm run build`
|
||||||
|
- [ ] Output directory set to: `frontend/dist`
|
||||||
|
- [ ] Production domain attached to `linearvoid.tv`
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- Screenshot of Pages deployment settings
|
||||||
|
- Deployment URL and latest deployment timestamp
|
||||||
|
|
||||||
|
## 2) Tiered Cache Enablement and Validation
|
||||||
|
|
||||||
|
- [ ] Smart Tiered Cache enabled for zone
|
||||||
|
- [ ] Warm request made to hot HLS segment
|
||||||
|
- [ ] Warm request made to live manifest
|
||||||
|
- [ ] `Cf-Cache-Status: HIT` observed on repeated requests
|
||||||
|
|
||||||
|
Validation commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
||||||
|
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- Headers from first request (expected MISS)
|
||||||
|
- Headers from repeated request (expected HIT)
|
||||||
|
|
||||||
|
## 3) WAF and Bot Protection
|
||||||
|
|
||||||
|
- [ ] OWASP Managed Ruleset enabled
|
||||||
|
- [ ] Mode set to Simulate
|
||||||
|
- [ ] Simulate mode reviewed for 48h
|
||||||
|
- [ ] Mode switched to Block after false-positive review
|
||||||
|
- [ ] Bot Fight Mode enabled
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- WAF ruleset status screenshot
|
||||||
|
- Count of blocked/challenged events after cutover
|
||||||
|
|
||||||
|
## 4) Rate Limiting Rules
|
||||||
|
|
||||||
|
- [ ] Rule created for `/api/admin/login`
|
||||||
|
- [ ] Rule created for `/api/admin/*`
|
||||||
|
- [ ] Threshold set to `10 requests / 10 seconds / IP`
|
||||||
|
- [ ] Action set to Managed Challenge
|
||||||
|
- [ ] Rule order validated
|
||||||
|
|
||||||
|
Validation approach:
|
||||||
|
|
||||||
|
- Use burst requests from a test client and verify challenge behavior without blocking legitimate admin flow.
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- Rule definitions screenshot
|
||||||
|
- Test result notes (challenge seen / no lockout)
|
||||||
|
|
||||||
|
## 5) HTTPS and WSS Hardening
|
||||||
|
|
||||||
|
- [ ] Always Use HTTPS enabled
|
||||||
|
- [ ] HSTS enabled with max-age 31536000
|
||||||
|
- [ ] HSTS includeSubDomains enabled
|
||||||
|
- [ ] HSTS preload enabled
|
||||||
|
- [ ] Minimum TLS version set to 1.2+
|
||||||
|
- [ ] SSL mode verified as Full (strict)
|
||||||
|
- [ ] WSS endpoint validated through proxy (`wss://linearvoid.tv/ws`)
|
||||||
|
|
||||||
|
Validation commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://linearvoid.tv
|
||||||
|
curl -I https://linearvoid.tv/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- Response headers showing HSTS
|
||||||
|
- Successful browser WebSocket connect in devtools
|
||||||
|
|
||||||
|
## 6) UptimeRobot Monitors
|
||||||
|
|
||||||
|
- [ ] HTTP monitor added for `https://linearvoid.tv/health` (5m)
|
||||||
|
- [ ] Keyword monitor added for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5m)
|
||||||
|
- [ ] Alert trigger set to 2 consecutive failures
|
||||||
|
- [ ] Notification channels configured (email + Discord/Slack webhook)
|
||||||
|
|
||||||
|
Evidence to capture:
|
||||||
|
|
||||||
|
- Monitor dashboard screenshot
|
||||||
|
- Test alert delivery confirmation
|
||||||
|
|
||||||
|
## 7) Sign-Off
|
||||||
|
|
||||||
|
- [ ] Cloudflare manual tasks complete
|
||||||
|
- [ ] UptimeRobot tasks complete
|
||||||
|
- [ ] TODO items updated to done
|
||||||
|
- [ ] Changelog entry added for completion batch
|
||||||
|
|
||||||
|
Sign-off fields:
|
||||||
|
|
||||||
|
- Owner:
|
||||||
|
- Date:
|
||||||
|
- Environment (test-lab or production):
|
||||||
|
- Notes:
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Scaling and Performance Playbook
|
||||||
|
|
||||||
|
Purpose: define repeatable method to measure performance, capture baseline data, and decide scaling actions.
|
||||||
|
|
||||||
|
Scope: planning and execution guide for TODO items under "Scaling & Performance (planning only)".
|
||||||
|
|
||||||
|
## 1) Manifest TTL Tuning Plan
|
||||||
|
|
||||||
|
Goal: tune `.m3u8` TTL to about `0.5x` segment duration while keeping playback stable.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- Segment duration (`HLS_SEGMENT_DURATION`)
|
||||||
|
- Current manifest TTL (Cloudflare cache rule)
|
||||||
|
- Cache outcome distribution (`Cf-Cache-Status`)
|
||||||
|
|
||||||
|
Procedure:
|
||||||
|
|
||||||
|
1. Start from current manifest TTL (2s).
|
||||||
|
2. Capture 24h baseline headers from repeated `playlist.m3u8` requests.
|
||||||
|
3. Test candidate TTLs: 1s, 2s, 3s.
|
||||||
|
4. Compare:
|
||||||
|
- rebuffer events from player logs
|
||||||
|
- origin request increase/decrease
|
||||||
|
- HIT ratio changes
|
||||||
|
5. Select lowest TTL that keeps playback stable and origin load acceptable.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- No material increase in rebuffer rate.
|
||||||
|
- Manifest staleness not observable by users.
|
||||||
|
|
||||||
|
## 2) TTFB Baseline Plan
|
||||||
|
|
||||||
|
Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`.
|
||||||
|
|
||||||
|
Suggested regions:
|
||||||
|
|
||||||
|
- US East (Virginia)
|
||||||
|
- US Central
|
||||||
|
- US West (California/Oregon)
|
||||||
|
|
||||||
|
Method:
|
||||||
|
|
||||||
|
1. Use WebPageTest with repeat view enabled.
|
||||||
|
2. Run at least 5 samples per region.
|
||||||
|
3. Record median and p95 TTFB.
|
||||||
|
4. Store results in table (date, region, median, p95, test URL).
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- Warm-cache median < 4s in all tested regions.
|
||||||
|
- Outliers documented with likely cause.
|
||||||
|
|
||||||
|
## 3) WebSocket Load-Test Plan
|
||||||
|
|
||||||
|
Goal: characterize chat service behavior across 10 -> 100 -> 500 -> 1000 concurrent clients.
|
||||||
|
|
||||||
|
Tooling option A (Artillery):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx artillery run scripts/loadtest/ws-chat.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Tooling option B (custom Go harness):
|
||||||
|
|
||||||
|
- open N concurrent WebSocket clients
|
||||||
|
- send message every X seconds
|
||||||
|
- measure round-trip latency and failures
|
||||||
|
|
||||||
|
Metrics to capture:
|
||||||
|
|
||||||
|
- p50/p95 message latency
|
||||||
|
- error rate (connect, send, receive)
|
||||||
|
- dropped connections
|
||||||
|
- CPU/memory for `chat`, `redis`, `nginx`
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- p95 chat latency <= 1s at target concurrency tier.
|
||||||
|
- error rate <= 1% during steady state.
|
||||||
|
|
||||||
|
## 4) Admin API Benchmark Plan
|
||||||
|
|
||||||
|
Goal: measure admin API p95 and identify slow endpoints.
|
||||||
|
|
||||||
|
Tooling option A (`k6`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
k6 run scripts/loadtest/admin-api.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Tooling option B (`wrk`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrk -t4 -c100 -d60s http://<host>/api/admin/overview
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
- `/api/admin/overview`
|
||||||
|
- `/api/admin/analytics`
|
||||||
|
- `/api/admin/chat-log`
|
||||||
|
- `/api/admin/users`
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- p95 < 1s for primary admin endpoints.
|
||||||
|
- slow endpoint list with remediation notes.
|
||||||
|
|
||||||
|
## 5) Cache Hit Ratio Tracking Plan
|
||||||
|
|
||||||
|
Goal: track Cloudflare cache hit ratio and alert when below threshold.
|
||||||
|
|
||||||
|
Implementation outline:
|
||||||
|
|
||||||
|
1. Pull Cloudflare GraphQL analytics on schedule.
|
||||||
|
2. Export to Prometheus-compatible metric path.
|
||||||
|
3. Add Grafana panel with 5m/1h/24h windows.
|
||||||
|
4. Alert when hit ratio < 90% for 15m.
|
||||||
|
|
||||||
|
Metric target:
|
||||||
|
|
||||||
|
- `cache_hit_ratio >= 0.90`
|
||||||
|
|
||||||
|
## 6) Capacity Trigger Rules
|
||||||
|
|
||||||
|
Define operational triggers:
|
||||||
|
|
||||||
|
- Cache miss ratio > 10% for 15m
|
||||||
|
- Chat p95 latency > 1s for 5m
|
||||||
|
- Host CPU > 80% for 10m
|
||||||
|
- Redis memory > 85% sustained
|
||||||
|
|
||||||
|
Action mapping:
|
||||||
|
|
||||||
|
- Level 1: investigate + restart non-critical components
|
||||||
|
- Level 2: scale service resources (CPU/RAM)
|
||||||
|
- Level 3: activate standby planning path
|
||||||
|
|
||||||
|
## 7) Phase 2 Service Separation Plan
|
||||||
|
|
||||||
|
Objective: separate bottlenecked services while keeping simple ops.
|
||||||
|
|
||||||
|
Boundaries:
|
||||||
|
|
||||||
|
- `playout` isolated first (CPU-heavy)
|
||||||
|
- `chat` + `redis` grouped for latency
|
||||||
|
- `db` isolated for IO stability
|
||||||
|
- monitoring stack optionally separated after sustained load
|
||||||
|
|
||||||
|
Initial sizing guidance:
|
||||||
|
|
||||||
|
- playout host: 4 vCPU / 8 GB
|
||||||
|
- chat host: 2-4 vCPU / 4-8 GB
|
||||||
|
- db host: 2 vCPU / 4 GB + fast disk
|
||||||
|
|
||||||
|
Exit criteria for Phase 2:
|
||||||
|
|
||||||
|
- repeated trigger events over 7 days
|
||||||
|
- capacity saturation despite tuning
|
||||||
|
|
||||||
|
## 8) Phase 3 Warm Standby Plan
|
||||||
|
|
||||||
|
Objective: second-region resilience with DNS failover.
|
||||||
|
|
||||||
|
Candidate layout:
|
||||||
|
|
||||||
|
- Primary: Hetzner Ashburn (US-East)
|
||||||
|
- Standby: Hetzner Hillsboro (US-West)
|
||||||
|
|
||||||
|
Design:
|
||||||
|
|
||||||
|
- Warm standby stack deployed and health-checked
|
||||||
|
- Redis replication or fallback mode documented
|
||||||
|
- Postgres replication strategy selected (streaming replica or periodic snapshot restore)
|
||||||
|
- Cloudflare DNS failover policy defined (health-based or manual emergency switch)
|
||||||
|
|
||||||
|
Failover drill requirements:
|
||||||
|
|
||||||
|
- periodic simulated cutover
|
||||||
|
- measured RTO/RPO logged in incident records
|
||||||
|
|
||||||
|
## 9) Data Capture Template
|
||||||
|
|
||||||
|
For each benchmark run, capture:
|
||||||
|
|
||||||
|
- Date/time
|
||||||
|
- Environment (test-lab/staging/production)
|
||||||
|
- Commit SHA / image tag
|
||||||
|
- Test scenario and duration
|
||||||
|
- Raw metrics (latency, errors, resource usage)
|
||||||
|
- Conclusion and next action
|
||||||
|
|
||||||
|
## 10) Execution Order
|
||||||
|
|
||||||
|
1. TTFB baseline
|
||||||
|
2. WebSocket load profile
|
||||||
|
3. Admin API benchmark
|
||||||
|
4. Cache ratio panel + alerting
|
||||||
|
5. Trigger tuning
|
||||||
|
6. Phase 2 boundary decision
|
||||||
|
7. Phase 3 standby design review
|
||||||
@@ -262,7 +262,7 @@ select {
|
|||||||
.btn {
|
.btn {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-1);
|
border-radius: var(--radius-1);
|
||||||
padding: 0.62rem 0.9rem;
|
padding: 0.2rem 0.5rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
@@ -411,14 +411,14 @@ select {
|
|||||||
|
|
||||||
.lv-body {
|
.lv-body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer-shell {
|
.viewer-shell {
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -821,7 +821,7 @@ select {
|
|||||||
border: 1px solid var(--line-soft);
|
border: 1px solid var(--line-soft);
|
||||||
border-radius: var(--radius-2);
|
border-radius: var(--radius-2);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
padding: var(--space-4);
|
padding: var(--space-2);
|
||||||
box-shadow: var(--shadow-1);
|
box-shadow: var(--shadow-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
# Cloudflare Cache Rules for Linear Void (per ADR-001)
|
# Cloudflare Cache Rules for Linear Void (per ADR-001)
|
||||||
|
|
||||||
## Recommended Cache Rules for media.example.com
|
## Recommended Cache Rules for media.linearvoid.tv
|
||||||
|
|
||||||
1. **.ts segments (immutable)**
|
1. **.m4s and .ts segments (immutable)**
|
||||||
- Expression: (http.host eq "media.example.com" and http.request.uri.path contains ".ts")
|
- Expression: (http.host eq "media.linearvoid.tv" and (http.request.uri.path contains ".ts" or http.request.uri.path contains ".m4s"))
|
||||||
- Cache status: Eligible for cache
|
- Cache status: Eligible for cache
|
||||||
- Edge Cache TTL: 1 month (or `Cache-Control: public, max-age=31536000, immutable` from origin)
|
- Edge Cache TTL: 1 month (or `Cache-Control: public, max-age=31536000, immutable` from origin)
|
||||||
- Cache Everything: Yes
|
- Cache Everything: Yes
|
||||||
|
|
||||||
2. **.m3u8 manifests (live)**
|
2. **.m3u8 manifests (live)**
|
||||||
- Expression: (http.host eq "media.example.com" and http.request.uri.path contains ".m3u8")
|
- Expression: (http.host eq "media.linearvoid.tv" and http.request.uri.path contains ".m3u8")
|
||||||
- Cache status: Eligible for cache
|
- Cache status: Eligible for cache
|
||||||
- Edge Cache TTL: 10 seconds
|
- Edge Cache TTL: 2 seconds
|
||||||
- Cache-Control: public, max-age=10, must-revalidate
|
- Cache-Control: public, max-age=2, must-revalidate
|
||||||
|
|
||||||
3. **Static assets**
|
3. **Static assets**
|
||||||
- Expression: (http.host eq "example.com" and http.request.uri.path contains ".js" or http.request.uri.path contains ".css")
|
- Expression: (http.host eq "linearvoid.tv" and http.request.uri.path contains ".js" or http.request.uri.path contains ".css")
|
||||||
- Edge Cache TTL: 1 year (with hash in filename for immutable caching)
|
- Edge Cache TTL: 1 year (with hash in filename for immutable caching)
|
||||||
|
|
||||||
**Tiered Cache**: Enable Smart Tiered Cache for R2 custom domain to reduce origin requests.
|
**Tiered Cache**: Enable Smart Tiered Cache for R2 custom domain to reduce origin requests.
|
||||||
|
|
||||||
**WAF**: Enable basic rate limiting and bot management for /ws and admin routes.
|
**WAF**: Enable basic rate limiting and bot management for /ws and admin routes.
|
||||||
|
|
||||||
These rules ensure high cache hit ratio for .ts segments while keeping live manifests fresh. Monitor cache hit ratio in Cloudflare analytics.
|
These rules ensure high cache hit ratio for .m4s and .ts segments while keeping live manifests fresh. Monitor cache hit ratio in Cloudflare analytics.
|
||||||
@@ -6,8 +6,8 @@ addEventListener("fetch", (event) => {
|
|||||||
async function handleRequest(request) {
|
async function handleRequest(request) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
// Add immutable cache headers for .ts segments
|
// Add immutable cache headers for .m4s segments
|
||||||
if (url.pathname.endsWith(".ts")) {
|
if (url.pathname.endsWith(".m4s")) {
|
||||||
const response = await fetch(request);
|
const response = await fetch(request);
|
||||||
const headers = new Headers(response.headers);
|
const headers = new Headers(response.headers);
|
||||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
|||||||
Reference in new issue
Block a user