From d425dc5553430666a76837f2c7c9490dda85a301 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 22:36:35 +0100 Subject: [PATCH] feat: add setup wizard for initial owner account creation When no users exist, the admin panel shows a setup wizard instead of the login form. Creates the first Owner account with a session token and generates an unlimited invite code for onboarding other users. The setup endpoint is locked out after the first user is created. Also fixes the admin panel 404 by serving index.html directly for the root path instead of delegating to http.FileServer. --- Server/admin/admin.go | 12 +++ Server/admin/api.go | 49 +++++----- Server/admin/setup_handler.go | 124 +++++++++++++++++++++++++ Server/admin/setup_handler_test.go | 144 +++++++++++++++++++++++++++++ Server/admin/static/index.html | 124 ++++++++++++++++++++++++- Server/db/admin_queries.go | 11 +++ 6 files changed, 439 insertions(+), 25 deletions(-) create mode 100644 Server/admin/setup_handler.go create mode 100644 Server/admin/setup_handler_test.go diff --git a/Server/admin/admin.go b/Server/admin/admin.go index d0ee00ac..35a83206 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -37,6 +37,18 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater. // happen in production. Panic so it surfaces immediately in tests. panic("admin: failed to create static sub-FS: " + err.Error()) } + + // Serve index.html directly for the root path. We read it once at + // startup instead of using http.FileServer, which has redirect + // behaviour that conflicts with chi's Mount prefix stripping. + indexHTML, err := fs.ReadFile(staticFS, "index.html") + if err != nil { + panic("admin: failed to read index.html: " + err.Error()) + } + r.Get("/", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(indexHTML) + }) r.Handle("/*", http.FileServer(http.FS(staticFS))) return r diff --git a/Server/admin/api.go b/Server/admin/api.go index ae17a20c..9df83132 100644 --- a/Server/admin/api.go +++ b/Server/admin/api.go @@ -30,31 +30,38 @@ const ( // ─── NewAdminAPI ────────────────────────────────────────────────────────────── // NewAdminAPI returns a chi router with all /admin/api/* routes. All routes -// are protected by adminAuthMiddleware which requires the ADMINISTRATOR bit. +// are protected by adminAuthMiddleware which requires the ADMINISTRATOR bit, +// except for the setup endpoints which are unauthenticated. func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater) http.Handler { r := chi.NewRouter() - // All routes require authentication and ADMINISTRATOR permission. - r.Use(adminAuthMiddleware(database)) + // Setup endpoints — unauthenticated, only functional when no users exist. + r.Get("/setup/status", handleSetupStatus(database)) + r.Post("/setup", handleSetup(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) - })) - r.Get("/updates", handleCheckUpdate(u)) - r.Post("/updates/apply", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - ownerOnlyMiddleware(database, handleApplyUpdate(u, hub, version)).ServeHTTP(w, req) - })) + // All remaining routes require authentication and ADMINISTRATOR permission. + r.Group(func(r chi.Router) { + 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) + })) + r.Get("/updates", handleCheckUpdate(u)) + r.Post("/updates/apply", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ownerOnlyMiddleware(database, handleApplyUpdate(u, hub, version)).ServeHTTP(w, req) + })) + }) return r } diff --git a/Server/admin/setup_handler.go b/Server/admin/setup_handler.go new file mode 100644 index 00000000..5f8f19d3 --- /dev/null +++ b/Server/admin/setup_handler.go @@ -0,0 +1,124 @@ +package admin + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/microcosm-cc/bluemonday" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// setupSanitizer strips all HTML from user input during setup. +var setupSanitizer = bluemonday.StrictPolicy() + +// ownerRoleID is the role ID assigned to the first user (Owner). +const ownerRoleID = 1 + +// setupStatusResponse is the JSON shape returned by GET /api/setup/status. +type setupStatusResponse struct { + NeedsSetup bool `json:"needs_setup"` +} + +// setupRequest is the JSON body for POST /api/setup. +type setupRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// setupResponse is the JSON shape returned on successful setup. +type setupResponse struct { + Token string `json:"token"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + InviteCode string `json:"invite_code"` +} + +// handleSetupStatus returns whether initial setup is needed (no users exist). +func handleSetupStatus(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + count, err := database.UserCount() + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to check user count") + return + } + writeJSON(w, http.StatusOK, setupStatusResponse{NeedsSetup: count == 0}) + } +} + +// handleSetup creates the first owner account. It only works when no users +// exist in the database, preventing abuse after initial setup. +func handleSetup(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Gate: only allow when no users exist. + count, err := database.UserCount() + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to check user count") + return + } + if count > 0 { + writeErr(w, http.StatusForbidden, "FORBIDDEN", "setup has already been completed") + return + } + + var req setupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") + return + } + + req.Username = strings.TrimSpace(setupSanitizer.Sanitize(req.Username)) + if req.Username == "" || req.Password == "" { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "username and password are required") + return + } + + if err := auth.ValidatePasswordStrength(req.Password); err != nil { + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) + return + } + + // Hash the password. + hash, err := auth.HashPassword(req.Password) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to hash password") + return + } + + // Create the owner account (role_id=1 is Owner). + uid, err := database.CreateUser(req.Username, hash, ownerRoleID) + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create user") + return + } + + // Issue a session token so the user is immediately logged in. + token, err := auth.GenerateToken() + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate session token") + return + } + + device := r.Header.Get("User-Agent") + ip := r.RemoteAddr + if _, err := database.CreateSession(uid, auth.HashToken(token), device, ip); err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create session") + return + } + + // Generate a bootstrap invite code so the owner can invite others. + inviteCode, err := database.CreateInvite(uid, 0, nil) // unlimited uses, no expiry + if err != nil { + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate invite code") + return + } + + writeJSON(w, http.StatusCreated, setupResponse{ + Token: token, + UserID: uid, + Username: req.Username, + InviteCode: inviteCode, + }) + } +} diff --git a/Server/admin/setup_handler_test.go b/Server/admin/setup_handler_test.go new file mode 100644 index 00000000..05212bdb --- /dev/null +++ b/Server/admin/setup_handler_test.go @@ -0,0 +1,144 @@ +package admin_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/owncord/server/admin" +) + +func TestSetupStatus_NeedsSetup(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + + rr := doRequest(t, handler, "GET", "/setup/status", "", nil) + if rr.Code != http.StatusOK { + t.Fatalf("GET /setup/status = %d, want 200", rr.Code) + } + + var resp struct { + NeedsSetup bool `json:"needs_setup"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !resp.NeedsSetup { + t.Error("needs_setup = false, want true (no users)") + } +} + +func TestSetupStatus_NoSetupNeeded(t *testing.T) { + database := openAdminTestDB(t) + createAdminUser(t, database) // Create a user first + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + + rr := doRequest(t, handler, "GET", "/setup/status", "", nil) + if rr.Code != http.StatusOK { + t.Fatalf("GET /setup/status = %d, want 200", rr.Code) + } + + var resp struct { + NeedsSetup bool `json:"needs_setup"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.NeedsSetup { + t.Error("needs_setup = true, want false (user exists)") + } +} + +func TestSetup_CreatesOwner(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + + rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ + "username": "myadmin", + "password": "SecurePass123!", + }) + + if rr.Code != http.StatusCreated { + t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String()) + } + + var resp struct { + Token string `json:"token"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + InviteCode string `json:"invite_code"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Token == "" { + t.Error("token is empty") + } + if resp.Username != "myadmin" { + t.Errorf("username = %q, want %q", resp.Username, "myadmin") + } + if resp.InviteCode == "" { + t.Error("invite_code is empty") + } + if resp.UserID == 0 { + t.Error("user_id is 0") + } + + // Verify user was created with Owner role. + user, err := database.GetUserByUsername("myadmin") + if err != nil || user == nil { + t.Fatal("user not found in database after setup") + } + if user.RoleID != 1 { + t.Errorf("role_id = %d, want 1 (Owner)", user.RoleID) + } +} + +func TestSetup_BlockedAfterFirstUser(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + + // First setup succeeds. + rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ + "username": "owner1", + "password": "SecurePass123!", + }) + if rr.Code != http.StatusCreated { + t.Fatalf("first setup = %d, want 201; body=%s", rr.Code, rr.Body.String()) + } + + // Second setup is blocked. + rr2 := doRequest(t, handler, "POST", "/setup", "", map[string]string{ + "username": "hacker", + "password": "EvilPass456!", + }) + if rr2.Code != http.StatusForbidden { + t.Errorf("second setup = %d, want 403", rr2.Code) + } +} + +func TestSetup_WeakPassword(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + + rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ + "username": "admin", + "password": "short", + }) + if rr.Code != http.StatusBadRequest { + t.Errorf("weak password = %d, want 400; body=%s", rr.Code, rr.Body.String()) + } +} + +func TestSetup_MissingFields(t *testing.T) { + database := openAdminTestDB(t) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + + rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ + "username": "", + "password": "", + }) + if rr.Code != http.StatusBadRequest { + t.Errorf("missing fields = %d, want 400", rr.Code) + } +} diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html index 315da9d4..fcdd3c14 100644 --- a/Server/admin/static/index.html +++ b/Server/admin/static/index.html @@ -203,8 +203,41 @@ + + + + + + -
+