2026-03-14 20:34:37 +01:00
|
|
|
// Package api provides the HTTP router and handlers for the OwnCord server.
|
|
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-03-15 11:42:25 +01:00
|
|
|
"log/slog"
|
2026-03-14 20:34:37 +01:00
|
|
|
"net/http"
|
2026-03-17 02:56:19 +01:00
|
|
|
"time"
|
2026-03-14 20:34:37 +01:00
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
2026-03-14 21:31:03 +01:00
|
|
|
"github.com/owncord/server/admin"
|
2026-03-14 20:52:11 +01:00
|
|
|
"github.com/owncord/server/auth"
|
2026-03-14 20:34:37 +01:00
|
|
|
"github.com/owncord/server/config"
|
|
|
|
|
"github.com/owncord/server/db"
|
2026-03-18 14:13:52 +01:00
|
|
|
"github.com/owncord/server/storage"
|
2026-03-14 22:05:13 +01:00
|
|
|
"github.com/owncord/server/updater"
|
2026-03-14 21:17:09 +01:00
|
|
|
"github.com/owncord/server/ws"
|
2026-03-14 20:34:37 +01:00
|
|
|
)
|
|
|
|
|
|
2026-03-19 03:53:40 +01:00
|
|
|
// NewRouter builds and returns the fully configured HTTP handler and the
|
|
|
|
|
// WebSocket hub (so the caller can call hub.GracefulStop on shutdown).
|
2026-03-19 05:32:40 +01:00
|
|
|
func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.RingBuffer) (http.Handler, *ws.Hub) {
|
2026-03-14 20:34:37 +01:00
|
|
|
r := chi.NewRouter()
|
|
|
|
|
|
|
|
|
|
// Middleware stack.
|
|
|
|
|
r.Use(middleware.RequestID)
|
|
|
|
|
r.Use(setRequestIDHeader) // echo request ID into response header
|
2026-03-15 07:07:59 +01:00
|
|
|
// NOTE: middleware.RealIP is intentionally omitted — trusting X-Real-IP from
|
|
|
|
|
// any source allows IP spoofing for rate-limit bypass. IP header trust is now
|
|
|
|
|
// handled explicitly in clientIPWithProxies using the trusted_proxies config.
|
2026-03-14 20:34:37 +01:00
|
|
|
r.Use(middleware.Recoverer)
|
2026-03-17 20:25:15 +01:00
|
|
|
r.Use(requestLogger) // structured request/response logging
|
2026-03-15 07:07:59 +01:00
|
|
|
r.Use(SecurityHeaders)
|
2026-03-18 17:10:16 +01:00
|
|
|
r.Use(MaxBodySizeUnless(1<<20, "/api/v1/uploads")) // 1 MiB default; upload route exempt
|
2026-03-14 22:05:13 +01:00
|
|
|
|
2026-03-14 20:34:37 +01:00
|
|
|
// Health check — unauthenticated, no versioning prefix.
|
2026-03-15 07:07:59 +01:00
|
|
|
r.Get("/health", handleHealth(ver))
|
2026-03-14 20:34:37 +01:00
|
|
|
|
2026-03-14 20:52:11 +01:00
|
|
|
// Shared rate limiter for auth endpoints.
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
|
2026-03-14 20:34:37 +01:00
|
|
|
// Versioned API routes.
|
|
|
|
|
r.Route("/api/v1", func(r chi.Router) {
|
2026-03-16 16:54:56 +01:00
|
|
|
r.Get("/health", handleHealth(ver))
|
2026-03-15 07:07:59 +01:00
|
|
|
r.Get("/info", handleInfo(cfg, ver))
|
2026-03-14 20:34:37 +01:00
|
|
|
})
|
|
|
|
|
|
2026-03-14 20:52:11 +01:00
|
|
|
// Auth routes: register, login, logout, me.
|
2026-03-24 21:30:23 +01:00
|
|
|
MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies)
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
// Invite management routes (require MANAGE_INVITES permission).
|
|
|
|
|
MountInviteRoutes(r, database)
|
|
|
|
|
|
2026-03-14 21:17:09 +01:00
|
|
|
// Channel and message REST routes.
|
|
|
|
|
MountChannelRoutes(r, database)
|
|
|
|
|
|
2026-03-18 14:13:52 +01:00
|
|
|
// File upload and serving routes.
|
|
|
|
|
store, storeErr := storage.New(cfg.Upload.StorageDir, cfg.Upload.MaxSizeMB)
|
|
|
|
|
if storeErr != nil {
|
|
|
|
|
slog.Error("failed to create file storage", "error", storeErr)
|
|
|
|
|
} else {
|
|
|
|
|
MountUploadRoutes(r, database, store)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 21:17:09 +01:00
|
|
|
// WebSocket hub — WS does its own in-band auth, so no AuthMiddleware here.
|
|
|
|
|
hub := ws.NewHub(database, limiter)
|
2026-03-15 11:42:25 +01:00
|
|
|
|
2026-03-20 05:30:27 +01:00
|
|
|
// Create LiveKit client if voice config is present; voice is disabled on failure.
|
|
|
|
|
lk, lkErr := ws.NewLiveKitClient(&cfg.Voice)
|
|
|
|
|
if lkErr != nil {
|
|
|
|
|
slog.Warn("failed to create LiveKit client, voice disabled", "error", lkErr)
|
2026-03-15 11:42:25 +01:00
|
|
|
} else {
|
2026-03-20 05:30:27 +01:00
|
|
|
hub.SetLiveKit(lk)
|
|
|
|
|
|
|
|
|
|
// Optionally start a companion LiveKit process.
|
|
|
|
|
if cfg.Voice.LiveKitBinaryPath != "" {
|
2026-03-20 06:11:09 +01:00
|
|
|
proc := ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir)
|
2026-03-20 05:30:27 +01:00
|
|
|
if startErr := proc.Start(); startErr != nil {
|
|
|
|
|
slog.Error("failed to start LiveKit process", "error", startErr)
|
|
|
|
|
} else {
|
|
|
|
|
hub.SetLiveKitProcess(proc)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-15 11:42:25 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 05:34:38 +01:00
|
|
|
// LiveKit webhook endpoint (no auth middleware — uses LiveKit JWT verification).
|
|
|
|
|
if lkErr == nil {
|
|
|
|
|
r.Post("/api/v1/livekit/webhook",
|
|
|
|
|
ws.MountWebhookRoute(hub, cfg.Voice.LiveKitAPIKey, cfg.Voice.LiveKitAPISecret))
|
2026-03-20 06:11:09 +01:00
|
|
|
|
2026-03-21 11:59:14 +01:00
|
|
|
// LiveKit health check — admin-IP-restricted.
|
|
|
|
|
r.With(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs)).
|
|
|
|
|
Get("/api/v1/livekit/health", handleLiveKitHealth(hub))
|
|
|
|
|
|
2026-03-20 06:11:09 +01:00
|
|
|
// Reverse proxy LiveKit signaling through OwnCord's HTTPS server.
|
|
|
|
|
// This avoids mixed-content blocks (secure page → insecure WS).
|
|
|
|
|
// Client connects to wss://server:8443/livekit/* → ws://localhost:7880/*
|
2026-03-24 20:23:40 +01:00
|
|
|
// Auth + rate limiting prevent unauthenticated access to the LiveKit SFU.
|
|
|
|
|
r.With(AuthMiddleware(database), RateLimitMiddleware(limiter, 30, time.Minute)).
|
|
|
|
|
Handle("/livekit/*", http.StripPrefix("/livekit", NewLiveKitProxy(cfg.Voice.LiveKitURL, cfg.Server.AllowedOrigins)))
|
2026-03-20 05:34:38 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-14 21:17:09 +01:00
|
|
|
go hub.Run()
|
2026-03-15 07:07:59 +01:00
|
|
|
r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins))
|
2026-03-14 21:17:09 +01:00
|
|
|
|
2026-03-21 10:08:44 +01:00
|
|
|
// Metrics endpoint — admin-IP-restricted, returns runtime stats as JSON.
|
|
|
|
|
r.With(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs)).
|
2026-03-22 19:29:42 +01:00
|
|
|
Get("/api/v1/metrics", handleMetrics(
|
|
|
|
|
func() int { return hub.ClientCount() },
|
|
|
|
|
func() (bool, error) { return hub.LiveKitHealthCheck() },
|
|
|
|
|
))
|
2026-03-21 10:08:44 +01:00
|
|
|
|
2026-03-14 21:31:03 +01:00
|
|
|
// Admin panel: static files + REST API (Phase 6).
|
2026-03-19 12:23:24 +01:00
|
|
|
// Restrict /admin to configured CIDRs (default: private networks only).
|
2026-03-14 22:05:13 +01:00
|
|
|
u := updater.NewUpdater(ver, cfg.GitHub.Token, "J3vb", "OwnCord")
|
2026-03-19 12:23:24 +01:00
|
|
|
adminHandler := admin.NewHandler(database, ver, hub, u, logBuf)
|
|
|
|
|
r.Group(func(r chi.Router) {
|
|
|
|
|
r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs))
|
|
|
|
|
r.Mount("/admin", adminHandler)
|
|
|
|
|
})
|
2026-03-14 21:31:03 +01:00
|
|
|
|
2026-03-18 17:47:59 +01:00
|
|
|
// Client auto-update endpoint (unauthenticated).
|
|
|
|
|
MountClientUpdateRoute(r, u)
|
|
|
|
|
|
2026-03-19 03:53:40 +01:00
|
|
|
return r, hub
|
2026-03-14 20:34:37 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 02:56:19 +01:00
|
|
|
// serverStartTime records when the process started; used for uptime in /health.
|
|
|
|
|
var serverStartTime = time.Now()
|
|
|
|
|
|
2026-03-14 20:34:37 +01:00
|
|
|
// healthResponse is the JSON shape returned by GET /health.
|
|
|
|
|
type healthResponse struct {
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
Version string `json:"version"`
|
2026-03-17 02:56:19 +01:00
|
|
|
Uptime int64 `json:"uptime"`
|
2026-03-14 20:34:37 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// infoResponse is the JSON shape returned by GET /api/v1/info.
|
|
|
|
|
type infoResponse struct {
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Version string `json:"version"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 07:07:59 +01:00
|
|
|
func handleHealth(ver string) http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
writeJSON(w, http.StatusOK, healthResponse{
|
|
|
|
|
Status: "ok",
|
|
|
|
|
Version: ver,
|
2026-03-17 02:56:19 +01:00
|
|
|
Uptime: int64(time.Since(serverStartTime).Seconds()),
|
2026-03-15 07:07:59 +01:00
|
|
|
})
|
|
|
|
|
}
|
2026-03-14 20:34:37 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-15 07:07:59 +01:00
|
|
|
func handleInfo(cfg *config.Config, ver string) http.HandlerFunc {
|
2026-03-14 20:34:37 +01:00
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
writeJSON(w, http.StatusOK, infoResponse{
|
|
|
|
|
Name: cfg.Server.Name,
|
2026-03-15 07:07:59 +01:00
|
|
|
Version: ver,
|
2026-03-14 20:34:37 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 11:59:14 +01:00
|
|
|
// livekitHealthResponse is the JSON shape returned by GET /api/v1/livekit/health.
|
|
|
|
|
type livekitHealthResponse struct {
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
LiveKitReachable bool `json:"livekit_reachable"`
|
|
|
|
|
Error string `json:"error,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func handleLiveKitHealth(hub *ws.Hub) http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ok, err := hub.LiveKitHealthCheck()
|
|
|
|
|
if ok {
|
|
|
|
|
writeJSON(w, http.StatusOK, livekitHealthResponse{
|
|
|
|
|
Status: "ok",
|
|
|
|
|
LiveKitReachable: true,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
errMsg := "unknown"
|
|
|
|
|
if err != nil {
|
|
|
|
|
errMsg = err.Error()
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusServiceUnavailable, livekitHealthResponse{
|
|
|
|
|
Status: "degraded",
|
|
|
|
|
LiveKitReachable: false,
|
|
|
|
|
Error: errMsg,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 20:34:37 +01:00
|
|
|
// setRequestIDHeader copies the request ID from context into the response header.
|
|
|
|
|
func setRequestIDHeader(next http.Handler) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
requestID := middleware.GetReqID(r.Context())
|
|
|
|
|
if requestID != "" {
|
|
|
|
|
w.Header().Set("X-Request-Id", requestID)
|
|
|
|
|
}
|
|
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 20:25:15 +01:00
|
|
|
// requestLogger logs every HTTP request with method, path, status, and duration.
|
|
|
|
|
// Health checks are logged at Debug level to avoid noise.
|
|
|
|
|
func requestLogger(next http.Handler) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
start := time.Now()
|
|
|
|
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
|
|
|
next.ServeHTTP(ww, r)
|
|
|
|
|
elapsed := time.Since(start)
|
|
|
|
|
status := ww.Status()
|
|
|
|
|
|
|
|
|
|
// Health checks at Debug level; errors at Warn; everything else at Info.
|
|
|
|
|
path := r.URL.Path
|
|
|
|
|
attrs := []any{
|
|
|
|
|
"method", r.Method,
|
|
|
|
|
"path", path,
|
|
|
|
|
"status", status,
|
|
|
|
|
"duration_ms", elapsed.Milliseconds(),
|
|
|
|
|
}
|
|
|
|
|
switch {
|
|
|
|
|
case path == "/health" || path == "/api/v1/health":
|
|
|
|
|
slog.Debug("http request", attrs...)
|
|
|
|
|
case status >= 500:
|
|
|
|
|
slog.Error("http request", attrs...)
|
|
|
|
|
case status >= 400:
|
|
|
|
|
slog.Warn("http request", attrs...)
|
|
|
|
|
default:
|
|
|
|
|
slog.Info("http request", attrs...)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 20:34:37 +01:00
|
|
|
// writeJSON encodes v as JSON and writes it to w with the given status code.
|
2026-03-15 07:07:59 +01:00
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
2026-03-14 20:34:37 +01:00
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
|
w.WriteHeader(status)
|
|
|
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
|
|
|
}
|