update environment configuration for local development, enhance database initialization, and improve Nginx routing for admin access

This commit is contained in:
zwitschi committed 2026-08-03 14:26:11 +02:00
1 parent 2e061dff28
commit 1ae0ad121b
10 files changed
+185 -37

No files matched your search

+12 -11
View File
@@ -15,13 +15,14 @@ CLOUDFLARE_ACCOUNT_ID=your-account-id
MEDIA_DOMAIN=media.linearvoid.tv
# Public frontend URLs (Vite reads these from the repository root via frontend/vite.config.ts)
# Local defaults keep development on localhost. Production should use:
# VITE_PUBLIC_APP_URL=https://linearvoid.tv
# VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
# VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
VITE_PUBLIC_APP_URL=http://localhost:8080
VITE_PUBLIC_WS_URL=ws://localhost:3000/ws
VITE_PUBLIC_HLS_URL=http://localhost:8080/hls/playlist.m3u8
# Preferred local setup in this repo uses the real public domains through Cloudflare/Nginx Proxy Manager.
VITE_PUBLIC_APP_URL=https://linearvoid.tv
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
# Optional direct-local fallback when bypassing the public proxy:
# VITE_PUBLIC_APP_URL=http://localhost
# VITE_PUBLIC_WS_URL=ws://localhost/ws
# VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8
# Gitea Container Registry (for self-hosted Docker images)
GITEA_REGISTRY_URL=git.allucanget.biz
@@ -29,13 +30,13 @@ GITEA_USERNAME=your-gitea-username
GITEA_TOKEN=your-gitea-token
GITEA_REGISTRY_NAMESPACE=zwitschi/linearvoid
# Chat / Application Database (PostgreSQL or SQLite for local)
DATABASE_URL=postgres://user:password@localhost:5432/linearvoid
# Or for SQLite (local development):
# Chat / Application Database (compose-safe PostgreSQL by default, SQLite optional)
DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
# Or for SQLite-only local development:
# DATABASE_URL=sqlite://./data/chat.db
# Redis (for presence, rate limiting, pub/sub)
REDIS_URL=redis://localhost:6379/0
REDIS_URL=redis://redis:6379/0
# Service Ports (for local docker-compose)
PLAYOUT_PORT=8080
+14
View File
@@ -1,5 +1,19 @@
# Change Log for linearvoid
<!-- markdownlint-disable MD024 -->
## [1.0.2] - 2026-08-03
### Changed
- Updated local environment documentation and examples to prefer real-domain local routing through `linearvoid.tv` and compose-safe internal service hostnames.
### Fixed
- Fixed admin production build output and nginx routing so `/admin/` serves its own entrypoint.
- Fixed static asset serving so hashed JS/CSS files return correct MIME types and stale asset URLs return `404` instead of SPA HTML.
- Fixed chat database configuration so `DATABASE_URL` is honored for compose-safe Postgres or SQLite local development.
## [1.0.1] - 2026-08-03
### Changed
+17 -10
View File
@@ -54,9 +54,14 @@ This guide covers prerequisites, environment setup, running the application loca
MEDIA_DOMAIN=media.linearvoid.tv
# Public frontend URLs
VITE_PUBLIC_APP_URL=http://localhost:8080
VITE_PUBLIC_WS_URL=ws://localhost:3000/ws
VITE_PUBLIC_HLS_URL=http://localhost:8080/hls/playlist.m3u8
# Preferred local setup uses the real public domains through Cloudflare and Nginx Proxy Manager.
VITE_PUBLIC_APP_URL=https://linearvoid.tv
VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws
VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8
# Optional direct-local fallback when bypassing the public proxy:
# VITE_PUBLIC_APP_URL=http://localhost
# VITE_PUBLIC_WS_URL=ws://localhost/ws
# VITE_PUBLIC_HLS_URL=http://localhost/hls/playlist.m3u8
# Gitea Container Registry (for self-hosted Docker images)
GITEA_REGISTRY_URL=git.allucanget.biz
@@ -65,10 +70,10 @@ This guide covers prerequisites, environment setup, running the application loca
GITEA_REGISTRY_NAMESPACE=zwitschi/linearvoid
# Chat / Database
DATABASE_URL=postgres://user:password@localhost:5432/linearvoid
# or for SQLite in local development:
DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
# or for SQLite-only local development:
# DATABASE_URL=sqlite://./data/chat.db
REDIS_URL=redis://localhost:6379/0
REDIS_URL=redis://redis:6379/0
# Ports (for local development)
PLAYOUT_PORT=8080
@@ -98,7 +103,7 @@ This guide covers prerequisites, environment setup, running the application loca
4. **Check local value overrides for Docker Compose**:
- `docker-compose.yml` builds images locally and maps the frontend build output from `frontend/dist` into nginx.
- `docker-compose.prod.yml` pulls prebuilt images from the Gitea registry.
- For production on the VPS, change hostnames in `.env` so containers talk to each other over the compose network, for example `REDIS_URL=redis://redis:6379/0` and `DATABASE_URL=postgres://linearvoid:<password>@db:5432/linearvoid`.
- Internal container-to-container URLs should use compose hostnames, for example `REDIS_URL=redis://redis:6379/0` and `DATABASE_URL=postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`.
- For production public routing, set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
5. **Create Data Directories**:
@@ -182,9 +187,10 @@ This guide covers prerequisites, environment setup, running the application loca
```
2. **Access the Application**:
- Viewer: <http://localhost:8080>
- Chat WebSocket: `ws://localhost:3000/ws` when using the local env defaults, or `ws://localhost:8080/ws` if you switch to same-origin local routing.
- Admin Interface: <http://localhost:8080/admin> (use the credentials from `.env` or default admin/admin)
- Preferred local access with the documented proxy setup: <https://linearvoid.tv>
- Admin Interface: <https://linearvoid.tv/admin>
- Chat WebSocket: `wss://linearvoid.tv/ws`
- Direct-local fallback when bypassing Cloudflare/Nginx Proxy Manager: <http://localhost>, <http://localhost/admin>, and `ws://localhost/ws`
- Health Checks:
- Playout: <http://localhost:8081/health>
- Chat: <http://localhost:3000/health>
@@ -239,6 +245,7 @@ This guide covers prerequisites, environment setup, running the application loca
- Keep `MEDIA_DOMAIN`, R2 credentials, and Gitea registry values consistent with the external infrastructure.
- Set `MEDIA_DOMAIN=media.linearvoid.tv`.
- Set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
- If you intentionally bypass the public proxy for local-only testing, switch those values to `http://localhost`, `ws://localhost/ws`, and `http://localhost/hls/playlist.m3u8`.
4. **Create runtime directories on the server**:
+10 -2
View File
@@ -1,10 +1,11 @@
module chat
go 1.23
go 1.23.0
require (
github.com/gorilla/websocket v1.5.3
github.com/redis/go-redis/v9 v9.7.0
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
@@ -12,7 +13,14 @@ require (
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
)
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
+52
View File
@@ -0,0 +1,52 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
+46 -12
View File
@@ -12,6 +12,7 @@ import (
"github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -56,11 +57,25 @@ type Presence struct {
// initRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007)
func initRedis() {
redisClient = redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_URL"),
Password: "",
DB: 0,
})
redisAddress := os.Getenv("REDIS_URL")
if redisAddress == "" {
redisAddress = "redis:6379"
}
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)
redisClient = nil
return
}
} else {
options = &redis.Options{Addr: redisAddress}
}
redisClient = redis.NewClient(options)
if err := redisClient.Ping(context.Background()).Err(); err != nil {
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
redisClient = nil
@@ -72,12 +87,31 @@ func initRedis() {
// initDB initializes SQLite for message history and moderation audit log (ADR-006)
func initDB() {
var err error
db, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to SQLite:", err)
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
driverName := "sqlite"
switch {
case databaseURL == "":
db, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
case strings.HasPrefix(databaseURL, "sqlite://"):
sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://")
if sqlitePath == "" {
sqlitePath = "chat.db"
}
db, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"):
driverName = "postgres"
db, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
default:
log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL)
}
if err != nil {
log.Fatalf("Failed to connect to %s database: %v", driverName, err)
}
db.AutoMigrate(&Message{}, &ModerationLog{})
log.Println("SQLite database initialized (chat history and moderation log)")
log.Printf("%s database initialized (chat history and moderation log)", strings.Title(driverName))
}
// broadcast sends a message to all connected clients in a room and saves to DB
@@ -110,7 +144,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
// Announce presence (ADR-007)
username := r.URL.Query().Get("username")
room := r.URL.Query().Get("room")
if username != "" && room != "" {
if redisClient != nil && username != "" && room != "" {
redisClient.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
broadcast(Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system"})
}
@@ -119,7 +153,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
mutex.Lock()
delete(clients, conn)
mutex.Unlock()
if username != "" && room != "" {
if redisClient != nil && username != "" && room != "" {
redisClient.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
broadcast(Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system"})
}
@@ -230,6 +264,6 @@ func main() {
port = "3000"
}
log.Printf("Chat Backend starting on :%s (WebSocket at /ws, health at /health, SQLite persistence enabled)", port)
log.Printf("Chat Backend starting on :%s (WebSocket at /ws, health at /health, database persistence enabled)", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
+8 -1
View File
@@ -64,6 +64,12 @@ func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "file ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
line = strings.Trim(line, "'\"")
}
videos = append(videos, VideoFile{Path: line})
}
@@ -177,8 +183,9 @@ func main() {
"-f", "hls",
"-hls_time", "6",
"-hls_list_size", "24",
"-strftime", "1",
"-hls_flags", "delete_segments+append_list+independent_segments",
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S_%%03d.ts", // unique immutable names per ADR-001
"-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.ts",
"/app/hls/playlist.m3u8")
if err := cmd.Run(); err != nil {
+2 -1
View File
@@ -34,8 +34,9 @@ while true; do
-f hls \
-hls_time "${HLS_SEGMENT_DURATION}" \
-hls_list_size "${HLS_WINDOW_SIZE}" \
-strftime 1 \
-hls_flags delete_segments+append_list \
-hls_segment_filename "/app/hls/segment_%03d.ts" \
-hls_segment_filename "/app/hls/segment_%Y%m%d_%H%M%S.ts" \
"/app/hls/playlist.m3u8" || echo "FFmpeg exited with error (will restart in 5s per ADR-001 resilience requirements)..."
echo "Playout loop crashed or ended. Restarting in 5 seconds..."
+9
View File
@@ -1,5 +1,14 @@
import { resolve } from "node:path";
import { defineConfig } from "vite";
export default defineConfig({
envDir: "..",
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
admin: resolve(__dirname, "admin/index.html"),
},
},
},
});
+15
View File
@@ -3,6 +3,9 @@ events {
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
resolver 127.0.0.11 ipv6=off valid=30s;
map $http_upgrade $connection_upgrade {
@@ -24,6 +27,18 @@ http {
root /srv/frontend;
index index.html;
location = /admin {
return 301 /admin/;
}
location /admin/ {
try_files $uri $uri/ /admin/index.html;
}
location /assets/ {
try_files $uri =404;
}
# Serve static frontend
location / {
try_files $uri $uri/ /index.html;