mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat: implement server auto-update API endpoints with download, verify, and restart
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/updater"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
@@ -21,11 +22,11 @@ var staticFiles embed.FS
|
||||
//
|
||||
// /api/* — admin REST API (all require ADMINISTRATOR permission)
|
||||
// /* — embedded static files (SPA; index.html for unknown paths)
|
||||
func NewHandler(database *db.DB) http.Handler {
|
||||
func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Admin REST API mounted at /api
|
||||
r.Mount("/api", NewAdminAPI(database))
|
||||
r.Mount("/api", NewAdminAPI(database, version, hub, u))
|
||||
|
||||
// Static files — serve from the "static" sub-tree of the embedded FS.
|
||||
// The //go:embed static directive in this package embeds as "static/…",
|
||||
|
||||
+11
-1
@@ -12,8 +12,14 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/updater"
|
||||
)
|
||||
|
||||
// HubBroadcaster is the subset of ws.Hub needed by the admin package.
|
||||
type HubBroadcaster interface {
|
||||
BroadcastServerRestart(reason string, delaySeconds int)
|
||||
}
|
||||
|
||||
// ─── Permission constants ─────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
@@ -25,7 +31,7 @@ const (
|
||||
|
||||
// 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 {
|
||||
func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// All routes require authentication and ADMINISTRATOR permission.
|
||||
@@ -45,6 +51,10 @@ func NewAdminAPI(database *db.DB) http.Handler {
|
||||
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
|
||||
}
|
||||
|
||||
+26
-26
@@ -191,7 +191,7 @@ func doRequest(t *testing.T, handler http.Handler, method, path, token string, b
|
||||
|
||||
func TestAdminAPI_Stats_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
|
||||
@@ -214,7 +214,7 @@ func TestAdminAPI_Stats_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_Stats_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", "", nil)
|
||||
|
||||
@@ -225,7 +225,7 @@ func TestAdminAPI_Stats_Unauthenticated(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_Stats_Forbidden(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createMemberUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
|
||||
@@ -239,7 +239,7 @@ func TestAdminAPI_Stats_Forbidden(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ListUsers_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users?limit=50&offset=0", token, nil)
|
||||
@@ -260,7 +260,7 @@ func TestAdminAPI_ListUsers_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// No query params — should use defaults
|
||||
@@ -273,7 +273,7 @@ func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users", "", nil)
|
||||
|
||||
@@ -286,7 +286,7 @@ func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchUser_BanUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a target user
|
||||
@@ -314,7 +314,7 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser("rolechange", "hash", 3)
|
||||
@@ -336,7 +336,7 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchUser_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]interface{}{"banned": true}
|
||||
@@ -349,7 +349,7 @@ func TestAdminAPI_PatchUser_NotFound(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchUser_InvalidID(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPatch, "/users/abc", token, nil)
|
||||
@@ -363,7 +363,7 @@ func TestAdminAPI_PatchUser_InvalidID(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ForceLogout_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser("logoutme", "hash", 3)
|
||||
@@ -383,7 +383,7 @@ func TestAdminAPI_ForceLogout_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/users/1/sessions", "", nil)
|
||||
|
||||
@@ -396,7 +396,7 @@ func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ListChannels_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
database.AdminCreateChannel("general", "text", "", "", 0)
|
||||
@@ -420,7 +420,7 @@ func TestAdminAPI_ListChannels_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_CreateChannel_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]interface{}{
|
||||
@@ -447,7 +447,7 @@ func TestAdminAPI_CreateChannel_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_CreateChannel_MissingName(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]interface{}{
|
||||
@@ -464,7 +464,7 @@ func TestAdminAPI_CreateChannel_MissingName(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_UpdateChannel_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel("old", "text", "", "", 0)
|
||||
@@ -485,7 +485,7 @@ func TestAdminAPI_UpdateChannel_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]interface{}{"name": "x"}
|
||||
@@ -500,7 +500,7 @@ func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_DeleteChannel_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel("del-me", "text", "", "", 0)
|
||||
@@ -514,7 +514,7 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/channels/99999", token, nil)
|
||||
@@ -528,7 +528,7 @@ func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_AuditLog_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
uid, _ := database.CreateUser("actor", "hash", 1)
|
||||
@@ -551,7 +551,7 @@ func TestAdminAPI_AuditLog_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_AuditLog_Empty(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/audit-log", token, nil)
|
||||
@@ -571,7 +571,7 @@ func TestAdminAPI_AuditLog_Empty(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_GetSettings_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/settings", token, nil)
|
||||
@@ -593,7 +593,7 @@ func TestAdminAPI_GetSettings_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchSettings_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
@@ -618,7 +618,7 @@ func TestAdminAPI_PatchSettings_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader([]byte("not-json")))
|
||||
@@ -635,7 +635,7 @@ func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_Backup_RequiresOwner(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
// Admin (role 2) can authenticate but is not Owner (role 1, position 100)
|
||||
adminUID, _ := database.CreateUser("adminonly", "hash", 2)
|
||||
@@ -652,7 +652,7 @@ func TestAdminAPI_Backup_RequiresOwner(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_Backup_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/backup", "", nil)
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/updater"
|
||||
)
|
||||
|
||||
// handleCheckUpdate returns the current update status.
|
||||
func handleCheckUpdate(u *updater.Updater) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if u == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured")
|
||||
return
|
||||
}
|
||||
info, err := u.CheckForUpdate(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "UPDATE_CHECK_FAILED", "failed to check for updates: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
|
||||
// handleApplyUpdate downloads and applies a server update.
|
||||
func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, currentVersion string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if u == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for available update.
|
||||
info, err := u.CheckForUpdate(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "UPDATE_CHECK_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
if !info.UpdateAvailable {
|
||||
writeErr(w, http.StatusConflict, "NO_UPDATE", "server is already up to date")
|
||||
return
|
||||
}
|
||||
if info.DownloadURL == "" || info.ChecksumURL == "" {
|
||||
writeErr(w, http.StatusBadGateway, "MISSING_ASSETS", "release is missing required assets")
|
||||
return
|
||||
}
|
||||
|
||||
// Get current executable path.
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "cannot determine executable path")
|
||||
return
|
||||
}
|
||||
exePath, err = filepath.EvalSymlinks(exePath)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "cannot resolve executable path")
|
||||
return
|
||||
}
|
||||
|
||||
newPath := exePath + ".new"
|
||||
oldPath := exePath + ".old"
|
||||
|
||||
// Download and verify.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := u.DownloadAndVerify(ctx, info.DownloadURL, info.ChecksumURL, newPath); err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "DOWNLOAD_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Respond to the client before shutting down.
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "applying",
|
||||
"version": info.Latest,
|
||||
})
|
||||
|
||||
// Broadcast restart notification and apply in background.
|
||||
go func() {
|
||||
if hub != nil {
|
||||
hub.BroadcastServerRestart("update", 5)
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Rename: current -> .old, .new -> current
|
||||
_ = os.Remove(oldPath) // remove any stale .old
|
||||
if err := os.Rename(exePath, oldPath); err != nil {
|
||||
slog.Error("update: rename current to old failed", "error", err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(newPath, exePath); err != nil {
|
||||
slog.Error("update: rename new to current failed", "error", err)
|
||||
// Try to restore
|
||||
_ = os.Rename(oldPath, exePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Spawn new process.
|
||||
if err := spawnDetached(exePath, os.Args[1:]); err != nil {
|
||||
slog.Error("update: spawn new process failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Exit current process.
|
||||
slog.Info("update: new process spawned, exiting current process")
|
||||
os.Exit(0)
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// spawnDetached starts a new process that is not attached to the current one.
|
||||
func spawnDetached(exePath string, args []string) error {
|
||||
cmd := exec.Command(exePath, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: 0x00000008, // DETACHED_PROCESS
|
||||
}
|
||||
}
|
||||
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/admin"
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/updater"
|
||||
)
|
||||
|
||||
func TestAdminAPI_CheckUpdate_OK(t *testing.T) {
|
||||
// Mock GitHub API
|
||||
mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"tag_name": "v2.0.0",
|
||||
"body": "New release",
|
||||
"html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0",
|
||||
"assets": []map[string]interface{}{
|
||||
{"name": "chatserver.exe", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe"},
|
||||
{"name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer mockGH.Close()
|
||||
|
||||
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var info updater.UpdateInfo
|
||||
json.Unmarshal(w.Body.Bytes(), &info)
|
||||
if !info.UpdateAvailable {
|
||||
t.Error("expected update_available = true")
|
||||
}
|
||||
if info.Latest != "v2.0.0" {
|
||||
t.Errorf("latest = %q, want v2.0.0", info.Latest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) {
|
||||
mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"tag_name": "v1.0.0",
|
||||
"body": "",
|
||||
"html_url": "https://github.com/J3vb/OwnCord/releases/tag/v1.0.0",
|
||||
"assets": []map[string]interface{}{},
|
||||
})
|
||||
}))
|
||||
defer mockGH.Close()
|
||||
|
||||
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
var info updater.UpdateInfo
|
||||
json.Unmarshal(w.Body.Bytes(), &info)
|
||||
if info.UpdateAvailable {
|
||||
t.Error("expected update_available = false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPI_CheckUpdate_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", "", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
// Create admin user (not owner - role 2)
|
||||
adminUID, _ := database.CreateUser("adminonly2", "hash", 2)
|
||||
token := "admin-role-token"
|
||||
database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1")
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,15 @@ import (
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/updater"
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
// version is the server version string, overridden at build time via ldflags.
|
||||
// version is the server version string, set by NewRouter from the caller.
|
||||
var version = "dev"
|
||||
|
||||
// NewRouter builds and returns the fully configured HTTP handler.
|
||||
func NewRouter(cfg *config.Config, database *db.DB) http.Handler {
|
||||
func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middleware stack.
|
||||
@@ -27,6 +28,8 @@ func NewRouter(cfg *config.Config, database *db.DB) http.Handler {
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
version = ver
|
||||
|
||||
// Health check — unauthenticated, no versioning prefix.
|
||||
r.Get("/health", handleHealth)
|
||||
|
||||
@@ -56,7 +59,8 @@ func NewRouter(cfg *config.Config, database *db.DB) http.Handler {
|
||||
r.Get("/api/v1/ws", ws.ServeWS(hub, database))
|
||||
|
||||
// Admin panel: static files + REST API (Phase 6).
|
||||
r.Mount("/admin", admin.NewHandler(database))
|
||||
u := updater.NewUpdater(ver, cfg.GitHub.Token, "J3vb", "OwnCord")
|
||||
r.Mount("/admin", admin.NewHandler(database, ver, hub, u))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func setupRouter(t *testing.T) http.Handler {
|
||||
},
|
||||
}
|
||||
|
||||
return api.NewRouter(cfg, database)
|
||||
return api.NewRouter(cfg, database, "test")
|
||||
}
|
||||
|
||||
func TestHealthEndpointReturns200(t *testing.T) {
|
||||
|
||||
Binary file not shown.
+35
-6
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -35,6 +36,18 @@ func main() {
|
||||
|
||||
// run is the real entrypoint — separated for testability.
|
||||
func run(log *slog.Logger) error {
|
||||
// Clean up old binary from a previous update.
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
oldPath := exePath + ".old"
|
||||
if _, statErr := os.Stat(oldPath); statErr == nil {
|
||||
if rmErr := os.Remove(oldPath); rmErr != nil {
|
||||
log.Warn("failed to remove old binary", "path", oldPath, "error", rmErr)
|
||||
} else {
|
||||
log.Info("removed old binary from previous update", "path", oldPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. Load configuration ──────────────────────────────────────────────
|
||||
cfg, err := config.Load("config.yaml")
|
||||
if err != nil {
|
||||
@@ -70,7 +83,7 @@ func run(log *slog.Logger) error {
|
||||
}
|
||||
|
||||
// ── 5. Build HTTP router ───────────────────────────────────────────────
|
||||
router := api.NewRouter(cfg, database)
|
||||
router := api.NewRouter(cfg, database, version)
|
||||
|
||||
// ── 6. Start server ────────────────────────────────────────────────────
|
||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||
@@ -93,12 +106,23 @@ func run(log *slog.Logger) error {
|
||||
log.Info("server starting", "addr", addr, "tls", tlsCfg != nil, "version", version)
|
||||
|
||||
var listenErr error
|
||||
if tlsCfg != nil {
|
||||
listenErr = srv.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
listenErr = srv.ListenAndServe()
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
if tlsCfg != nil {
|
||||
listenErr = srv.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
listenErr = srv.ListenAndServe()
|
||||
}
|
||||
if listenErr != nil && !errors.Is(listenErr, http.ErrServerClosed) {
|
||||
// Check if it's an "address already in use" error (port not released yet from old process)
|
||||
if attempt < 19 && isAddrInUse(listenErr) {
|
||||
log.Warn("port in use, retrying...", "attempt", attempt+1, "error", listenErr)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
serveErr <- listenErr
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if listenErr != nil && !errors.Is(listenErr, http.ErrServerClosed) {
|
||||
serveErr <- listenErr
|
||||
}
|
||||
@@ -127,3 +151,8 @@ func run(log *slog.Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAddrInUse checks if an error is an "address already in use" error.
|
||||
func isAddrInUse(err error) bool {
|
||||
return err != nil && (strings.Contains(err.Error(), "address already in use") || strings.Contains(err.Error(), "Only one usage of each socket address"))
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,11 @@ func NewUpdater(currentVersion, githubToken, repoOwner, repoName string) *Update
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseURL overrides the GitHub API base URL (for testing).
|
||||
func (u *Updater) SetBaseURL(url string) {
|
||||
u.baseURL = url
|
||||
}
|
||||
|
||||
// ensureVPrefix returns the version string with a "v" prefix for semver
|
||||
// comparison. If it already has one, it is returned unchanged.
|
||||
func ensureVPrefix(v string) string {
|
||||
|
||||
Reference in New Issue
Block a user