Files
OwnCord/Server/admin/api.go
T
jevb ab389764b5 feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)
Phase 5 — Voice:
- migrations/002_voice_states.sql: voice_states table with FK + index
- db/voice_queries: JoinVoiceChannel, LeaveVoiceChannel, GetVoiceState,
  GetChannelVoiceStates, UpdateVoiceMute, UpdateVoiceDeafen, ClearVoiceState
- ws/voice_handlers: handleVoiceJoin (perm check, DB, broadcast existing
  states), handleVoiceLeave, handleVoiceMute, handleVoiceDeafen,
  handleVoiceSignal (rate-limited relay, SDP never logged),
  handleSoundboard (rate-limited, USE_SOUNDBOARD perm check)
- ws/handlers: dispatch voice_join/leave/mute/deafen/offer/answer/ice/soundboard
- ws/serve: call handleVoiceLeave on disconnect; include voice states in ready payload
- ws/messages: buildVoiceState, buildVoiceLeave, buildVoiceSignalRelay
- api/voice_handler: GET /api/v1/voice/credentials — HMAC-SHA1 TURN creds
- config: VoiceConfig (TURNSecret, STUNPort, TURNPort, TURNEnabled)

Phase 6 — Admin Panel:
- migrations/003_audit_log.sql: audit_log table with indexes
- db/admin_queries: GetServerStats, ListAllUsers, UpdateUserRole,
  ForceLogoutUser, AdminCreate/Update/DeleteChannel, LogAudit,
  GetAuditLog, GetSetting, SetSetting, GetAllSettings, BackupTo
- admin/api: full REST API — stats, users, channels, audit log, settings,
  backup; adminAuthMiddleware (ADMINISTRATOR bit), ownerOnlyMiddleware
- admin/static/index.html: single-page admin panel (dark theme, vanilla JS,
  no CDN) — dashboard, users, channels, audit log, settings sections
- admin/admin.go: NewHandler wiring go:embed static files + API

Fixes: Channel struct json tags (was serializing as "ID" not "id"),
duplicate getWithToken helper renamed in voice_handler_test.go

Test coverage: admin 59.1%, api 78.2%, auth 90.9%, db 82.0%, ws 37.9%
2026-03-14 21:31:03 +01:00

497 lines
15 KiB
Go

