mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116) Route the tray Status submenu through saveUserStatus() (mapping the legacy "offline" to "invisible") so notifications, autoIdle, and reconnect presence restore all agree with the tray's choice; build the connected overlay from the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the TOTP overlay open across a rejected verify (totpPending latch) and retain the partial token for the retry instead of clearing it in finally. Hand-applied combined cluster preserved from the previous fix run's overlap-guard block (both clusters edit main.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 2 defect(s) (OC-0010, OC-0011) * fix(ws): 1 defect(s) (OC-0050) * fix(db): 1 defect(s) (OC-0052) * fix(client): 1 defect(s) (OC-0054) * fix(client): 1 defect(s) (OC-0059) * fix(auth): 1 defect(s) (OC-0061) * fix(ws): 1 defect(s) (OC-0062) * fix(client): 1 defect(s) (OC-0064) * fix(service): 1 defect(s) (OC-0070) * fix(ws): 1 defect(s) (OC-0073) * fix(service): 2 defect(s) (OC-0075, OC-0120) * fix(admin): 1 defect(s) (OC-0076) * fix(voice): 1 defect(s) (OC-0084) * fix(client): 2 defect(s) (OC-0085, OC-0094) Scope collapsed-category persistence to the connected host instead of the server display name, and stop the DM back button from jumping to the first text channel when DM mode was entered without recording channelBeforeDm. * fix(service): 1 defect(s) (OC-0087) * fix(client): 1 defect(s) (OC-0089) * fix(ws): 1 defect(s) (OC-0091) * fix(api): 1 defect(s) (OC-0093) * fix(identity): 1 defect(s) (OC-0118) * fix(dm): 1 defect(s) (OC-0119) * fix(voice): 1 defect(s) (OC-0135) * fix(api): 1 defect(s) (OC-0137) * fix(client): 1 defect(s) (OC-0142) * fix(client): 1 defect(s) (OC-0144) * fix(admin): 1 defect(s) (OC-0145) * fix(updater): 1 defect(s) (OC-0146) * fix(client): 1 defect(s) (OC-0150) * fix(mentions): 1 defect(s) (OC-0131) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
185 lines
8.9 KiB
Go
185 lines
8.9 KiB
Go
package admin
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
"github.com/owncord/server/service"
|
|
"github.com/owncord/server/updater"
|
|
)
|
|
|
|
// setupLimiterReapInterval and setupLimiterReapMaxWindow control how often
|
|
// the setup endpoint's dedicated rate limiter reaps stale window entries.
|
|
// Vars, not consts, so tests can shrink them instead of waiting on the real
|
|
// interval (see export_test.go).
|
|
var (
|
|
setupLimiterReapInterval = 5 * time.Minute
|
|
setupLimiterReapMaxWindow = 15 * time.Minute
|
|
)
|
|
|
|
// setupLimiterHook, when non-nil, receives the *auth.RateLimiter NewAdminAPI
|
|
// creates for the /setup endpoint. Test-only seam: NewAdminAPI returns only
|
|
// an http.Handler, so tests otherwise have no way to reach that limiter to
|
|
// verify it gets reaped.
|
|
var setupLimiterHook func(*auth.RateLimiter)
|
|
|
|
// startSetupLimiterReap keeps rl's window map bounded for the life of the
|
|
// process. Every distinct source IP that ever hits POST /setup leaves an
|
|
// entry that Allow itself only prunes on a repeat call from that same key —
|
|
// a one-shot caller's entry sits forever unless something sweeps the whole
|
|
// map. api/router.go reaps its own limiter with RateLimiter.StartCleanup, a
|
|
// goroutine parked in a ticker select until a stop channel closes — but
|
|
// NewAdminAPI has no shutdown hook and is called directly by ~180 tests that
|
|
// never capture one, so a parked goroutine here would leak under every
|
|
// test's goleak check. time.AfterFunc self-rescheduling avoids that: between
|
|
// fires there is no live goroutine, only a runtime timer, so nothing needs
|
|
// to stop it.
|
|
func startSetupLimiterReap(rl *auth.RateLimiter) {
|
|
// Capture the timing once, synchronously, on the caller's goroutine.
|
|
// The rescheduled AfterFunc callbacks below must never re-read the
|
|
// package vars themselves: those callbacks run on their own goroutine
|
|
// indefinitely (nothing stops the chain), so a later test's
|
|
// SetSetupLimiterReapTiming restoring the vars on its own goroutine
|
|
// would otherwise race an in-flight reap here.
|
|
interval, maxWindow := setupLimiterReapInterval, setupLimiterReapMaxWindow
|
|
var reap func()
|
|
reap = func() {
|
|
rl.Cleanup(maxWindow)
|
|
time.AfterFunc(interval, reap)
|
|
}
|
|
time.AfterFunc(interval, reap)
|
|
}
|
|
|
|
// ─── NewAdminAPI ──────────────────────────────────────────────────────────────
|
|
|
|
// NewAdminAPI returns a chi router with all /admin/api/* routes. All routes
|
|
// except the unauthenticated setup endpoints are protected by
|
|
// adminAuthMiddleware, which admits any role holding a bit of
|
|
// permissions.AdminPerimeter; route groups then require the specific bit
|
|
// (requirePerm) or the Owner role (ownerOnlyMiddleware).
|
|
//
|
|
// The optional trailing SetupOptions enables the first-run wizard's
|
|
// config.yaml write-back and restart; without it the setup endpoints keep
|
|
// their legacy account-only behaviour (the case in most tests).
|
|
func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, opts ...SetupOptions) http.Handler {
|
|
r := chi.NewRouter()
|
|
|
|
var setupOpts SetupOptions
|
|
if len(opts) > 0 {
|
|
setupOpts = opts[0]
|
|
}
|
|
|
|
// Setup endpoints — unauthenticated, only functional when no users exist.
|
|
setupLimiter := auth.NewRateLimiter()
|
|
if setupLimiterHook != nil {
|
|
setupLimiterHook(setupLimiter)
|
|
}
|
|
startSetupLimiterReap(setupLimiter)
|
|
r.Get("/setup/status", handleSetupStatus(database, setupOpts))
|
|
r.Post("/setup", handleSetup(database, setupLimiter, allowedOrigins, hub, setupOpts))
|
|
|
|
// SSE log stream — auth is via a single-use ticket from POST /logs/ticket.
|
|
// EventSource cannot send Authorization headers, so the client first
|
|
// obtains a short-lived ticket via the authenticated ticket endpoint,
|
|
// then passes it as ?ticket= to the SSE stream.
|
|
if logBuf != nil {
|
|
r.Get("/logs/stream", handleLogStream(database, logBuf))
|
|
}
|
|
|
|
// All remaining routes require authentication plus at least one
|
|
// moderation-capable bit (permissions.AdminPerimeter). Route groups that
|
|
// map onto a specific bit re-check it with requirePerm; the rest
|
|
// (stats, users list, me) are perimeter-level.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(adminAuthMiddleware(database))
|
|
|
|
// Log stream ticket — issues a single-use, 30s TTL ticket for SSE auth.
|
|
// ADMINISTRATOR-gated to match handleLogStream's own re-check: server
|
|
// logs are not scoped to any narrower moderation bit.
|
|
r.With(requirePerm(permissions.Administrator)).
|
|
Post("/logs/ticket", handleLogTicket(database))
|
|
|
|
r.Get("/stats", handleGetStats(database, hub))
|
|
r.Get("/me", handleGetMe())
|
|
r.Get("/users", handleListUsers(database))
|
|
// Ban/unban and role change are authorized inside ModerationService
|
|
// (BAN_MEMBERS / MANAGE_ROLES + hierarchy), so the route itself stays
|
|
// perimeter-level — a moderator with only BAN_MEMBERS must reach it.
|
|
r.Patch("/users/{id}", handlePatchUser(database, hub, permInvalidator, mod))
|
|
r.With(requirePerm(permissions.KickMembers)).
|
|
Delete("/users/{id}/sessions", handleForceLogout(mod))
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requirePerm(permissions.ManageChannels))
|
|
r.Get("/channels", handleListChannels(database))
|
|
r.Post("/channels", handleCreateChannel(database, hub))
|
|
r.Patch("/channels/{id}", handlePatchChannel(database, hub))
|
|
r.Delete("/channels/{id}", handleDeleteChannel(database, hub))
|
|
r.Get("/channels/{id}/permissions", handleGetChannelPermissions(database))
|
|
r.Put("/channels/{id}/permissions/{roleId}", handlePutChannelPermission(database, hub, permInvalidator))
|
|
r.Delete("/channels/{id}/permissions/{roleId}", handleDeleteChannelPermission(database, hub, permInvalidator))
|
|
// Per-user overrides — the last layer of the resolution order,
|
|
// gated on the same MANAGE_CHANNELS bit as the role layer.
|
|
r.Put("/channels/{id}/user-permissions/{userId}", handlePutChannelUserPermission(database, hub, permInvalidator))
|
|
r.Delete("/channels/{id}/user-permissions/{userId}", handleDeleteChannelUserPermission(database, hub, permInvalidator))
|
|
})
|
|
|
|
// Role CRUD. MANAGE_ROLES gates the group; RoleService additionally
|
|
// enforces the hierarchy (manage only roles below your own position,
|
|
// never grant a bit your role lacks) and refuses to delete the Owner
|
|
// or the default role.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requirePerm(permissions.ManageRoles))
|
|
r.Get("/roles", handleListRoles(roles))
|
|
r.Post("/roles", handleCreateRole(database, hub, roles))
|
|
// Registered before /roles/{id} so "reorder" is never parsed as an id.
|
|
r.Patch("/roles/reorder", handleReorderRoles(hub, permInvalidator, roles))
|
|
r.Patch("/roles/{id}", handlePatchRole(database, hub, permInvalidator, roles))
|
|
r.Delete("/roles/{id}", handleDeleteRole(database, hub, permInvalidator, roles))
|
|
})
|
|
|
|
r.With(requirePerm(permissions.ViewAuditLog)).
|
|
Get("/audit-log", handleGetAuditLog(database))
|
|
// API tokens — Owner-only. Minting a network-reachable, revocation-
|
|
// surviving bearer credential is gated like backups/updates.
|
|
r.Get("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
ownerOnlyMiddleware(database, handleListAPITokens(database)).ServeHTTP(w, req)
|
|
}))
|
|
r.Post("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
ownerOnlyMiddleware(database, handleCreateAPIToken(database)).ServeHTTP(w, req)
|
|
}))
|
|
r.Delete("/tokens/{id}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
ownerOnlyMiddleware(database, handleRevokeAPIToken(database)).ServeHTTP(w, req)
|
|
}))
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(requirePerm(permissions.ManageServer))
|
|
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)
|
|
}))
|
|
r.Get("/backups", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
ownerOnlyMiddleware(database, handleListBackups()).ServeHTTP(w, req)
|
|
}))
|
|
r.Delete("/backups/{name}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
ownerOnlyMiddleware(database, handleDeleteBackup(database)).ServeHTTP(w, req)
|
|
}))
|
|
r.Post("/backups/{name}/restore", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
ownerOnlyMiddleware(database, handleRestoreBackup(database, hub)).ServeHTTP(w, req)
|
|
}))
|
|
r.Get("/updates", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
ownerOnlyMiddleware(database, handleCheckUpdate(u)).ServeHTTP(w, req)
|
|
}))
|
|
r.Post("/updates/apply", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
ownerOnlyMiddleware(database, handleApplyUpdate(u, hub, version)).ServeHTTP(w, req)
|
|
}))
|
|
})
|
|
|
|
return r
|
|
}
|