package admin
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
)
// ─── Permission constants ─────────────────────────────────────────────────────
const (
permAdministrator = int64(0x40000000)
ownerRolePosition = 100
)
// ─── NewAdminAPI ──────────────────────────────────────────────────────────────
// NewAdminAPI returns a chi router with all /admin/api/* routes. All routes
// are protected by adminAuthMiddleware which requires the ADMINISTRATOR bit.
func NewAdminAPI(database *db.DB) http.Handler {
r := chi.NewRouter()
// All routes require authentication and ADMINISTRATOR permission.
r.Use(adminAuthMiddleware(database))
r.Get("/stats", handleGetStats(database))
r.Get("/users", handleListUsers(database))
r.Patch("/users/{id}", handlePatchUser(database))
r.Delete("/users/{id}/sessions", handleForceLogout(database))
r.Get("/channels", handleListChannels(database))
r.Post("/channels", handleCreateChannel(database))
r.Patch("/channels/{id}", handlePatchChannel(database))
r.Delete("/channels/{id}", handleDeleteChannel(database))
r.Get("/audit-log", handleGetAuditLog(database))
r.Get("/settings", handleGetSettings(database))
r.Patch("/settings", handlePatchSettings(database))
r.Post("/backup", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleBackup(database)).ServeHTTP(w, req)
}))
return r
}
// ─── Middleware ───────────────────────────────────────────────────────────────
// adminAuthMiddleware validates the Bearer token and requires ADMINISTRATOR.
func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := extractBearer(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing or invalid authorization header")
return
}
hash := auth.HashToken(token)
sess, err := database.GetSessionByTokenHash(hash)
if err != nil || sess == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session")
return
}
if isExpired(sess.ExpiresAt) {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "session has expired")
return
}
user, err := database.GetUserByID(sess.UserID)
if err != nil || user == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found")
return
}
role, err := database.GetRoleByID(user.RoleID)
if err != nil || role == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "role not found")
return
}
if role.Permissions&permAdministrator == 0 {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "administrator permission required")
return
}
next.ServeHTTP(w, r)
})
}
}
// ownerOnlyMiddleware wraps a handler to require Owner role (position == 100).
func ownerOnlyMiddleware(database *db.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := extractBearer(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing authorization header")
return
}
hash := auth.HashToken(token)
sess, err := database.GetSessionByTokenHash(hash)
if err != nil || sess == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid session")
return
}
user, err := database.GetUserByID(sess.UserID)
if err != nil || user == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found")
return
}
role, err := database.GetRoleByID(user.RoleID)
if err != nil || role == nil {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "role not found")
return
}
if role.Position < ownerRolePosition {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "owner role required")
return
}
next.ServeHTTP(w, r)
})
}
// ─── Handlers ─────────────────────────────────────────────────────────────────
func handleGetStats(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := database.GetServerStats()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get stats")
return
}
writeJSON(w, http.StatusOK, stats)
}
}
func handleListUsers(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 50)
offset := queryInt(r, "offset", 0)
users, err := database.ListAllUsers(limit, offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list users")
return
}
writeJSON(w, http.StatusOK, users)
}
}
// patchUserRequest is the JSON body for PATCH /admin/api/users/{id}.
type patchUserRequest struct {
RoleID *int64 `json:"role_id"`
Banned *bool `json:"banned"`
BanReason *string `json:"ban_reason"`
}
func handlePatchUser(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id")
return
}
var req patchUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
user, err := database.GetUserByID(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch user")
return
}
if user == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "user not found")
return
}
if req.RoleID != nil {
if err := database.UpdateUserRole(id, *req.RoleID); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update role")
return
}
}
if req.Banned != nil {
reason := ""
if req.BanReason != nil {
reason = *req.BanReason
}
if *req.Banned {
if err := database.BanUser(id, reason, nil); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to ban user")
return
}
} else {
if err := database.UnbanUser(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to unban user")
return
}
}
}
updated, err := database.GetUserByID(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated user")
return
}
writeJSON(w, http.StatusOK, updated)
}
}
func handleForceLogout(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id")
return
}
if err := database.ForceLogoutUser(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to logout user")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func handleListChannels(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
channels, err := database.ListChannels()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channels")
return
}
writeJSON(w, http.StatusOK, channels)
}
}
// createChannelRequest is the JSON body for POST /admin/api/channels.
type createChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Topic string `json:"topic"`
Position int `json:"position"`
}
func handleCreateChannel(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req createChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
if strings.TrimSpace(req.Name) == "" {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "name is required")
return
}
if req.Type == "" {
req.Type = "text"
}
id, err := database.AdminCreateChannel(req.Name, req.Type, req.Category, req.Topic, req.Position)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create channel")
return
}
ch, err := database.GetChannel(id)
if err != nil || ch == nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch created channel")
return
}
writeJSON(w, http.StatusCreated, ch)
}
}
// updateChannelRequest is the JSON body for PATCH /admin/api/channels/{id}.
type updateChannelRequest struct {
Name string `json:"name"`
Topic string `json:"topic"`
SlowMode int `json:"slow_mode"`
Position int `json:"position"`
Archived bool `json:"archived"`
}
func handlePatchChannel(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id")
return
}
existing, err := database.GetChannel(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
return
}
if existing == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
return
}
// Start from existing values so a partial body is safe.
req := updateChannelRequest{
Name: existing.Name,
Topic: existing.Topic,
SlowMode: existing.SlowMode,
Position: existing.Position,
Archived: existing.Archived,
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
if err := database.AdminUpdateChannel(id, req.Name, req.Topic, req.SlowMode, req.Position, req.Archived); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update channel")
return
}
updated, _ := database.GetChannel(id)
writeJSON(w, http.StatusOK, updated)
}
}
func handleDeleteChannel(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id")
return
}
existing, err := database.GetChannel(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
return
}
if existing == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
return
}
if err := database.AdminDeleteChannel(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func handleGetAuditLog(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 50)
offset := queryInt(r, "offset", 0)
entries, err := database.GetAuditLog(limit, offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get audit log")
return
}
writeJSON(w, http.StatusOK, entries)
}
}
func handleGetSettings(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := database.GetAllSettings()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get settings")
return
}
writeJSON(w, http.StatusOK, settings)
}
}
func handlePatchSettings(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var updates map[string]string
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
for key, value := range updates {
if err := database.SetSetting(key, value); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update setting: "+key)
return
}
}
settings, err := database.GetAllSettings()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch settings")
return
}
writeJSON(w, http.StatusOK, settings)
}
}
func handleBackup(database *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backupDir := filepath.Join("data", "backups")
if err := os.MkdirAll(backupDir, 0o750); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create backup directory")
return
}
timestamp := time.Now().UTC().Format("20060102_150405")
backupPath := filepath.Join(backupDir, "chatserver_"+timestamp+".db")
if err := database.BackupTo(backupPath); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "backup failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"path": backupPath,
"created": timestamp,
})
})
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, errorResponse{Error: code, Message: msg})
}
func extractBearer(r *http.Request) (string, bool) {
header := r.Header.Get("Authorization")
if header == "" {
return "", false
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || parts[1] == "" {
return "", false
}
return parts[1], true
}
func pathInt64(r *http.Request, param string) (int64, error) {
raw := chi.URLParam(r, param)
return strconv.ParseInt(raw, 10, 64)
}
func queryInt(r *http.Request, key string, defaultVal int) int {
raw := r.URL.Query().Get(key)
if raw == "" {
return defaultVal
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return defaultVal
}
return n
}
func isExpired(expiresAt string) bool {
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
t, err := time.Parse(layout, expiresAt)
if err == nil {
return time.Now().UTC().After(t.UTC())
}
}
return true
}