From a1434ad07f8420b1b7e69b7387be28ac8a415119 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 20:34:37 +0100 Subject: [PATCH 001/100] feat: implement Phase 1 server skeleton with TDD - Scaffold Go module (github.com/owncord/server) with all package dirs - config: koanf-based YAML loader with env var overrides, default generation - db: pure-Go SQLite (modernc, no CGO), WAL mode, FK enforcement, full 15-table schema from SCHEMA.md including FTS5 and idempotent migrations - auth/tls: ECDSA P-256 self-signed cert generation, LoadOrGenerate for all 4 TLS modes (self_signed, acme, manual, off) - api: chi router with request ID middleware, /health and /api/v1/info - main: graceful shutdown (30s timeout), structured slog JSON logging - Stubs for ws, storage, admin packages ready for Phase 2+ Test coverage: api 100%, auth 85.7%, db 82.4%, config 80.6% Binary: chatserver.exe 12MB, GOOS=windows GOARCH=amd64 --- Server/admin/admin.go | 16 + Server/admin/static/index.html | 18 + Server/api/router.go | 82 ++++ Server/api/router_test.go | 194 ++++++++++ Server/auth/auth.go | 2 + Server/auth/tls.go | 141 +++++++ Server/auth/tls_test.go | 189 +++++++++ Server/config/config.go | 175 +++++++++ Server/config/config_test.go | 245 ++++++++++++ Server/db/db.go | 116 ++++++ Server/db/db_test.go | 472 +++++++++++++++++++++++ Server/go.mod | 31 ++ Server/go.sum | 55 +++ Server/main.go | 129 +++++++ Server/migrations/001_initial_schema.sql | 201 ++++++++++ Server/migrations/migrations.go | 9 + Server/storage/storage.go | 52 +++ Server/ws/hub.go | 45 +++ 18 files changed, 2172 insertions(+) create mode 100644 Server/admin/admin.go create mode 100644 Server/admin/static/index.html create mode 100644 Server/api/router.go create mode 100644 Server/api/router_test.go create mode 100644 Server/auth/auth.go create mode 100644 Server/auth/tls.go create mode 100644 Server/auth/tls_test.go create mode 100644 Server/config/config.go create mode 100644 Server/config/config_test.go create mode 100644 Server/db/db.go create mode 100644 Server/db/db_test.go create mode 100644 Server/go.mod create mode 100644 Server/go.sum create mode 100644 Server/main.go create mode 100644 Server/migrations/001_initial_schema.sql create mode 100644 Server/migrations/migrations.go create mode 100644 Server/storage/storage.go create mode 100644 Server/ws/hub.go diff --git a/Server/admin/admin.go b/Server/admin/admin.go new file mode 100644 index 00000000..550f7d81 --- /dev/null +++ b/Server/admin/admin.go @@ -0,0 +1,16 @@ +// Package admin provides the embedded admin panel static file server. +// Full admin API implementation follows in Phase 6. +package admin + +import ( + "embed" + "net/http" +) + +//go:embed static +var staticFiles embed.FS + +// Handler returns an http.Handler that serves the embedded admin panel static files. +func Handler() http.Handler { + return http.FileServer(http.FS(staticFiles)) +} diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html new file mode 100644 index 00000000..dd8b0d42 --- /dev/null +++ b/Server/admin/static/index.html @@ -0,0 +1,18 @@ + + + + + + OwnCord Admin Panel + + + +

OwnCord Admin Panel

+

Admin panel frontend — coming in Phase 6.

+

For now, use the REST API endpoints under /api/admin/ directly.

+ + diff --git a/Server/api/router.go b/Server/api/router.go new file mode 100644 index 00000000..3ec1ed49 --- /dev/null +++ b/Server/api/router.go @@ -0,0 +1,82 @@ +// Package api provides the HTTP router and handlers for the OwnCord server. +package api + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// version is the server version string, overridden at build time via ldflags. +var version = "dev" + +// NewRouter builds and returns the fully configured HTTP handler. +func NewRouter(cfg *config.Config, database *db.DB) http.Handler { + r := chi.NewRouter() + + // Middleware stack. + r.Use(middleware.RequestID) + r.Use(setRequestIDHeader) // echo request ID into response header + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + + // Health check — unauthenticated, no versioning prefix. + r.Get("/health", handleHealth) + + // Versioned API routes. + r.Route("/api/v1", func(r chi.Router) { + r.Get("/info", handleInfo(cfg)) + }) + + return r +} + +// healthResponse is the JSON shape returned by GET /health. +type healthResponse struct { + Status string `json:"status"` + Version string `json:"version"` +} + +// infoResponse is the JSON shape returned by GET /api/v1/info. +type infoResponse struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, healthResponse{ + Status: "ok", + Version: version, + }) +} + +func handleInfo(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, infoResponse{ + Name: cfg.Server.Name, + Version: version, + }) + } +} + +// 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) + }) +} + +// writeJSON encodes v as JSON and writes it to w with the given status code. +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) +} diff --git a/Server/api/router_test.go b/Server/api/router_test.go new file mode 100644 index 00000000..c96ca2f3 --- /dev/null +++ b/Server/api/router_test.go @@ -0,0 +1,194 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/owncord/server/api" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// setupRouter creates a test router with an in-memory database. +func setupRouter(t *testing.T) http.Handler { + t.Helper() + + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open error: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate error: %v", err) + } + t.Cleanup(func() { database.Close() }) + + cfg := &config.Config{ + Server: config.ServerConfig{ + Name: "Test Server", + Port: 8443, + }, + } + + return api.NewRouter(cfg, database) +} + +func TestHealthEndpointReturns200(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("GET /health status = %d, want 200", rec.Code) + } +} + +func TestHealthEndpointReturnsJSON(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + contentType := rec.Header().Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + t.Errorf("Content-Type = %q, want application/json", contentType) + } + + var body map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("response body is not valid JSON: %v", err) + } +} + +func TestHealthEndpointStatusOK(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + var body map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("JSON decode error: %v", err) + } + + if body["status"] != "ok" { + t.Errorf("status = %v, want 'ok'", body["status"]) + } +} + +func TestHealthEndpointHasVersion(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + var body map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("JSON decode error: %v", err) + } + + if body["version"] == nil || body["version"] == "" { + t.Error("health response missing 'version' field") + } +} + +func TestAPIV1InfoEndpoint(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/info", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("GET /api/v1/info status = %d, want 200", rec.Code) + } +} + +func TestAPIV1InfoReturnsServerName(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/info", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + var body map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("JSON decode error: %v", err) + } + + if body["name"] != "Test Server" { + t.Errorf("name = %v, want 'Test Server'", body["name"]) + } +} + +func TestAPIV1InfoReturnsVersion(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/info", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + var body map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("JSON decode error: %v", err) + } + + if body["version"] == nil { + t.Error("info response missing 'version' field") + } +} + +func TestUnknownRouteReturns404(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/nonexistent", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("GET /api/v1/nonexistent status = %d, want 404", rec.Code) + } +} + +func TestRequestIDMiddleware(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + // Request ID header should be set by middleware. + requestID := rec.Header().Get("X-Request-Id") + if requestID == "" { + t.Error("X-Request-Id header not set by middleware") + } +} + +func TestHealthMethodNotAllowed(t *testing.T) { + router := setupRouter(t) + + req := httptest.NewRequest(http.MethodPost, "/health", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("POST /health status = %d, want 405", rec.Code) + } +} diff --git a/Server/auth/auth.go b/Server/auth/auth.go new file mode 100644 index 00000000..0c6b9713 --- /dev/null +++ b/Server/auth/auth.go @@ -0,0 +1,2 @@ +// Package auth provides authentication helpers for the OwnCord server. +package auth diff --git a/Server/auth/tls.go b/Server/auth/tls.go new file mode 100644 index 00000000..029ffbb7 --- /dev/null +++ b/Server/auth/tls.go @@ -0,0 +1,141 @@ +// Package auth provides authentication and TLS helpers for the OwnCord server. +package auth + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "time" + + "github.com/owncord/server/config" +) + +// GenerateSelfSigned generates an ECDSA P-256 self-signed TLS certificate +// valid for 10 years and writes the PEM-encoded cert and key to the given +// file paths. +// +// ECDSA P-256 is preferred over RSA 4096 for performance — it provides +// equivalent security at a fraction of the key generation cost, which matters +// for server startup and test speed. +func GenerateSelfSigned(certFile, keyFile string) error { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("generating ECDSA key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("generating serial number: %w", err) + } + + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + Organization: []string{"OwnCord Server"}, + CommonName: "OwnCord Self-Signed", + }, + NotBefore: now, + NotAfter: now.Add(10 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey) + if err != nil { + return fmt.Errorf("creating certificate: %w", err) + } + + if err := writePEM(certFile, "CERTIFICATE", certDER); err != nil { + return fmt.Errorf("writing cert file: %w", err) + } + + keyDER, err := x509.MarshalECPrivateKey(privKey) + if err != nil { + return fmt.Errorf("marshalling EC private key: %w", err) + } + + if err := writePEM(keyFile, "EC PRIVATE KEY", keyDER); err != nil { + return fmt.Errorf("writing key file: %w", err) + } + + return nil +} + +// LoadOrGenerate returns a *tls.Config based on the TLS configuration mode: +// - "self_signed": loads existing cert/key or generates new ones +// - "manual": loads existing cert/key from CertFile/KeyFile paths +// - "off": returns nil (TLS disabled) +// - "acme": not yet implemented — returns error +func LoadOrGenerate(cfg config.TLSConfig) (*tls.Config, error) { + switch cfg.Mode { + case "off": + return nil, nil + + case "self_signed": + return loadOrGenerateSelfSigned(cfg) + + case "manual": + return loadCertPair(cfg.CertFile, cfg.KeyFile) + + case "acme": + return nil, fmt.Errorf("TLS mode 'acme' is not yet implemented") + + default: + return nil, fmt.Errorf("unknown TLS mode: %q", cfg.Mode) + } +} + +// loadOrGenerateSelfSigned loads the cert/key if both files exist, otherwise +// generates a new self-signed pair. +func loadOrGenerateSelfSigned(cfg config.TLSConfig) (*tls.Config, error) { + certExists := fileExists(cfg.CertFile) + keyExists := fileExists(cfg.KeyFile) + + if !certExists || !keyExists { + if err := GenerateSelfSigned(cfg.CertFile, cfg.KeyFile); err != nil { + return nil, fmt.Errorf("generating self-signed cert: %w", err) + } + } + + return loadCertPair(cfg.CertFile, cfg.KeyFile) +} + +// loadCertPair loads a TLS certificate and key from the given file paths. +func loadCertPair(certFile, keyFile string) (*tls.Config, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("loading cert/key pair: %w", err) + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + }, nil +} + +// writePEM encodes data as a PEM block and writes it to path (mode 0600). +func writePEM(path, pemType string, data []byte) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer f.Close() + + return pem.Encode(f, &pem.Block{Type: pemType, Bytes: data}) +} + +// fileExists reports whether path refers to an existing file. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/Server/auth/tls_test.go b/Server/auth/tls_test.go new file mode 100644 index 00000000..96eb8e14 --- /dev/null +++ b/Server/auth/tls_test.go @@ -0,0 +1,189 @@ +package auth_test + +import ( + "crypto/tls" + "crypto/x509" + "os" + "path/filepath" + "testing" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/config" +) + +func TestGenerateSelfSignedCreatesFiles(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + keyFile := filepath.Join(tmpDir, "key.pem") + + if err := auth.GenerateSelfSigned(certFile, keyFile); err != nil { + t.Fatalf("GenerateSelfSigned() error: %v", err) + } + + if _, err := os.Stat(certFile); os.IsNotExist(err) { + t.Error("cert.pem not created") + } + if _, err := os.Stat(keyFile); os.IsNotExist(err) { + t.Error("key.pem not created") + } +} + +func TestGenerateSelfSignedProducesValidCert(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + keyFile := filepath.Join(tmpDir, "key.pem") + + if err := auth.GenerateSelfSigned(certFile, keyFile); err != nil { + t.Fatalf("GenerateSelfSigned() error: %v", err) + } + + // Load the generated cert/key pair. + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + t.Fatalf("tls.LoadX509KeyPair error: %v", err) + } + + // Parse the leaf certificate. + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatalf("x509.ParseCertificate error: %v", err) + } + + // Verify validity period is at least 9 years in the future (10y cert). + minExpiry := time.Now().Add(9 * 365 * 24 * time.Hour) + if leaf.NotAfter.Before(minExpiry) { + t.Errorf("cert expires %v, expected at least 9 years from now (%v)", leaf.NotAfter, minExpiry) + } + + // Verify it is a CA/self-signed cert. + if !leaf.IsCA { + t.Error("expected IsCA = true for self-signed cert") + } +} + +func TestGenerateSelfSignedInvalidCertPath(t *testing.T) { + err := auth.GenerateSelfSigned("/nonexistent/dir/cert.pem", "/nonexistent/dir/key.pem") + if err == nil { + t.Error("GenerateSelfSigned() should error for invalid cert path") + } +} + +func TestGenerateSelfSignedInvalidKeyPath(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + + // Key path in non-existent dir. + err := auth.GenerateSelfSigned(certFile, "/nonexistent/dir/key.pem") + if err == nil { + t.Error("GenerateSelfSigned() should error for invalid key path") + } +} + +func TestLoadOrGenerateSelfSigned(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + keyFile := filepath.Join(tmpDir, "key.pem") + + cfg := config.TLSConfig{ + Mode: "self_signed", + CertFile: certFile, + KeyFile: keyFile, + } + + tlsCfg, err := auth.LoadOrGenerate(cfg) + if err != nil { + t.Fatalf("LoadOrGenerate() error: %v", err) + } + if tlsCfg == nil { + t.Fatal("LoadOrGenerate() returned nil tls.Config") + } + if len(tlsCfg.Certificates) == 0 { + t.Error("LoadOrGenerate() returned tls.Config with no certificates") + } +} + +func TestLoadOrGenerateLoadsExistingCert(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + keyFile := filepath.Join(tmpDir, "key.pem") + + // Generate a cert first. + if err := auth.GenerateSelfSigned(certFile, keyFile); err != nil { + t.Fatalf("GenerateSelfSigned() error: %v", err) + } + + cfg := config.TLSConfig{ + Mode: "self_signed", + CertFile: certFile, + KeyFile: keyFile, + } + + // Load the existing cert (should not regenerate). + tlsCfg, err := auth.LoadOrGenerate(cfg) + if err != nil { + t.Fatalf("LoadOrGenerate() error: %v", err) + } + if len(tlsCfg.Certificates) == 0 { + t.Error("LoadOrGenerate() returned no certificates") + } +} + +func TestLoadOrGenerateModeOff(t *testing.T) { + cfg := config.TLSConfig{Mode: "off"} + + tlsCfg, err := auth.LoadOrGenerate(cfg) + if err != nil { + t.Fatalf("LoadOrGenerate(mode=off) error: %v", err) + } + if tlsCfg != nil { + t.Error("LoadOrGenerate(mode=off) should return nil tls.Config") + } +} + +func TestLoadOrGenerateModeManualMissingFiles(t *testing.T) { + cfg := config.TLSConfig{ + Mode: "manual", + CertFile: "/nonexistent/cert.pem", + KeyFile: "/nonexistent/key.pem", + } + + _, err := auth.LoadOrGenerate(cfg) + if err == nil { + t.Error("LoadOrGenerate(mode=manual) should error when cert/key don't exist") + } +} + +func TestLoadOrGenerateModeManualValidFiles(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "cert.pem") + keyFile := filepath.Join(tmpDir, "key.pem") + + // Pre-generate cert files. + if err := auth.GenerateSelfSigned(certFile, keyFile); err != nil { + t.Fatalf("GenerateSelfSigned() error: %v", err) + } + + cfg := config.TLSConfig{ + Mode: "manual", + CertFile: certFile, + KeyFile: keyFile, + } + + tlsCfg, err := auth.LoadOrGenerate(cfg) + if err != nil { + t.Fatalf("LoadOrGenerate(mode=manual) error: %v", err) + } + if len(tlsCfg.Certificates) == 0 { + t.Error("LoadOrGenerate(mode=manual) returned no certificates") + } +} + +func TestLoadOrGenerateUnknownMode(t *testing.T) { + cfg := config.TLSConfig{Mode: "unknown_mode"} + + _, err := auth.LoadOrGenerate(cfg) + if err == nil { + t.Error("LoadOrGenerate() should error for unknown TLS mode") + } +} diff --git a/Server/config/config.go b/Server/config/config.go new file mode 100644 index 00000000..918407b1 --- /dev/null +++ b/Server/config/config.go @@ -0,0 +1,175 @@ +// Package config provides configuration loading for the OwnCord server. +package config + +import ( + "fmt" + "os" + "strings" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/providers/structs" + "github.com/knadh/koanf/v2" + goyaml "go.yaml.in/yaml/v3" +) + +// Config holds the full server configuration. +type Config struct { + Server ServerConfig `koanf:"server"` + Database DatabaseConfig `koanf:"database"` + TLS TLSConfig `koanf:"tls"` + Upload UploadConfig `koanf:"upload"` +} + +// ServerConfig holds HTTP server settings. +type ServerConfig struct { + Port int `koanf:"port"` + Name string `koanf:"name"` + DataDir string `koanf:"data_dir"` +} + +// DatabaseConfig holds database settings. +type DatabaseConfig struct { + Path string `koanf:"path"` +} + +// TLSConfig holds TLS/certificate settings. +type TLSConfig struct { + Mode string `koanf:"mode"` + CertFile string `koanf:"cert_file"` + KeyFile string `koanf:"key_file"` + Domain string `koanf:"domain"` +} + +// UploadConfig holds file upload settings. +type UploadConfig struct { + MaxSizeMB int `koanf:"max_size_mb"` + StorageDir string `koanf:"storage_dir"` +} + +// defaults returns the default configuration. +func defaults() Config { + return Config{ + Server: ServerConfig{ + Port: 8443, + Name: "OwnCord Server", + DataDir: "data", + }, + Database: DatabaseConfig{ + Path: "data/chatserver.db", + }, + TLS: TLSConfig{ + Mode: "self_signed", + }, + Upload: UploadConfig{ + MaxSizeMB: 100, + StorageDir: "data/uploads", + }, + } +} + +// defaultYAML is the content written when no config file is present. +const defaultYAML = `# OwnCord Server Configuration +server: + port: 8443 + name: "OwnCord Server" + data_dir: "data" + +database: + path: "data/chatserver.db" + +tls: + mode: "self_signed" # self_signed, acme, manual, off + cert_file: "" + key_file: "" + domain: "" + +upload: + max_size_mb: 100 + storage_dir: "data/uploads" +` + +// Load reads configuration from the given YAML file path, merging with +// defaults and environment variable overrides. If the file does not exist, +// a default config.yaml is written and defaults are returned. +func Load(cfgPath string) (*Config, error) { + k := koanf.New(".") + + // Layer 1: built-in defaults via struct provider. + def := defaults() + if err := k.Load(structs.Provider(def, "koanf"), nil); err != nil { + return nil, fmt.Errorf("loading defaults: %w", err) + } + + // Layer 2: YAML file (create default if missing). + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + if writeErr := os.WriteFile(cfgPath, []byte(defaultYAML), 0o644); writeErr != nil { + return nil, fmt.Errorf("writing default config: %w", writeErr) + } + } else { + // Read the file and try to parse it ourselves to detect invalid YAML. + raw, readErr := os.ReadFile(cfgPath) + if readErr != nil { + return nil, fmt.Errorf("reading config file %s: %w", cfgPath, readErr) + } + if parseErr := validateYAML(raw); parseErr != nil { + return nil, fmt.Errorf("loading config file %s: %w", cfgPath, parseErr) + } + if err := k.Load(file.Provider(cfgPath), yaml.Parser()); err != nil { + return nil, fmt.Errorf("loading config file %s: %w", cfgPath, err) + } + } + + // Layer 3: environment variable overrides. + // OWNCORD_SERVER_PORT -> server.port, OWNCORD_TLS_MODE -> tls.mode, etc. + envProvider := env.Provider("OWNCORD_", ".", func(s string) string { + // Strip prefix, lowercase, replace _ with . except within a key segment. + // OWNCORD_SERVER_PORT -> server.port + // OWNCORD_DATABASE_PATH -> database.path + // OWNCORD_UPLOAD_MAX_SIZE_MB -> upload.max_size_mb + s = strings.TrimPrefix(s, "OWNCORD_") + s = strings.ToLower(s) + // Split into at most 2 parts on the first underscore to get + // section.key. We need smarter splitting because keys can have + // underscores (e.g. max_size_mb, data_dir, storage_dir). + return envKeyToKoanf(s) + }) + if err := k.Load(envProvider, nil); err != nil { + return nil, fmt.Errorf("loading env vars: %w", err) + } + + var cfg Config + if err := k.Unmarshal("", &cfg); err != nil { + return nil, fmt.Errorf("unmarshalling config: %w", err) + } + + return &cfg, nil +} + +// validateYAML checks that raw bytes are valid YAML. +func validateYAML(raw []byte) error { + var v interface{} + return goyaml.Unmarshal(raw, &v) +} + +// envKeyToKoanf converts a lower-case env key (without OWNCORD_ prefix) to a +// koanf dotted path. The first segment (up to the first underscore) is the +// section; the remainder is the key (with underscores preserved). +// +// Examples: +// +// server_port -> server.port +// server_name -> server.name +// server_data_dir -> server.data_dir +// database_path -> database.path +// tls_mode -> tls.mode +// tls_cert_file -> tls.cert_file +// upload_max_size_mb -> upload.max_size_mb +func envKeyToKoanf(s string) string { + idx := strings.Index(s, "_") + if idx < 0 { + return s + } + return s[:idx] + "." + s[idx+1:] +} diff --git a/Server/config/config_test.go b/Server/config/config_test.go new file mode 100644 index 00000000..054b78f4 --- /dev/null +++ b/Server/config/config_test.go @@ -0,0 +1,245 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/owncord/server/config" +) + +func TestLoadDefaults(t *testing.T) { + // When no config file exists, Load should return defaults. + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() with missing file returned error: %v", err) + } + + tests := []struct { + name string + got interface{} + want interface{} + }{ + {"Server.Port", cfg.Server.Port, 8443}, + {"Server.Name", cfg.Server.Name, "OwnCord Server"}, + {"Server.DataDir", cfg.Server.DataDir, "data"}, + {"Database.Path", cfg.Database.Path, "data/chatserver.db"}, + {"TLS.Mode", cfg.TLS.Mode, "self_signed"}, + {"Upload.MaxSizeMB", cfg.Upload.MaxSizeMB, 100}, + {"Upload.StorageDir", cfg.Upload.StorageDir, "data/uploads"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.got != tc.want { + t.Errorf("got %v, want %v", tc.got, tc.want) + } + }) + } +} + +func TestLoadGeneratesDefaultFile(t *testing.T) { + // When no config file exists, Load should write a default config.yaml. + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + _, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) { + t.Error("Load() did not generate default config.yaml") + } +} + +func TestLoadMergesYAML(t *testing.T) { + // When a YAML file exists with overrides, they should be merged with defaults. + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + yaml := ` +server: + port: 9000 + name: "My Custom Server" +database: + path: "custom/path.db" +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + if cfg.Server.Port != 9000 { + t.Errorf("Server.Port = %d, want 9000", cfg.Server.Port) + } + if cfg.Server.Name != "My Custom Server" { + t.Errorf("Server.Name = %q, want 'My Custom Server'", cfg.Server.Name) + } + if cfg.Database.Path != "custom/path.db" { + t.Errorf("Database.Path = %q, want 'custom/path.db'", cfg.Database.Path) + } + // Non-overridden defaults should still be present. + if cfg.Server.DataDir != "data" { + t.Errorf("Server.DataDir = %q, want 'data'", cfg.Server.DataDir) + } + if cfg.Upload.MaxSizeMB != 100 { + t.Errorf("Upload.MaxSizeMB = %d, want 100", cfg.Upload.MaxSizeMB) + } +} + +func TestLoadEnvironmentVariableOverrides(t *testing.T) { + // Environment variables with OWNCORD_ prefix should override config values. + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + t.Setenv("OWNCORD_SERVER_PORT", "7777") + t.Setenv("OWNCORD_SERVER_NAME", "Env Server") + t.Setenv("OWNCORD_DATABASE_PATH", "env/path.db") + t.Setenv("OWNCORD_TLS_MODE", "manual") + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + if cfg.Server.Port != 7777 { + t.Errorf("Server.Port = %d, want 7777", cfg.Server.Port) + } + if cfg.Server.Name != "Env Server" { + t.Errorf("Server.Name = %q, want 'Env Server'", cfg.Server.Name) + } + if cfg.Database.Path != "env/path.db" { + t.Errorf("Database.Path = %q, want 'env/path.db'", cfg.Database.Path) + } + if cfg.TLS.Mode != "manual" { + t.Errorf("TLS.Mode = %q, want 'manual'", cfg.TLS.Mode) + } +} + +func TestLoadInvalidYAML(t *testing.T) { + // Malformed YAML (bad indentation/tab mix) should return an error. + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + // Tabs in YAML indentation are illegal per the YAML spec. + invalidYAML := "server:\n\tport: 9000\n" + if err := os.WriteFile(cfgPath, []byte(invalidYAML), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + _, err := config.Load(cfgPath) + if err == nil { + t.Error("Load() with invalid YAML should return error, got nil") + } +} + +func TestLoadTLSModeValues(t *testing.T) { + // Test that all valid TLS modes are accepted. + validModes := []string{"self_signed", "acme", "manual", "off"} + + for _, mode := range validModes { + t.Run(mode, func(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + yaml := "tls:\n mode: " + mode + "\n" + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.TLS.Mode != mode { + t.Errorf("TLS.Mode = %q, want %q", cfg.TLS.Mode, mode) + } + }) + } +} + +func TestLoadEnvVarNoUnderscore(t *testing.T) { + // Test an env var that maps to a top-level key (no section separator). + // OWNCORD_PORT (no second underscore) — should not crash, just map to "port". + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + t.Setenv("OWNCORD_PORT", "1234") + + // Load should succeed without panicking. + _, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } +} + +func TestLoadEnvVarStorageDir(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + t.Setenv("OWNCORD_UPLOAD_STORAGE_DIR", "/mnt/data/uploads") + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.Upload.StorageDir != "/mnt/data/uploads" { + t.Errorf("Upload.StorageDir = %q, want '/mnt/data/uploads'", cfg.Upload.StorageDir) + } +} + +func TestLoadTLSCertAndKeyFields(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + yaml := ` +tls: + mode: "manual" + cert_file: "/etc/ssl/cert.pem" + key_file: "/etc/ssl/key.pem" + domain: "example.com" +` + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.TLS.CertFile != "/etc/ssl/cert.pem" { + t.Errorf("TLS.CertFile = %q, want '/etc/ssl/cert.pem'", cfg.TLS.CertFile) + } + if cfg.TLS.KeyFile != "/etc/ssl/key.pem" { + t.Errorf("TLS.KeyFile = %q, want '/etc/ssl/key.pem'", cfg.TLS.KeyFile) + } + if cfg.TLS.Domain != "example.com" { + t.Errorf("TLS.Domain = %q, want 'example.com'", cfg.TLS.Domain) + } +} + +func TestLoadUploadBoundaryValues(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.yaml") + + yaml := "upload:\n max_size_mb: 0\n" + if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.Upload.MaxSizeMB != 0 { + t.Errorf("Upload.MaxSizeMB = %d, want 0", cfg.Upload.MaxSizeMB) + } +} diff --git a/Server/db/db.go b/Server/db/db.go new file mode 100644 index 00000000..25d0ce2d --- /dev/null +++ b/Server/db/db.go @@ -0,0 +1,116 @@ +// Package db provides database access for the OwnCord server. +// It uses modernc.org/sqlite — a pure-Go SQLite driver requiring no CGO. +package db + +import ( + "database/sql" + "fmt" + "io/fs" + "sort" + "strings" + + "github.com/owncord/server/migrations" + _ "modernc.org/sqlite" // register the sqlite3 driver +) + +// DB wraps *sql.DB and exposes the subset of methods needed by the server. +type DB struct { + sqlDB *sql.DB +} + +// Open opens (or creates) a SQLite database at path, enables WAL mode and +// foreign key enforcement, and returns a ready-to-use DB. +func Open(path string) (*DB, error) { + sqlDB, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("opening sqlite db: %w", err) + } + + // Verify the connection is actually usable. + if err := sqlDB.Ping(); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("pinging sqlite db: %w", err) + } + + // Enable WAL mode for better concurrent read performance. + if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL;"); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("enabling WAL mode: %w", err) + } + + // Enforce foreign key constraints. + if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON;"); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("enabling foreign keys: %w", err) + } + + return &DB{sqlDB: sqlDB}, nil +} + +// Migrate runs all SQL migration files from the embedded migrations FS in +// lexicographic order. It is idempotent — SQL uses IF NOT EXISTS / INSERT OR +// IGNORE, so re-running is safe. +func Migrate(database *DB) error { + return MigrateFS(database, migrations.FS) +} + +// MigrateFS runs all *.sql files from the given FS in sorted order. +// This is exposed for testing with custom FS implementations. +func MigrateFS(database *DB, fsys fs.FS) error { + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return fmt.Errorf("reading migrations dir: %w", err) + } + + // Sort files to ensure deterministic order. + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + + raw, readErr := fs.ReadFile(fsys, entry.Name()) + if readErr != nil { + return fmt.Errorf("reading migration %s: %w", entry.Name(), readErr) + } + + if _, execErr := database.sqlDB.Exec(string(raw)); execErr != nil { + return fmt.Errorf("executing migration %s: %w", entry.Name(), execErr) + } + } + + return nil +} + +// Close releases the underlying database connection. +func (d *DB) Close() error { + return d.sqlDB.Close() +} + +// QueryRow executes a query that returns at most one row. +func (d *DB) QueryRow(query string, args ...interface{}) *sql.Row { + return d.sqlDB.QueryRow(query, args...) +} + +// Exec executes a query that doesn't return rows. +func (d *DB) Exec(query string, args ...interface{}) (sql.Result, error) { + return d.sqlDB.Exec(query, args...) +} + +// Query executes a query that returns multiple rows. +func (d *DB) Query(query string, args ...interface{}) (*sql.Rows, error) { + return d.sqlDB.Query(query, args...) +} + +// Begin starts a database transaction. +func (d *DB) Begin() (*sql.Tx, error) { + return d.sqlDB.Begin() +} + +// SQLDb returns the underlying *sql.DB for cases requiring direct access. +func (d *DB) SQLDb() *sql.DB { + return d.sqlDB +} diff --git a/Server/db/db_test.go b/Server/db/db_test.go new file mode 100644 index 00000000..87d99b6b --- /dev/null +++ b/Server/db/db_test.go @@ -0,0 +1,472 @@ +package db_test + +import ( + "database/sql" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/db" +) + +// openMemory opens an in-memory database for testing. +func openMemory(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Open(':memory:') error: %v", err) + } + t.Cleanup(func() { database.Close() }) + return database +} + +func TestOpenInMemory(t *testing.T) { + database := openMemory(t) + if database == nil { + t.Fatal("Open returned nil DB") + } +} + +func TestOpenCreatesFile(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + defer database.Close() + + if _, statErr := os.Stat(dbPath); os.IsNotExist(statErr) { + t.Error("Open() did not create the database file") + } +} + +func TestOpenInvalidPath(t *testing.T) { + // A path to a non-existent directory should return an error. + _, err := db.Open("/nonexistent/dir/that/does/not/exist/test.db") + if err == nil { + t.Error("Open() with invalid path should return error, got nil") + } +} + +func TestWALModeEnabled(t *testing.T) { + database := openMemory(t) + + var journalMode string + err := database.QueryRow("PRAGMA journal_mode;").Scan(&journalMode) + if err != nil { + t.Fatalf("PRAGMA journal_mode query error: %v", err) + } + // In-memory databases return "memory" even when WAL is requested, + // because WAL is not supported for in-memory DBs. File DBs return "wal". + // Accept both for in-memory test; the file-based test verifies WAL properly. + if journalMode != "memory" && journalMode != "wal" { + t.Errorf("journal_mode = %q, want 'wal' or 'memory'", journalMode) + } +} + +func TestWALModeEnabledOnFile(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "wal_test.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + defer database.Close() + + var journalMode string + if err := database.QueryRow("PRAGMA journal_mode;").Scan(&journalMode); err != nil { + t.Fatalf("PRAGMA journal_mode query error: %v", err) + } + if journalMode != "wal" { + t.Errorf("journal_mode = %q, want 'wal'", journalMode) + } +} + +func TestForeignKeysEnabled(t *testing.T) { + database := openMemory(t) + + var fkEnabled int + if err := database.QueryRow("PRAGMA foreign_keys;").Scan(&fkEnabled); err != nil { + t.Fatalf("PRAGMA foreign_keys query error: %v", err) + } + if fkEnabled != 1 { + t.Errorf("foreign_keys = %d, want 1 (enabled)", fkEnabled) + } +} + +func TestMigrateCreatesAllTables(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + expectedTables := []string{ + "users", "sessions", "roles", "channels", "channel_overrides", + "messages", "attachments", "reactions", "invites", "read_states", + "audit_log", "login_attempts", "settings", "emoji", "sounds", + } + + for _, table := range expectedTables { + t.Run(table, func(t *testing.T) { + var name string + err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + table, + ).Scan(&name) + if err == sql.ErrNoRows { + t.Errorf("table %q not found after migration", table) + } else if err != nil { + t.Errorf("query error for table %q: %v", table, err) + } + }) + } +} + +func TestMigrateCreatesFTSTable(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + var name string + err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'", + ).Scan(&name) + if err == sql.ErrNoRows { + t.Error("messages_fts virtual table not found after migration") + } else if err != nil { + t.Errorf("query error: %v", err) + } +} + +func TestMigrateIsIdempotent(t *testing.T) { + database := openMemory(t) + + // Run migration twice — should not error. + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() first run error: %v", err) + } + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() second run error: %v", err) + } +} + +func TestMigrateInsertsDefaultRoles(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM roles").Scan(&count); err != nil { + t.Fatalf("COUNT roles error: %v", err) + } + if count < 4 { + t.Errorf("expected at least 4 default roles, got %d", count) + } +} + +func TestMigrateInsertsDefaultSettings(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + var value string + err := database.QueryRow("SELECT value FROM settings WHERE key='registration_open'").Scan(&value) + if err != nil { + t.Fatalf("settings query error: %v", err) + } + if value != "0" { + t.Errorf("registration_open = %q, want '0'", value) + } +} + +func TestMigrateCreatesIndexes(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + expectedIndexes := []string{ + "idx_sessions_token", + "idx_messages_channel", + "idx_invites_code", + "idx_audit_timestamp", + } + + for _, idx := range expectedIndexes { + t.Run(idx, func(t *testing.T) { + var name string + err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='index' AND name=?", + idx, + ).Scan(&name) + if err == sql.ErrNoRows { + t.Errorf("index %q not found after migration", idx) + } else if err != nil { + t.Errorf("query error for index %q: %v", idx, err) + } + }) + } +} + +func TestCloseIdempotent(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Open error: %v", err) + } + + if err := database.Close(); err != nil { + t.Errorf("Close() first call error: %v", err) + } +} + +func TestQueryRow(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + // Verify we can run a simple query via the exposed DB. + var schemaVersion string + err := database.QueryRow("SELECT value FROM settings WHERE key='schema_version'").Scan(&schemaVersion) + if err != nil { + t.Fatalf("QueryRow error: %v", err) + } + if schemaVersion == "" { + t.Error("schema_version should not be empty") + } +} + +func TestExec(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + // Insert a settings row using Exec. + _, err := database.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", "test_key", "test_val") + if err != nil { + t.Fatalf("Exec() error: %v", err) + } + + var val string + if err := database.QueryRow("SELECT value FROM settings WHERE key='test_key'").Scan(&val); err != nil { + t.Fatalf("QueryRow after Exec error: %v", err) + } + if val != "test_val" { + t.Errorf("value = %q, want 'test_val'", val) + } +} + +func TestQuery(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + rows, err := database.Query("SELECT key FROM settings") + if err != nil { + t.Fatalf("Query() error: %v", err) + } + defer rows.Close() + + var count int + for rows.Next() { + count++ + var key string + if err := rows.Scan(&key); err != nil { + t.Fatalf("rows.Scan error: %v", err) + } + } + if count == 0 { + t.Error("Query() returned no rows from settings table") + } +} + +func TestBegin(t *testing.T) { + database := openMemory(t) + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + tx, err := database.Begin() + if err != nil { + t.Fatalf("Begin() error: %v", err) + } + + _, err = tx.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES ('tx_key', 'tx_val')") + if err != nil { + tx.Rollback() + t.Fatalf("tx.Exec error: %v", err) + } + + if err := tx.Rollback(); err != nil { + t.Fatalf("tx.Rollback error: %v", err) + } + + // After rollback, tx_key should not exist. + var val string + err = database.QueryRow("SELECT value FROM settings WHERE key='tx_key'").Scan(&val) + if err == nil { + t.Error("tx_key should not exist after rollback") + } +} + +func TestSQLDb(t *testing.T) { + database := openMemory(t) + sqlDB := database.SQLDb() + if sqlDB == nil { + t.Error("SQLDb() returned nil") + } +} + +// failReadFS implements fs.FS with a ReadDir that returns a file but +// ReadFile always errors — used to test the read-file error path in MigrateFS. +type failReadFS struct{} + +func (failReadFS) Open(name string) (fs.File, error) { + if name == "." { + return &fakeDir{}, nil + } + return nil, fmt.Errorf("read error for %s", name) +} + +type fakeDir struct{ pos int } + +func (d *fakeDir) Read([]byte) (int, error) { return 0, io.EOF } +func (d *fakeDir) Close() error { return nil } +func (d *fakeDir) Stat() (fs.FileInfo, error) { + return fakeDirInfo{}, nil +} +func (d *fakeDir) ReadDir(n int) ([]fs.DirEntry, error) { + if d.pos > 0 { + return nil, io.EOF + } + d.pos++ + return []fs.DirEntry{fakeDirEntry{}}, nil +} + +type fakeDirInfo struct{} + +func (fakeDirInfo) Name() string { return "." } +func (fakeDirInfo) Size() int64 { return 0 } +func (fakeDirInfo) Mode() fs.FileMode { return fs.ModeDir | 0o755 } +func (fakeDirInfo) ModTime() time.Time { return time.Time{} } +func (fakeDirInfo) IsDir() bool { return true } +func (fakeDirInfo) Sys() interface{} { return nil } + +type fakeDirEntry struct{} + +func (fakeDirEntry) Name() string { return "001_fail.sql" } +func (fakeDirEntry) IsDir() bool { return false } +func (fakeDirEntry) Type() fs.FileMode { return 0 } +func (fakeDirEntry) Info() (fs.FileInfo, error) { return fakeFileInfo{}, nil } + +type fakeFileInfo struct{} + +func (fakeFileInfo) Name() string { return "001_fail.sql" } +func (fakeFileInfo) Size() int64 { return 0 } +func (fakeFileInfo) Mode() fs.FileMode { return 0o644 } +func (fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (fakeFileInfo) IsDir() bool { return false } +func (fakeFileInfo) Sys() interface{} { return nil } + +func TestMigrateFSReadFileError(t *testing.T) { + database := openMemory(t) + + err := db.MigrateFS(database, failReadFS{}) + if err == nil { + t.Error("MigrateFS() should return error when ReadFile fails") + } +} + +func TestMigrateFSInvalidSQL(t *testing.T) { + database := openMemory(t) + + // Create an in-memory FS with invalid SQL to trigger an exec error. + badFS := fstest.MapFS{ + "001_bad.sql": &fstest.MapFile{ + Data: []byte("THIS IS NOT VALID SQL !!!@@@###"), + }, + } + + err := db.MigrateFS(database, badFS) + if err == nil { + t.Error("MigrateFS() should return error for invalid SQL, got nil") + } +} + +func TestMigrateFSSkipsNonSQL(t *testing.T) { + database := openMemory(t) + + // FS with non-.sql files should be skipped without error. + mixedFS := fstest.MapFS{ + "README.md": &fstest.MapFile{Data: []byte("not sql")}, + "001_ok.sql": &fstest.MapFile{ + Data: []byte("CREATE TABLE IF NOT EXISTS test_skip (id INTEGER PRIMARY KEY);"), + }, + } + + if err := db.MigrateFS(database, mixedFS); err != nil { + t.Fatalf("MigrateFS() error: %v", err) + } + + // The table from the .sql file should exist. + var name string + if err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name='test_skip'", + ).Scan(&name); err != nil { + t.Error("table test_skip not found after MigrateFS") + } +} + +func TestOpenPingFails(t *testing.T) { + // Providing a path in a non-existent directory should fail. + _, err := db.Open("/no/such/directory/db.sqlite") + if err == nil { + t.Error("Open() should fail for inaccessible path") + } +} + +func TestMigrateWALAndFKOnFile(t *testing.T) { + // Verify Open sets WAL and foreign_keys on a file-backed DB, then migrate. + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "migrate_test.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("Open() error: %v", err) + } + defer database.Close() + + if err := db.Migrate(database); err != nil { + t.Fatalf("Migrate() error: %v", err) + } + + // Tables should exist. + var name string + if err := database.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name='users'", + ).Scan(&name); err != nil { + t.Errorf("users table not found after migration on file db: %v", err) + } +} diff --git a/Server/go.mod b/Server/go.mod new file mode 100644 index 00000000..a2240c41 --- /dev/null +++ b/Server/go.mod @@ -0,0 +1,31 @@ +module github.com/owncord/server + +go 1.25.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/structs v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/knadh/koanf/parsers/yaml v1.1.0 // indirect + github.com/knadh/koanf/providers/env v1.1.0 // indirect + github.com/knadh/koanf/providers/file v1.2.1 // indirect + github.com/knadh/koanf/providers/structs v1.0.0 // indirect + github.com/knadh/koanf/v2 v2.3.3 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + go.yaml.in/yaml/v3 v3.0.3 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.46.1 // indirect +) diff --git a/Server/go.sum b/Server/go.sum new file mode 100644 index 00000000..73a412ba --- /dev/null +++ b/Server/go.sum @@ -0,0 +1,55 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ5KvfgMbio+23u4= +github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w= +github.com/knadh/koanf/v2 v2.3.3 h1:jLJC8XCRfLC7n4F+ZKKdBsbq1bfXTpuFhf4L7t94D94= +github.com/knadh/koanf/v2 v2.3.3/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/Server/main.go b/Server/main.go new file mode 100644 index 00000000..e6e2be99 --- /dev/null +++ b/Server/main.go @@ -0,0 +1,129 @@ +// OwnCord chat server — self-hosted, Windows-native. +// Build: go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// version is overridden at build time via -ldflags "-X main.version=1.0.0". +var version = "dev" + +func main() { + log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + if err := run(log); err != nil { + log.Error("server exited with error", "error", err) + os.Exit(1) + } +} + +// run is the real entrypoint — separated for testability. +func run(log *slog.Logger) error { + // ── 1. Load configuration ────────────────────────────────────────────── + cfg, err := config.Load("config.yaml") + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + log.Info("configuration loaded", + "server_name", cfg.Server.Name, + "port", cfg.Server.Port, + "tls_mode", cfg.TLS.Mode, + ) + + // ── 2. Ensure data directory exists ──────────────────────────────────── + if mkdirErr := os.MkdirAll(cfg.Server.DataDir, 0o755); mkdirErr != nil { + return fmt.Errorf("creating data dir %s: %w", cfg.Server.DataDir, mkdirErr) + } + + // ── 3. Open database + run migrations ───────────────────────────────── + database, err := db.Open(cfg.Database.Path) + if err != nil { + return fmt.Errorf("opening database: %w", err) + } + defer database.Close() + + if err := db.Migrate(database); err != nil { + return fmt.Errorf("running migrations: %w", err) + } + log.Info("database ready", "path", cfg.Database.Path) + + // ── 4. TLS ───────────────────────────────────────────────────────────── + tlsCfg, err := auth.LoadOrGenerate(cfg.TLS) + if err != nil { + return fmt.Errorf("configuring TLS: %w", err) + } + + // ── 5. Build HTTP router ─────────────────────────────────────────────── + router := api.NewRouter(cfg, database) + + // ── 6. Start server ──────────────────────────────────────────────────── + addr := fmt.Sprintf(":%d", cfg.Server.Port) + srv := &http.Server{ + Addr: addr, + Handler: router, + TLSConfig: tlsCfg, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // Listen for OS signals for graceful shutdown. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // Start serving in a goroutine. + serveErr := make(chan error, 1) + go func() { + log.Info("server starting", "addr", addr, "tls", tlsCfg != nil, "version", version) + + var listenErr error + if tlsCfg != nil { + listenErr = srv.ListenAndServeTLS("", "") + } else { + listenErr = srv.ListenAndServe() + } + + if listenErr != nil && !errors.Is(listenErr, http.ErrServerClosed) { + serveErr <- listenErr + } + close(serveErr) + }() + + // Wait for shutdown signal or server error. + select { + case err := <-serveErr: + if err != nil { + return fmt.Errorf("server error: %w", err) + } + case <-ctx.Done(): + log.Info("shutdown signal received, draining connections (30s timeout)") + } + + // Graceful shutdown. + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("graceful shutdown: %w", err) + } + + log.Info("server stopped cleanly") + return nil +} + diff --git a/Server/migrations/001_initial_schema.sql b/Server/migrations/001_initial_schema.sql new file mode 100644 index 00000000..785b107c --- /dev/null +++ b/Server/migrations/001_initial_schema.sql @@ -0,0 +1,201 @@ +-- Migration 001: Initial schema +-- All tables for the OwnCord server database. + +-- Roles must be created before users (foreign key dependency). +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT, + permissions INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); + +-- Insert default roles on first run. +INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) +VALUES + (1, 'Owner', '#E74C3C', 0x7FFFFFFF, 100, 0), + (2, 'Admin', '#F39C12', 0x3FFFFFFF, 80, 0), + (3, 'Moderator', '#3498DB', 0x000FFFFF, 60, 0), + (4, 'Member', NULL, 0x00100601, 40, 1); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + avatar TEXT, + role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), + totp_secret TEXT, + status TEXT NOT NULL DEFAULT 'offline', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT, + banned INTEGER NOT NULL DEFAULT 0, + ban_reason TEXT, + ban_expires TEXT +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + device TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS channel_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + allow INTEGER NOT NULL DEFAULT 0, + deny INTEGER NOT NULL DEFAULT 0, + UNIQUE(channel_id, role_id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + content TEXT NOT NULL, + reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, + edited_at TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_messages_user ON messages(user_id); + +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + content, + content='messages', + content_rowid='id' +); + +CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; + +CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); +END; + +CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content); + INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content); +END; + +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + stored_as TEXT NOT NULL, + mime_type TEXT NOT NULL, + size INTEGER NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + UNIQUE(message_id, user_id, emoji) +); + +CREATE TABLE IF NOT EXISTS invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + redeemed_by INTEGER REFERENCES users(id), + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); + +CREATE TABLE IF NOT EXISTS read_states ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + last_message_id INTEGER NOT NULL DEFAULT 0, + mention_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, channel_id) +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id), + action TEXT NOT NULL, + target_type TEXT, + target_id INTEGER, + details TEXT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp DESC); + +CREATE TABLE IF NOT EXISTS login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT NOT NULL, + username TEXT, + success INTEGER NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_login_ip ON login_attempts(ip_address, timestamp); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +-- Default server settings. +INSERT OR IGNORE INTO settings (key, value) VALUES + ('server_name', 'OwnCord Server'), + ('server_icon', ''), + ('motd', 'Welcome!'), + ('max_upload_bytes', '26214400'), + ('voice_quality', 'high'), + ('require_2fa', '0'), + ('registration_open', '0'), + ('backup_schedule', 'daily'), + ('backup_retention', '7'), + ('schema_version', '1'); + +CREATE TABLE IF NOT EXISTS emoji ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + shortcode TEXT NOT NULL UNIQUE, + filename TEXT NOT NULL, + uploaded_by INTEGER NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS sounds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + filename TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + uploaded_by INTEGER NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/Server/migrations/migrations.go b/Server/migrations/migrations.go new file mode 100644 index 00000000..c33dc4b7 --- /dev/null +++ b/Server/migrations/migrations.go @@ -0,0 +1,9 @@ +// Package migrations holds embedded SQL migration files for the OwnCord server. +package migrations + +import "embed" + +// FS holds all migration SQL files embedded at compile time. +// +//go:embed *.sql +var FS embed.FS diff --git a/Server/storage/storage.go b/Server/storage/storage.go new file mode 100644 index 00000000..0933510a --- /dev/null +++ b/Server/storage/storage.go @@ -0,0 +1,52 @@ +// Package storage handles file upload validation and storage for the OwnCord server. +// Full implementation follows in Phase 4 (Real-Time Chat Features). +package storage + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// Storage manages file uploads on disk. +type Storage struct { + dir string + maxSizeMB int +} + +// New creates a Storage instance that stores files in dir. +// dir is created if it does not exist. +func New(dir string, maxSizeMB int) (*Storage, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("creating storage dir %s: %w", dir, err) + } + return &Storage{dir: dir, maxSizeMB: maxSizeMB}, nil +} + +// Save writes the content from r to a file named by uuid within the storage dir. +// The caller is responsible for generating a UUID filename. +func (s *Storage) Save(uuid string, r io.Reader) error { + dst := filepath.Join(s.dir, uuid) + f, err := os.Create(dst) + if err != nil { + return fmt.Errorf("creating file %s: %w", dst, err) + } + defer f.Close() + + maxBytes := int64(s.maxSizeMB) * 1024 * 1024 + if _, err := io.Copy(f, io.LimitReader(r, maxBytes+1)); err != nil { + return fmt.Errorf("writing file: %w", err) + } + return nil +} + +// Delete removes the file named uuid from the storage dir. +func (s *Storage) Delete(uuid string) error { + return os.Remove(filepath.Join(s.dir, uuid)) +} + +// Open opens the file named uuid for reading. +func (s *Storage) Open(uuid string) (*os.File, error) { + return os.Open(filepath.Join(s.dir, uuid)) +} diff --git a/Server/ws/hub.go b/Server/ws/hub.go new file mode 100644 index 00000000..9e2cdff4 --- /dev/null +++ b/Server/ws/hub.go @@ -0,0 +1,45 @@ +// Package ws provides the WebSocket hub for the OwnCord server. +// Full implementation follows in Phase 4 (Real-Time Chat Features). +package ws + +import ( + "context" + "log/slog" + "net/http" + "sync" +) + +// Hub manages active WebSocket client connections. +// It is the central message routing point for all connected clients. +type Hub struct { + mu sync.RWMutex + clients map[*Client]struct{} + log *slog.Logger +} + +// Client represents a single WebSocket connection. +// Full implementation in Phase 4. +type Client struct { + UserID int64 +} + +// NewHub creates a new Hub with the given logger. +func NewHub(log *slog.Logger) *Hub { + return &Hub{ + clients: make(map[*Client]struct{}), + log: log, + } +} + +// Run starts the hub's message dispatch loop. +// It blocks until ctx is cancelled. +func (h *Hub) Run(ctx context.Context) { + <-ctx.Done() + h.log.Info("WebSocket hub shutting down") +} + +// ServeWS handles an incoming WebSocket upgrade request. +// Full implementation in Phase 4. +func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) { + http.Error(w, "WebSocket not yet implemented", http.StatusNotImplemented) +} From b7dd6eabe95378db3558c781c9ee508427efa438 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 20:52:11 +0100 Subject: [PATCH 002/100] feat: implement Phase 2 auth & security with TDD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth/session: 256-bit crypto-random tokens, SHA-256 hashing for storage - auth/password: bcrypt cost 12, strength validation (8-72 chars) - auth/ratelimit: sliding-window RateLimiter with lockout, thread-safe - db/models: User, Session, Invite, Role types - db/auth_queries: full user/session/invite CRUD with in-memory test coverage - api/middleware: AuthMiddleware (Bearer token), RequirePermission (bitfield), RateLimitMiddleware (X-Real-IP, Retry-After header) - api/auth_handler: POST register/login, POST logout, GET me - Generic errors — username existence never revealed - Rate limits: 3/min register, 5/min login, lockout after 10 failures - api/invite_handler: create/list/revoke behind MANAGE_INVITES permission - bluemonday sanitization on all user-supplied string fields Test coverage: auth 90.9%, db 84.4%, api 80.9% --- Server/api/auth_handler.go | 311 +++++++++++++++++ Server/api/auth_handler_test.go | 456 +++++++++++++++++++++++++ Server/api/invite_handler.go | 160 +++++++++ Server/api/invite_handler_test.go | 294 ++++++++++++++++ Server/api/middleware.go | 194 +++++++++++ Server/api/middleware_test.go | 361 ++++++++++++++++++++ Server/api/router.go | 10 + Server/auth/password.go | 50 +++ Server/auth/password_test.go | 106 ++++++ Server/auth/ratelimit.go | 104 ++++++ Server/auth/ratelimit_test.go | 126 +++++++ Server/auth/session.go | 24 ++ Server/auth/session_test.go | 77 +++++ Server/db/auth_queries.go | 285 ++++++++++++++++ Server/db/auth_queries_test.go | 468 ++++++++++++++++++++++++++ Server/db/invite_queries.go | 30 ++ Server/db/models.go | 56 +++ Server/db/role_invite_queries_test.go | 156 +++++++++ Server/db/role_queries.go | 49 +++ Server/go.mod | 4 + Server/go.sum | 8 + 21 files changed, 3329 insertions(+) create mode 100644 Server/api/auth_handler.go create mode 100644 Server/api/auth_handler_test.go create mode 100644 Server/api/invite_handler.go create mode 100644 Server/api/invite_handler_test.go create mode 100644 Server/api/middleware.go create mode 100644 Server/api/middleware_test.go create mode 100644 Server/auth/password.go create mode 100644 Server/auth/password_test.go create mode 100644 Server/auth/ratelimit.go create mode 100644 Server/auth/ratelimit_test.go create mode 100644 Server/auth/session.go create mode 100644 Server/auth/session_test.go create mode 100644 Server/db/auth_queries.go create mode 100644 Server/db/auth_queries_test.go create mode 100644 Server/db/invite_queries.go create mode 100644 Server/db/models.go create mode 100644 Server/db/role_invite_queries_test.go create mode 100644 Server/db/role_queries.go diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go new file mode 100644 index 00000000..e04cf2f8 --- /dev/null +++ b/Server/api/auth_handler.go @@ -0,0 +1,311 @@ +package api + +import ( + "encoding/json" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/microcosm-cc/bluemonday" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// sanitizer strips all HTML from user-supplied strings before storage. +var sanitizer = bluemonday.StrictPolicy() + +// genericAuthError is returned for all login/register failures to avoid +// revealing whether a username exists. +var genericAuthError = errorResponse{ + Error: "INVALID_CREDENTIALS", + Message: "invalid invite or credentials", +} + +// registerRequest is the JSON body for POST /api/v1/auth/register. +type registerRequest struct { + Username string `json:"username"` + Password string `json:"password"` + InviteCode string `json:"invite_code"` +} + +// loginRequest is the JSON body for POST /api/v1/auth/login. +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// userResponse is the user shape included in auth responses. +type userResponse struct { + ID int64 `json:"id"` + Username string `json:"username"` + Avatar string `json:"avatar,omitempty"` + Status string `json:"status"` + RoleID int64 `json:"role_id"` + CreatedAt string `json:"created_at"` +} + +// authSuccessResponse is returned on successful login/register. +type authSuccessResponse struct { + Token string `json:"token"` + User userResponse `json:"user"` +} + +// MountAuthRoutes registers all auth endpoints on the given router. +// Rate limiters are applied per-endpoint as specified. +func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter) { + registerLimiter := limiter + loginLimiter := limiter + + r.Route("/api/v1/auth", func(r chi.Router) { + r.With(RateLimitMiddleware(registerLimiter, 3, time.Minute)). + Post("/register", handleRegister(database)) + + r.With(RateLimitMiddleware(loginLimiter, 5, time.Minute)). + Post("/login", handleLogin(database, limiter)) + + r.With(AuthMiddleware(database)). + Post("/logout", handleLogout(database)) + + r.With(AuthMiddleware(database)). + Get("/me", handleMe()) + }) +} + +// handleRegister processes POST /api/v1/auth/register. +func handleRegister(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req registerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "malformed request body", + }) + return + } + + req.Username = strings.TrimSpace(sanitizer.Sanitize(req.Username)) + req.InviteCode = strings.TrimSpace(req.InviteCode) + + if req.Username == "" || req.Password == "" || req.InviteCode == "" { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "username, password, and invite_code are required", + }) + return + } + + // Validate password strength before anything else. + if err := auth.ValidatePasswordStrength(req.Password); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: err.Error(), + }) + return + } + + // Validate invite. + inv, err := database.GetInvite(req.InviteCode) + if err != nil || inv == nil || inv.Revoked { + writeJSON(w, http.StatusBadRequest, genericAuthError) + return + } + if err := database.UseInvite(req.InviteCode); err != nil { + writeJSON(w, http.StatusBadRequest, genericAuthError) + return + } + + // Hash password. + hash, err := auth.HashPassword(req.Password) + if err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to process registration", + }) + return + } + + // Create user with default Member role (4). + uid, err := database.CreateUser(req.Username, hash, 4) + if err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "registration failed — check your details", + }) + return + } + + // Issue session. + token, err := auth.GenerateToken() + if err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to create session", + }) + return + } + + device := r.Header.Get("User-Agent") + ip := clientIP(r) + if _, err := database.CreateSession(uid, auth.HashToken(token), device, ip); err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to create session", + }) + return + } + + user, _ := database.GetUserByID(uid) + writeJSON(w, http.StatusCreated, authSuccessResponse{ + Token: token, + User: toUserResponse(user), + }) + } +} + +// handleLogin processes POST /api/v1/auth/login. +func handleLogin(database *db.DB, limiter *auth.RateLimiter) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "malformed request body", + }) + return + } + + req.Username = strings.TrimSpace(req.Username) + req.Password = strings.TrimSpace(req.Password) + + if req.Username == "" || req.Password == "" { + writeJSON(w, http.StatusBadRequest, errorResponse{ + Error: "INVALID_INPUT", + Message: "username and password are required", + }) + return + } + + ip := clientIP(r) + + // Check lockout first. + lockKey := "login_lock:" + ip + if limiter.IsLockedOut(lockKey) { + writeJSON(w, http.StatusTooManyRequests, errorResponse{ + Error: "RATE_LIMITED", + Message: "account temporarily locked due to too many failed attempts", + }) + return + } + + // Constant-time lookup: always attempt bcrypt compare even when user + // does not exist to prevent timing-based username enumeration. + user, err := database.GetUserByUsername(req.Username) + + failKey := "login_fail:" + ip + if err != nil || user == nil || !auth.CheckPassword(user.PasswordHash, req.Password) { + // Track failures; lockout after 10. + if !limiter.Allow(failKey, 10, 15*time.Minute) { + limiter.Lockout(lockKey, 15*time.Minute) + } + slog.Info("login failed", "ip", ip, "username_len", len(req.Username)) + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "invalid credentials", + }) + return + } + + // Reset failure counter on success. + limiter.Reset(failKey) + + if user.Banned { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "your account has been suspended", + }) + return + } + + // Issue session. + token, err := auth.GenerateToken() + if err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to create session", + }) + return + } + + device := r.Header.Get("User-Agent") + if _, err := database.CreateSession(user.ID, auth.HashToken(token), device, ip); err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to create session", + }) + return + } + + _ = database.UpdateUserStatus(user.ID, "online") + writeJSON(w, http.StatusOK, authSuccessResponse{ + Token: token, + User: toUserResponse(user), + }) + } +} + +// handleLogout processes POST /api/v1/auth/logout. +func handleLogout(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sess, ok := r.Context().Value(SessionKey).(*db.Session) + if !ok || sess == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "not authenticated", + }) + return + } + + if err := database.DeleteSession(sess.TokenHash); err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to logout", + }) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} + +// handleMe processes GET /api/v1/auth/me. +func handleMe() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(UserKey).(*db.User) + if !ok || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "not authenticated", + }) + return + } + writeJSON(w, http.StatusOK, toUserResponse(user)) + } +} + +// toUserResponse converts a db.User to the API response shape. +func toUserResponse(u *db.User) userResponse { + avatar := "" + if u.Avatar != nil { + avatar = *u.Avatar + } + return userResponse{ + ID: u.ID, + Username: u.Username, + Avatar: avatar, + Status: u.Status, + RoleID: u.RoleID, + CreatedAt: u.CreatedAt, + } +} diff --git a/Server/api/auth_handler_test.go b/Server/api/auth_handler_test.go new file mode 100644 index 00000000..943768ea --- /dev/null +++ b/Server/api/auth_handler_test.go @@ -0,0 +1,456 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// newAuthTestDB builds an in-memory DB with the full schema needed for auth tests. +func newAuthTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: apiTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// buildAuthRouter returns a chi router with auth routes mounted on /api/v1/auth. +func buildAuthRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler { + r := chi.NewRouter() + api.MountAuthRoutes(r, database, limiter) + return r +} + +// postJSON is a test helper that POSTs JSON to the given router. +func postJSON(t *testing.T, router http.Handler, path string, body interface{}) *httptest.ResponseRecorder { + t.Helper() + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// postJSONWithToken posts with an Authorization header. +func postJSONWithToken(t *testing.T, router http.Handler, path, token string, body interface{}) *httptest.ResponseRecorder { + t.Helper() + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// getWithToken performs a GET with an Authorization header. +func getWithToken(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// ─── Register tests ─────────────────────────────────────────────────────────── + +func TestRegister_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + // Create an invite first. + ownerID, _ := database.CreateUser("owner", "hash", 1) + code, _ := database.CreateInvite(ownerID, 1, nil) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "newuser", + "password": "securePass1", + "invite_code": code, + }) + + if rr.Code != http.StatusCreated { + t.Errorf("Register status = %d, want 201; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + if resp["token"] == nil { + t.Error("Register response missing token") + } + if resp["user"] == nil { + t.Error("Register response missing user") + } +} + +func TestRegister_InvalidInvite(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "newuser", + "password": "securePass1", + "invite_code": "bogus", + }) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Register invalid invite status = %d, want 400", rr.Code) + } +} + +func TestRegister_WeakPassword(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + ownerID, _ := database.CreateUser("owner2", "hash", 1) + code, _ := database.CreateInvite(ownerID, 1, nil) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "newuser", + "password": "short", + "invite_code": code, + }) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Register weak password status = %d, want 400", rr.Code) + } +} + +func TestRegister_InviteUsedUp(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + ownerID, _ := database.CreateUser("owner3", "hash", 1) + code, _ := database.CreateInvite(ownerID, 1, nil) // max 1 use + + // First registration should succeed. + postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "user1", + "password": "securePass1", + "invite_code": code, + }) + + // Second should fail — invite exhausted. + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "user2", + "password": "securePass2", + "invite_code": code, + }) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Register exhausted invite status = %d, want 400", rr.Code) + } +} + +func TestRegister_MissingFields(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{}) + if rr.Code != http.StatusBadRequest { + t.Errorf("Register missing fields status = %d, want 400", rr.Code) + } +} + +func TestRegister_ErrorNeverRevealUsername(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "someone", + "password": "securePass1", + "invite_code": "bogus", + }) + + body := rr.Body.String() + // Must not hint that the username doesn't exist or the invite is invalid specifically + if contains(body, "username") && contains(body, "taken") { + t.Error("Register error message reveals username status") + } +} + +// ─── Login tests ────────────────────────────────────────────────────────────── + +func TestLogin_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + database.CreateUser("loginuser", hash, 4) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "loginuser", + "password": "correctPass1", + }) + + if rr.Code != http.StatusOK { + t.Errorf("Login status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + if resp["token"] == nil { + t.Error("Login response missing token") + } +} + +func TestLogin_WrongPassword(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + database.CreateUser("loginuser2", hash, 4) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "loginuser2", + "password": "wrongpassword", + }) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Login wrong password status = %d, want 401", rr.Code) + } +} + +func TestLogin_UnknownUser(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "nobody", + "password": "anypass123", + }) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Login unknown user status = %d, want 401", rr.Code) + } +} + +func TestLogin_GenericErrorOnBadCredentials(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "nobody", + "password": "anypass123", + }) + + body := rr.Body.String() + // The response must never reveal whether the user exists + if contains(body, "user not found") || contains(body, "does not exist") { + t.Errorf("Login error reveals user existence: %s", body) + } +} + +func TestLogin_BannedUser(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + id, _ := database.CreateUser("banned", hash, 4) + database.BanUser(id, "violated rules", nil) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{ + "username": "banned", + "password": "correctPass1", + }) + + if rr.Code != http.StatusForbidden { + t.Errorf("Login banned user status = %d, want 403", rr.Code) + } +} + +func TestLogin_MissingFields(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{}) + if rr.Code != http.StatusBadRequest { + t.Errorf("Login missing fields status = %d, want 400", rr.Code) + } +} + +// ─── Logout tests ───────────────────────────────────────────────────────────── + +func TestLogout_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + uid, _ := database.CreateUser("logoutuser", hash, 4) + token, _ := auth.GenerateToken() + tokenHash := auth.HashToken(token) + database.CreateSession(uid, tokenHash, "test", "127.0.0.1") + + rr := postJSONWithToken(t, router, "/api/v1/auth/logout", token, nil) + + if rr.Code != http.StatusNoContent { + t.Errorf("Logout status = %d, want 204", rr.Code) + } + + // Session should be gone. + sess, _ := database.GetSessionByTokenHash(tokenHash) + if sess != nil { + t.Error("Session still exists after logout") + } +} + +func TestLogout_NoAuth(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Logout no auth status = %d, want 401", rr.Code) + } +} + +// ─── Me tests ───────────────────────────────────────────────────────────────── + +func TestMe_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + uid, _ := database.CreateUser("meuser", hash, 4) + token, _ := auth.GenerateToken() + database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + + rr := getWithToken(t, router, "/api/v1/auth/me", token) + + if rr.Code != http.StatusOK { + t.Errorf("Me status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + if resp["id"] == nil { + t.Error("Me response missing id") + } + if resp["username"] != "meuser" { + t.Errorf("Me username = %v, want meuser", resp["username"]) + } +} + +func TestMe_NoAuth(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Me no auth status = %d, want 401", rr.Code) + } +} + +// ─── Rate limiting integration test ────────────────────────────────────────── + +func TestRegister_RateLimit(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + ownerID, _ := database.CreateUser("rl_owner", "hash", 1) + + // Attempt register 4 times (limit=3) — 4th should be rate-limited. + var lastCode int + for i := 0; i < 4; i++ { + code, _ := database.CreateInvite(ownerID, 1, nil) + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "rl_user" + string(rune('0'+i)), + "password": "securePass1", + "invite_code": code, + }) + lastCode = rr.Code + } + + if lastCode != http.StatusTooManyRequests { + t.Errorf("Register rate limit: last attempt status = %d, want 429", lastCode) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub)) +} + +func containsStr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// expiredInviteDB creates a DB with an already-expired invite. +func expiredInviteDB(t *testing.T) (*db.DB, string) { + t.Helper() + database := newAuthTestDB(t) + ownerID, _ := database.CreateUser("expowner", "hash", 1) + past := time.Now().Add(-time.Hour) + code, _ := database.CreateInvite(ownerID, 0, &past) + return database, code +} + +func TestRegister_ExpiredInvite(t *testing.T) { + database, code := expiredInviteDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{ + "username": "newuser", + "password": "securePass1", + "invite_code": code, + }) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Register expired invite status = %d, want 400", rr.Code) + } +} diff --git a/Server/api/invite_handler.go b/Server/api/invite_handler.go new file mode 100644 index 00000000..442af7cc --- /dev/null +++ b/Server/api/invite_handler.go @@ -0,0 +1,160 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/db" +) + +// manageInvitesPerm is the MANAGE_INVITES permission bit. +const manageInvitesPerm = int64(0x4000000) + +// createInviteRequest is the JSON body for POST /api/v1/invites. +type createInviteRequest struct { + MaxUses int `json:"max_uses"` + ExpiresInHours int `json:"expires_in_hours"` +} + +// inviteResponse is the API shape for an invite. +type inviteResponse struct { + ID int64 `json:"id"` + Code string `json:"code"` + MaxUses *int `json:"max_uses"` + Uses int `json:"uses"` + ExpiresAt *string `json:"expires_at"` + Revoked bool `json:"revoked"` + CreatedAt string `json:"created_at"` +} + +// MountInviteRoutes registers invite endpoints on the given router. +// All routes require authentication and MANAGE_INVITES permission. +func MountInviteRoutes(r chi.Router, database *db.DB) { + r.Route("/api/v1/invites", func(r chi.Router) { + r.Use(AuthMiddleware(database)) + r.Use(RequirePermission(manageInvitesPerm)) + + r.Post("/", handleCreateInvite(database)) + r.Get("/", handleListInvites(database)) + r.Delete("/{code}", handleRevokeInvite(database)) + }) +} + +// handleCreateInvite processes POST /api/v1/invites. +func handleCreateInvite(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req createInviteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Treat missing body as default values (all optional). + req = createInviteRequest{} + } + + user, ok := r.Context().Value(UserKey).(*db.User) + if !ok || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "not authenticated", + }) + return + } + + var expiresAt *time.Time + if req.ExpiresInHours > 0 { + t := time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour) + expiresAt = &t + } + + code, err := database.CreateInvite(user.ID, req.MaxUses, expiresAt) + if err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to create invite", + }) + return + } + + inv, err := database.GetInvite(code) + if err != nil || inv == nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to retrieve invite", + }) + return + } + + writeJSON(w, http.StatusCreated, toInviteResponse(inv)) + } +} + +// handleListInvites processes GET /api/v1/invites. +func handleListInvites(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + invites, err := database.ListInvites() + if err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to list invites", + }) + return + } + + resp := make([]inviteResponse, 0, len(invites)) + for _, inv := range invites { + resp = append(resp, toInviteResponse(inv)) + } + writeJSON(w, http.StatusOK, resp) + } +} + +// handleRevokeInvite processes DELETE /api/v1/invites/:code. +func handleRevokeInvite(database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + code := chi.URLParam(r, "code") + + inv, err := database.GetInvite(code) + if err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to look up invite", + }) + return + } + if inv == nil { + writeJSON(w, http.StatusNotFound, errorResponse{ + Error: "NOT_FOUND", + Message: "invite not found", + }) + return + } + + if err := database.RevokeInvite(code); err != nil { + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "SERVER_ERROR", + Message: "failed to revoke invite", + }) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} + +// toInviteResponse converts a db.Invite to the API response shape. +func toInviteResponse(inv *db.Invite) inviteResponse { + var maxUses *int + if inv.MaxUses != nil { + v := *inv.MaxUses + maxUses = &v + } + return inviteResponse{ + ID: inv.ID, + Code: inv.Code, + MaxUses: maxUses, + Uses: inv.Uses, + ExpiresAt: inv.ExpiresAt, + Revoked: inv.Revoked, + CreatedAt: inv.CreatedAt, + } +} diff --git a/Server/api/invite_handler_test.go b/Server/api/invite_handler_test.go new file mode 100644 index 00000000..9aa79224 --- /dev/null +++ b/Server/api/invite_handler_test.go @@ -0,0 +1,294 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// buildInviteRouter returns a chi router with invite routes and auth middleware. +func buildInviteRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler { + r := chi.NewRouter() + api.MountAuthRoutes(r, database, limiter) + api.MountInviteRoutes(r, database) + return r +} + +// loginAndGetToken creates a user with a known password and returns their session token. +func loginAndGetToken(t *testing.T, router http.Handler, database *db.DB, username string, roleID int) string { + t.Helper() + hash, _ := auth.HashPassword("Password1!") + uid, _ := database.CreateUser(username, hash, roleID) + token, _ := auth.GenerateToken() + database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1") + return token +} + +// ─── POST /api/v1/invites ───────────────────────────────────────────────────── + +func TestCreateInvite_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + // Admin role (id=2) has MANAGE_INVITES (0x4000000) set. + token := loginAndGetToken(t, router, database, "invitecreator", 2) + + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{ + "max_uses": 5, + "expires_in_hours": 48, + }) + + if rr.Code != http.StatusCreated { + t.Errorf("CreateInvite status = %d, want 201; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + if resp["code"] == nil { + t.Error("CreateInvite response missing code") + } +} + +func TestCreateInvite_Unauthorized(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + rr := postJSON(t, router, "/api/v1/invites", map[string]interface{}{ + "max_uses": 5, + }) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("CreateInvite no auth status = %d, want 401", rr.Code) + } +} + +func TestCreateInvite_MemberForbidden(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + // Member role (id=4) does NOT have MANAGE_INVITES. + token := loginAndGetToken(t, router, database, "memberuser", 4) + + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{ + "max_uses": 1, + }) + + if rr.Code != http.StatusForbidden { + t.Errorf("CreateInvite member status = %d, want 403", rr.Code) + } +} + +func TestCreateInvite_Unlimited(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + token := loginAndGetToken(t, router, database, "adminuser2", 2) + + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{}) + + if rr.Code != http.StatusCreated { + t.Errorf("CreateInvite unlimited status = %d, want 201", rr.Code) + } +} + +// ─── GET /api/v1/invites ────────────────────────────────────────────────────── + +func TestListInvites_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + token := loginAndGetToken(t, router, database, "listuser", 2) + + // Create a couple of invites. + postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{"max_uses": 1}) + postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{"max_uses": 5}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("ListInvites status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } + + var resp []interface{} + json.NewDecoder(rr.Body).Decode(&resp) + if len(resp) < 2 { + t.Errorf("ListInvites returned %d items, want >= 2", len(resp)) + } +} + +func TestListInvites_Unauthorized(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("ListInvites no auth status = %d, want 401", rr.Code) + } +} + +// ─── DELETE /api/v1/invites/:code ───────────────────────────────────────────── + +func TestRevokeInvite_Success(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + token := loginAndGetToken(t, router, database, "revoker", 2) + + // Create invite via API. + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{}) + if rr.Code != http.StatusCreated { + t.Fatalf("Create invite for revoke test: status = %d, body = %s", rr.Code, rr.Body.String()) + } + var created map[string]interface{} + json.NewDecoder(rr.Body).Decode(&created) + codeVal, ok := created["code"] + if !ok || codeVal == nil { + t.Fatalf("Create invite response missing code field; body parsed as %v", created) + } + code := codeVal.(string) + + // Revoke it. + req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr2 := httptest.NewRecorder() + router.ServeHTTP(rr2, req) + + if rr2.Code != http.StatusNoContent { + t.Errorf("RevokeInvite status = %d, want 204; body = %s", rr2.Code, rr2.Body.String()) + } + + // Verify invite is revoked. + inv, _ := database.GetInvite(code) + if inv == nil || !inv.Revoked { + t.Error("Invite not revoked in database after DELETE") + } +} + +func TestRevokeInvite_NotFound(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + token := loginAndGetToken(t, router, database, "revoker2", 2) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/doesnotexist", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Errorf("RevokeInvite not found status = %d, want 404", rr.Code) + } +} + +func TestRevokeInvite_MemberForbidden(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + adminToken := loginAndGetToken(t, router, database, "admin3", 2) + memberToken := loginAndGetToken(t, router, database, "member3", 4) + + // Admin creates invite. + rr := postJSONWithToken(t, router, "/api/v1/invites", adminToken, map[string]interface{}{}) + var created map[string]interface{} + json.NewDecoder(rr.Body).Decode(&created) + code := created["code"].(string) + + // Member tries to revoke. + req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil) + req.Header.Set("Authorization", "Bearer "+memberToken) + req.RemoteAddr = "127.0.0.1:9999" + rr2 := httptest.NewRecorder() + router.ServeHTTP(rr2, req) + + if rr2.Code != http.StatusForbidden { + t.Errorf("RevokeInvite member status = %d, want 403", rr2.Code) + } +} + +// TestListInvites_IncludesRevokedAndActive checks the list endpoint returns +// correct data for both revoked and active invites. +func TestListInvites_IncludesRevokedAndActive(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + + token := loginAndGetToken(t, router, database, "listall", 2) + + // Create and revoke one invite. + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{}) + if rr.Code != http.StatusCreated { + t.Fatalf("Create invite for list test: status = %d, body = %s", rr.Code, rr.Body.String()) + } + var created map[string]interface{} + json.NewDecoder(rr.Body).Decode(&created) + code := created["code"].(string) + + delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil) + delReq.Header.Set("Authorization", "Bearer "+token) + delReq.RemoteAddr = "127.0.0.1:9999" + httptest.NewRecorder() // discard + router.ServeHTTP(httptest.NewRecorder(), delReq) + + // Create one active invite. + postJSONWithToken(t, router, "/api/v1/invites", token, map[string]interface{}{}) + + // List should include both. + req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr2 := httptest.NewRecorder() + router.ServeHTTP(rr2, req) + + if rr2.Code != http.StatusOK { + t.Errorf("ListInvites status = %d, want 200", rr2.Code) + } +} + +// ─── Helpers for ListInvites queries ───────────────────────────────────────── + +// ListInvites returns all invites from the DB for assertions. +func listInvitesFromDB(t *testing.T, database *db.DB) []*db.Invite { + t.Helper() + rows, err := database.Query(`SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at FROM invites`) + if err != nil { + t.Fatalf("listing invites: %v", err) + } + defer rows.Close() + + var invites []*db.Invite + for rows.Next() { + inv := &db.Invite{} + var revoked int + if err := rows.Scan(&inv.ID, &inv.Code, &inv.CreatedBy, &inv.MaxUses, &inv.Uses, &inv.ExpiresAt, &revoked, &inv.CreatedAt); err != nil { + t.Fatalf("scanning invite: %v", err) + } + inv.Revoked = revoked != 0 + invites = append(invites, inv) + } + return invites +} diff --git a/Server/api/middleware.go b/Server/api/middleware.go new file mode 100644 index 00000000..1606ef5c --- /dev/null +++ b/Server/api/middleware.go @@ -0,0 +1,194 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// contextKey is an unexported type for context keys in this package. +type contextKey int + +const ( + // UserKey is the context key for the authenticated *db.User. + UserKey contextKey = iota + // SessionKey is the context key for the authenticated *db.Session. + SessionKey + // RoleKey is the context key for the *db.Role of the authenticated user. + RoleKey +) + +// AuthMiddleware reads the "Authorization: Bearer " header, validates +// the session, and injects the user and session into the request context. +// Returns 401 if the token is missing, invalid, or the session is expired. +func AuthMiddleware(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 := extractBearerToken(r) + if !ok { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "missing or invalid authorization header", + }) + return + } + + hash := auth.HashToken(token) + sess, err := database.GetSessionByTokenHash(hash) + if err != nil || sess == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "invalid or expired session", + }) + return + } + + // Check expiry. + if isSessionExpired(sess.ExpiresAt) { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "session has expired", + }) + return + } + + // Load user. + user, err := database.GetUserByID(sess.UserID) + if err != nil || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "user not found", + }) + return + } + + // Load role for permission checks. + role, err := database.GetRoleByID(user.RoleID) + if err != nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "role not found", + }) + return + } + + // Touch session in background — non-fatal if it fails. + _ = database.TouchSession(hash) + + ctx := context.WithValue(r.Context(), UserKey, user) + ctx = context.WithValue(ctx, SessionKey, sess) + ctx = context.WithValue(ctx, RoleKey, role) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RequirePermission returns middleware that checks the authenticated user's +// role permissions. Returns 403 if the user lacks the required permission. +// The ADMINISTRATOR bit (0x40000000) bypasses all checks. +func RequirePermission(perm int64) func(http.Handler) http.Handler { + const administrator = int64(0x40000000) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + role, ok := r.Context().Value(RoleKey).(*db.Role) + if !ok || role == nil { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "insufficient permissions", + }) + return + } + + // ADMINISTRATOR bypasses all permission checks. + if role.Permissions&administrator != 0 { + next.ServeHTTP(w, r) + return + } + + if role.Permissions&perm == 0 { + writeJSON(w, http.StatusForbidden, errorResponse{ + Error: "FORBIDDEN", + Message: "insufficient permissions", + }) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// RateLimitMiddleware returns middleware that limits requests per IP using the +// provided RateLimiter. The IP is taken from X-Real-IP header when present, +// falling back to RemoteAddr. Returns 429 with Retry-After when exceeded. +func RateLimitMiddleware(limiter *auth.RateLimiter, limit int, window time.Duration) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + + if !limiter.Allow(ip, limit, window) { + w.Header().Set("Retry-After", fmt.Sprintf("%d", int(window.Seconds()))) + writeJSON(w, http.StatusTooManyRequests, errorResponse{ + Error: "RATE_LIMITED", + Message: "too many requests, please slow down", + }) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +// extractBearerToken parses "Authorization: Bearer " and returns the +// token and true, or "", false if the header is missing or malformed. +func extractBearerToken(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 +} + +// clientIP returns the client IP from X-Real-IP or RemoteAddr (without port). +func clientIP(r *http.Request) string { + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + // RemoteAddr is "host:port"; strip the port. + addr := r.RemoteAddr + if idx := strings.LastIndex(addr, ":"); idx != -1 { + return addr[:idx] + } + return addr +} + +// isSessionExpired returns true when expiresAt string represents a past time. +// Handles both "2006-01-02 15:04:05" (SQLite) and "2006-01-02T15:04:05Z" formats. +func isSessionExpired(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()) + } + } + // Unparseable expiry — treat as expired for safety. + return true +} + +// errorResponse is the standard error JSON shape. +type errorResponse struct { + Error string `json:"error"` + Message string `json:"message"` +} diff --git a/Server/api/middleware_test.go b/Server/api/middleware_test.go new file mode 100644 index 00000000..1dfddb4b --- /dev/null +++ b/Server/api/middleware_test.go @@ -0,0 +1,361 @@ +package api_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/db" +) + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +func newAPITestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: apiTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// ok is a trivial handler that responds 200 OK to confirm the middleware +// passed the request through. +func ok(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} + +// bearerToken wraps an HTTP handler with an Authorization header bearing token. +func withBearer(req *http.Request, token string) *http.Request { + req.Header.Set("Authorization", "Bearer "+token) + return req +} + +// ─── AuthMiddleware tests ───────────────────────────────────────────────────── + +func TestAuthMiddleware_ValidToken(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("alice", "hash", 4) + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + database.CreateSession(uid, hash, "test", "127.0.0.1") + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("AuthMiddleware valid token status = %d, want %d", rr.Code, http.StatusOK) + } +} + +func TestAuthMiddleware_MissingToken(t *testing.T) { + database := newAPITestDB(t) + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("AuthMiddleware no token status = %d, want 401", rr.Code) + } +} + +func TestAuthMiddleware_InvalidToken(t *testing.T) { + database := newAPITestDB(t) + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, "notarealtoken") + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("AuthMiddleware invalid token status = %d, want 401", rr.Code) + } +} + +func TestAuthMiddleware_ExpiredSession(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("bob", "hash", 4) + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + + // Insert an already-expired session. + pastTime := time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05") + database.Exec( + `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`, + uid, hash, "test", "127.0.0.1", pastTime, + ) + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("AuthMiddleware expired session status = %d, want 401", rr.Code) + } +} + +func TestAuthMiddleware_MalformedAuthHeader(t *testing.T) { + database := newAPITestDB(t) + + h := api.AuthMiddleware(database)(http.HandlerFunc(ok)) + + cases := []string{ + "Token abc", // wrong scheme + "Bearer", // missing token after Bearer + "abc", // no space + } + for _, header := range cases { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", header) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("AuthMiddleware header=%q status = %d, want 401", header, rr.Code) + } + } +} + +// ─── RequirePermission tests ────────────────────────────────────────────────── + +func TestRequirePermission_Allowed(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("carol", "hash", 4) // Member role = 0x00100601 + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + database.CreateSession(uid, hash, "test", "127.0.0.1") + + // SEND_MESSAGES = 0x1 — Member role has this bit + h := api.AuthMiddleware(database)( + api.RequirePermission(0x1)(http.HandlerFunc(ok)), + ) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("RequirePermission allowed status = %d, want 200", rr.Code) + } +} + +func TestRequirePermission_Forbidden(t *testing.T) { + database := newAPITestDB(t) + uid, _ := database.CreateUser("dave", "hash", 4) // Member role = 0x00100601 + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + database.CreateSession(uid, hash, "test", "127.0.0.1") + + // MANAGE_ROLES = 0x1000000 — Member does not have this + h := api.AuthMiddleware(database)( + api.RequirePermission(0x1000000)(http.HandlerFunc(ok)), + ) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Errorf("RequirePermission forbidden status = %d, want 403", rr.Code) + } +} + +func TestRequirePermission_Administrator_Bypass(t *testing.T) { + database := newAPITestDB(t) + // Owner role (id=1) has permissions 0x7FFFFFFF which includes ADMINISTRATOR (0x40000000) + uid, _ := database.CreateUser("owner", "hash", 1) + token, _ := auth.GenerateToken() + hash := auth.HashToken(token) + database.CreateSession(uid, hash, "test", "127.0.0.1") + + // Any permission should pass for ADMINISTRATOR + h := api.AuthMiddleware(database)( + api.RequirePermission(0x1000000)(http.HandlerFunc(ok)), + ) + req := httptest.NewRequest(http.MethodGet, "/", nil) + withBearer(req, token) + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("RequirePermission administrator bypass status = %d, want 200", rr.Code) + } +} + +// ─── RateLimitMiddleware tests ──────────────────────────────────────────────── + +func TestRateLimitMiddleware_UnderLimit(t *testing.T) { + limiter := auth.NewRateLimiter() + + h := api.RateLimitMiddleware(limiter, 5, time.Minute)(http.HandlerFunc(ok)) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:1234" + rr := httptest.NewRecorder() + + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("RateLimitMiddleware under limit status = %d, want 200", rr.Code) + } +} + +func TestRateLimitMiddleware_OverLimit(t *testing.T) { + limiter := auth.NewRateLimiter() + limit := 3 + + h := api.RateLimitMiddleware(limiter, limit, time.Minute)(http.HandlerFunc(ok)) + + for i := 0; i < limit; i++ { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.2:1234" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + } + + // This next request should be rate-limited. + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.2:1234" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusTooManyRequests { + t.Errorf("RateLimitMiddleware over limit status = %d, want 429", rr.Code) + } +} + +func TestRateLimitMiddleware_RetryAfterHeader(t *testing.T) { + limiter := auth.NewRateLimiter() + + h := api.RateLimitMiddleware(limiter, 1, time.Minute)(http.HandlerFunc(ok)) + + // Exhaust limit. + for i := 0; i < 2; i++ { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.3:1234" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.3:1234" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Header().Get("Retry-After") == "" { + t.Error("RateLimitMiddleware: missing Retry-After header on 429 response") + } +} + +func TestRateLimitMiddleware_XRealIPUsed(t *testing.T) { + limiter := auth.NewRateLimiter() + limit := 2 + + h := api.RateLimitMiddleware(limiter, limit, time.Minute)(http.HandlerFunc(ok)) + + // Two requests from the same X-Real-IP but different RemoteAddr. + for i := 0; i < limit; i++ { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Real-IP", "192.168.1.1") + req.RemoteAddr = "10.0.0.99:9999" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + } + + // Third request should be blocked by the X-Real-IP key. + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Real-IP", "192.168.1.1") + req.RemoteAddr = "10.0.0.99:9999" + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + if rr.Code != http.StatusTooManyRequests { + t.Errorf("RateLimitMiddleware X-Real-IP status = %d, want 429", rr.Code) + } +} + +// apiTestSchema is the full schema needed for all api tests (middleware, +// auth handler, and invite handler). +var apiTestSchema = []byte(` +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT, + permissions INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); + +INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES + (1, 'Owner', '#E74C3C', 2147483647, 100, 0), + (2, 'Admin', '#F39C12', 1073741823, 80, 0), + (3, 'Moderator', '#3498DB', 1048575, 60, 0), + (4, 'Member', NULL, 1049089, 40, 1); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + avatar TEXT, + role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), + totp_secret TEXT, + status TEXT NOT NULL DEFAULT 'offline', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT, + banned INTEGER NOT NULL DEFAULT 0, + ban_reason TEXT, + ban_expires TEXT +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + device TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); + +CREATE TABLE IF NOT EXISTS invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + redeemed_by INTEGER REFERENCES users(id), + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); +`) diff --git a/Server/api/router.go b/Server/api/router.go index 3ec1ed49..56068e5f 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/owncord/server/auth" "github.com/owncord/server/config" "github.com/owncord/server/db" ) @@ -27,11 +28,20 @@ func NewRouter(cfg *config.Config, database *db.DB) http.Handler { // Health check — unauthenticated, no versioning prefix. r.Get("/health", handleHealth) + // Shared rate limiter for auth endpoints. + limiter := auth.NewRateLimiter() + // Versioned API routes. r.Route("/api/v1", func(r chi.Router) { r.Get("/info", handleInfo(cfg)) }) + // Auth routes: register, login, logout, me. + MountAuthRoutes(r, database, limiter) + + // Invite management routes (require MANAGE_INVITES permission). + MountInviteRoutes(r, database) + return r } diff --git a/Server/auth/password.go b/Server/auth/password.go new file mode 100644 index 00000000..f1cffb07 --- /dev/null +++ b/Server/auth/password.go @@ -0,0 +1,50 @@ +package auth + +import ( + "errors" + + "golang.org/x/crypto/bcrypt" +) + +const ( + bcryptCost = 12 + minPassLen = 8 + maxPassLen = 72 // bcrypt silently truncates beyond 72 bytes +) + +// ErrPasswordTooShort is returned when the password is below the minimum length. +var ErrPasswordTooShort = errors.New("password must be at least 8 characters") + +// ErrPasswordTooLong is returned when the password exceeds bcrypt's 72-byte limit. +var ErrPasswordTooLong = errors.New("password must not exceed 72 characters") + +// HashPassword returns a bcrypt hash of password using cost 12. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// CheckPassword reports whether password matches hash. Returns false on any +// error, including an empty or malformed hash. +func CheckPassword(hash, password string) bool { + if hash == "" { + return false + } + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// ValidatePasswordStrength returns an error if password fails strength +// requirements: minimum 8 characters, maximum 72 characters. +func ValidatePasswordStrength(password string) error { + if len(password) < minPassLen { + return ErrPasswordTooShort + } + if len(password) > maxPassLen { + return ErrPasswordTooLong + } + return nil +} diff --git a/Server/auth/password_test.go b/Server/auth/password_test.go new file mode 100644 index 00000000..a8581062 --- /dev/null +++ b/Server/auth/password_test.go @@ -0,0 +1,106 @@ +package auth_test + +import ( + "strings" + "testing" + + "github.com/owncord/server/auth" +) + +func TestHashPassword_DiffersFromPlaintext(t *testing.T) { + hash, err := auth.HashPassword("mypassword") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if hash == "mypassword" { + t.Error("HashPassword() hash equals plaintext") + } +} + +func TestHashPassword_BcryptPrefix(t *testing.T) { + hash, err := auth.HashPassword("mypassword") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if !strings.HasPrefix(hash, "$2") { + t.Errorf("HashPassword() = %q, want bcrypt prefix $2*", hash) + } +} + +func TestCheckPassword_CorrectPassword(t *testing.T) { + hash, err := auth.HashPassword("correctpassword") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if !auth.CheckPassword(hash, "correctpassword") { + t.Error("CheckPassword() returned false for correct password") + } +} + +func TestCheckPassword_WrongPassword(t *testing.T) { + hash, err := auth.HashPassword("correctpassword") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if auth.CheckPassword(hash, "wrongpassword") { + t.Error("CheckPassword() returned true for wrong password") + } +} + +func TestCheckPassword_EmptyPassword(t *testing.T) { + hash, err := auth.HashPassword("somepassword") + if err != nil { + t.Fatalf("HashPassword() error = %v", err) + } + if auth.CheckPassword(hash, "") { + t.Error("CheckPassword() returned true for empty password") + } +} + +func TestCheckPassword_EmptyHash(t *testing.T) { + if auth.CheckPassword("", "somepassword") { + t.Error("CheckPassword() returned true with empty hash") + } +} + +func TestValidatePasswordStrength_Valid(t *testing.T) { + cases := []string{ + "12345678", // exactly 8 chars + "abcdefghij", // 10 chars + strings.Repeat("a", 72), // exactly 72 chars (bcrypt max) + } + for _, pw := range cases { + if err := auth.ValidatePasswordStrength(pw); err != nil { + t.Errorf("ValidatePasswordStrength(%q) error = %v, want nil", pw, err) + } + } +} + +func TestValidatePasswordStrength_TooShort(t *testing.T) { + cases := []string{ + "", // empty + "1234567", // 7 chars + "abc", // 3 chars + } + for _, pw := range cases { + if err := auth.ValidatePasswordStrength(pw); err == nil { + t.Errorf("ValidatePasswordStrength(%q) error = nil, want error", pw) + } + } +} + +func TestValidatePasswordStrength_TooLong(t *testing.T) { + pw := strings.Repeat("a", 73) // 73 chars — over bcrypt 72 byte limit + if err := auth.ValidatePasswordStrength(pw); err == nil { + t.Errorf("ValidatePasswordStrength(%q) error = nil, want error for >72 chars", pw) + } +} + +func TestHashPassword_TwoCallsDifferentHashes(t *testing.T) { + // bcrypt includes a random salt + h1, _ := auth.HashPassword("password") + h2, _ := auth.HashPassword("password") + if h1 == h2 { + t.Error("HashPassword() produced identical hashes for the same password (salt missing?)") + } +} diff --git a/Server/auth/ratelimit.go b/Server/auth/ratelimit.go new file mode 100644 index 00000000..5af5a7ec --- /dev/null +++ b/Server/auth/ratelimit.go @@ -0,0 +1,104 @@ +package auth + +import ( + "sync" + "time" +) + +// entry records individual request timestamps for sliding-window limiting. +type entry struct { + timestamps []time.Time +} + +// lockoutEntry records when a lockout expires. +type lockoutEntry struct { + expiresAt time.Time +} + +// RateLimiter is an in-memory, thread-safe sliding-window rate limiter with +// optional IP lockout support. +type RateLimiter struct { + mu sync.Mutex + windows map[string]*entry + lockouts map[string]*lockoutEntry +} + +// NewRateLimiter returns an initialised RateLimiter. +func NewRateLimiter() *RateLimiter { + return &RateLimiter{ + windows: make(map[string]*entry), + lockouts: make(map[string]*lockoutEntry), + } +} + +// Allow reports whether a request from key is permitted given the limit and +// window. It records the current request timestamp regardless of the outcome. +// Returns false when key is locked out or has exceeded limit within window. +func (r *RateLimiter) Allow(key string, limit int, window time.Duration) bool { + r.mu.Lock() + defer r.mu.Unlock() + + // Lockout takes priority. + if lo, ok := r.lockouts[key]; ok { + if time.Now().Before(lo.expiresAt) { + return false + } + delete(r.lockouts, key) + } + + now := time.Now() + cutoff := now.Add(-window) + + e, ok := r.windows[key] + if !ok { + e = &entry{} + r.windows[key] = e + } + + // Prune timestamps outside the current window. + valid := e.timestamps[:0] + for _, ts := range e.timestamps { + if ts.After(cutoff) { + valid = append(valid, ts) + } + } + e.timestamps = valid + + if len(e.timestamps) >= limit { + return false + } + + e.timestamps = append(e.timestamps, now) + return true +} + +// Lockout prevents any requests from key for duration regardless of the +// sliding-window counter. +func (r *RateLimiter) Lockout(key string, duration time.Duration) { + r.mu.Lock() + defer r.mu.Unlock() + r.lockouts[key] = &lockoutEntry{expiresAt: time.Now().Add(duration)} +} + +// IsLockedOut reports whether key is currently under a lockout. +func (r *RateLimiter) IsLockedOut(key string) bool { + r.mu.Lock() + defer r.mu.Unlock() + lo, ok := r.lockouts[key] + if !ok { + return false + } + if time.Now().Before(lo.expiresAt) { + return true + } + delete(r.lockouts, key) + return false +} + +// Reset clears all rate-limit state (timestamps and lockout) for key. +func (r *RateLimiter) Reset(key string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.windows, key) + delete(r.lockouts, key) +} diff --git a/Server/auth/ratelimit_test.go b/Server/auth/ratelimit_test.go new file mode 100644 index 00000000..ca7b7e31 --- /dev/null +++ b/Server/auth/ratelimit_test.go @@ -0,0 +1,126 @@ +package auth_test + +import ( + "testing" + "time" + + "github.com/owncord/server/auth" +) + +func TestRateLimiter_UnderLimitAllowed(t *testing.T) { + rl := auth.NewRateLimiter() + for i := 0; i < 5; i++ { + if !rl.Allow("key1", 5, time.Second) { + t.Errorf("Allow() = false at iteration %d, want true", i) + } + } +} + +func TestRateLimiter_AtLimitAllowed(t *testing.T) { + rl := auth.NewRateLimiter() + // Allow up to exactly the limit + for i := 0; i < 3; i++ { + rl.Allow("keyA", 3, time.Second) + } + // The 4th call should be blocked + if rl.Allow("keyA", 3, time.Second) { + t.Error("Allow() = true after limit exceeded, want false") + } +} + +func TestRateLimiter_OverLimitBlocked(t *testing.T) { + rl := auth.NewRateLimiter() + limit := 3 + for i := 0; i < limit; i++ { + rl.Allow("key2", limit, time.Second) + } + if rl.Allow("key2", limit, time.Second) { + t.Error("Allow() = true when over limit, want false") + } +} + +func TestRateLimiter_WindowExpiryResets(t *testing.T) { + rl := auth.NewRateLimiter() + window := 50 * time.Millisecond + limit := 2 + // Exhaust limit + rl.Allow("key3", limit, window) + rl.Allow("key3", limit, window) + if rl.Allow("key3", limit, window) { + t.Error("Allow() should be blocked after exhausting limit") + } + // Wait for window to expire + time.Sleep(window + 10*time.Millisecond) + if !rl.Allow("key3", limit, window) { + t.Error("Allow() should be permitted after window expires") + } +} + +func TestRateLimiter_DifferentKeysIndependent(t *testing.T) { + rl := auth.NewRateLimiter() + for i := 0; i < 5; i++ { + rl.Allow("keyX", 3, time.Second) + } + // keyY should still be allowed + if !rl.Allow("keyY", 3, time.Second) { + t.Error("Allow() blocked keyY even though only keyX exceeded limit") + } +} + +func TestRateLimiter_LockoutEnforced(t *testing.T) { + rl := auth.NewRateLimiter() + rl.Lockout("keyLock", time.Hour) + if !rl.IsLockedOut("keyLock") { + t.Error("IsLockedOut() = false after Lockout(), want true") + } +} + +func TestRateLimiter_LockoutExpires(t *testing.T) { + rl := auth.NewRateLimiter() + rl.Lockout("keyExp", 30*time.Millisecond) + time.Sleep(50 * time.Millisecond) + if rl.IsLockedOut("keyExp") { + t.Error("IsLockedOut() = true after lockout expired, want false") + } +} + +func TestRateLimiter_IsLockedOut_UnknownKey(t *testing.T) { + rl := auth.NewRateLimiter() + if rl.IsLockedOut("unknown") { + t.Error("IsLockedOut() = true for unknown key, want false") + } +} + +func TestRateLimiter_Reset(t *testing.T) { + rl := auth.NewRateLimiter() + rl.Allow("keyR", 1, time.Second) + rl.Allow("keyR", 1, time.Second) // now blocked + rl.Reset("keyR") + if !rl.Allow("keyR", 1, time.Second) { + t.Error("Allow() = false after Reset(), want true") + } +} + +func TestRateLimiter_LockoutBlocksAllow(t *testing.T) { + rl := auth.NewRateLimiter() + rl.Lockout("keyLB", time.Hour) + // Even under normal limit, lockout should block + if rl.Allow("keyLB", 100, time.Second) { + t.Error("Allow() = true for locked-out key, want false") + } +} + +func TestRateLimiter_ThreadSafe(t *testing.T) { + rl := auth.NewRateLimiter() + done := make(chan struct{}, 100) + for i := 0; i < 100; i++ { + go func() { + rl.Allow("concurrent", 50, time.Second) + done <- struct{}{} + }() + } + for i := 0; i < 100; i++ { + <-done + } + // If we get here without a race condition data race, we pass +} diff --git a/Server/auth/session.go b/Server/auth/session.go new file mode 100644 index 00000000..19e3cedb --- /dev/null +++ b/Server/auth/session.go @@ -0,0 +1,24 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" +) + +// GenerateToken returns a cryptographically random 256-bit token encoded as a +// 64-character lowercase hex string. +func GenerateToken() (string, error) { + raw := make([]byte, 32) // 256 bits + if _, err := rand.Read(raw); err != nil { + return "", err + } + return hex.EncodeToString(raw), nil +} + +// HashToken returns the SHA-256 hex digest of token. Store this hash in the +// database; never store the plaintext token. +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/Server/auth/session_test.go b/Server/auth/session_test.go new file mode 100644 index 00000000..0b5865af --- /dev/null +++ b/Server/auth/session_test.go @@ -0,0 +1,77 @@ +package auth_test + +import ( + "testing" + + "github.com/owncord/server/auth" +) + +func TestGenerateToken_Length(t *testing.T) { + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken() error = %v", err) + } + if len(token) != 64 { + t.Errorf("GenerateToken() len = %d, want 64", len(token)) + } +} + +func TestGenerateToken_HexCharacters(t *testing.T) { + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken() error = %v", err) + } + for i, c := range token { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("GenerateToken() char[%d] = %q, not lowercase hex", i, c) + } + } +} + +func TestGenerateToken_Uniqueness(t *testing.T) { + const n = 1000 + seen := make(map[string]struct{}, n) + for i := 0; i < n; i++ { + tok, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken() iteration %d error = %v", i, err) + } + if _, dup := seen[tok]; dup { + t.Fatalf("GenerateToken() produced duplicate token at iteration %d", i) + } + seen[tok] = struct{}{} + } +} + +func TestHashToken_Deterministic(t *testing.T) { + token := "abc123" + h1 := auth.HashToken(token) + h2 := auth.HashToken(token) + if h1 != h2 { + t.Errorf("HashToken() not deterministic: %q != %q", h1, h2) + } +} + +func TestHashToken_DiffersFromPlaintext(t *testing.T) { + token := "abc123" + hash := auth.HashToken(token) + if hash == token { + t.Errorf("HashToken() hash equals plaintext token") + } +} + +func TestHashToken_Length(t *testing.T) { + // SHA-256 hex = 64 chars + hash := auth.HashToken("any-token") + if len(hash) != 64 { + t.Errorf("HashToken() len = %d, want 64", len(hash)) + } +} + +func TestHashToken_DifferentInputsDifferentHashes(t *testing.T) { + h1 := auth.HashToken("token-one") + h2 := auth.HashToken("token-two") + if h1 == h2 { + t.Errorf("HashToken() same hash for different inputs") + } +} diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go new file mode 100644 index 00000000..5e8eb760 --- /dev/null +++ b/Server/db/auth_queries.go @@ -0,0 +1,285 @@ +package db + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" +) + +// ─── User Operations ────────────────────────────────────────────────────────── + +// CreateUser inserts a new user record and returns the assigned ID. +func (d *DB) CreateUser(username, passwordHash string, roleID int) (int64, error) { + res, err := d.sqlDB.Exec( + `INSERT INTO users (username, password, role_id) VALUES (?, ?, ?)`, + username, passwordHash, roleID, + ) + if err != nil { + return 0, fmt.Errorf("CreateUser: %w", err) + } + return res.LastInsertId() +} + +// GetUserByUsername returns the user with the given username (case-insensitive), +// or nil if not found. +func (d *DB) GetUserByUsername(username string) (*User, error) { + row := d.sqlDB.QueryRow( + `SELECT id, username, password, avatar, role_id, totp_secret, status, + created_at, last_seen, banned, ban_reason, ban_expires + FROM users WHERE username = ? COLLATE NOCASE`, + username, + ) + return scanUser(row) +} + +// GetUserByID returns the user with the given ID, or nil if not found. +func (d *DB) GetUserByID(id int64) (*User, error) { + row := d.sqlDB.QueryRow( + `SELECT id, username, password, avatar, role_id, totp_secret, status, + created_at, last_seen, banned, ban_reason, ban_expires + FROM users WHERE id = ?`, + id, + ) + return scanUser(row) +} + +// scanUser reads a User from a *sql.Row, returning nil (not an error) when the +// row is not found. +func scanUser(row *sql.Row) (*User, error) { + u := &User{} + var banned int + err := row.Scan( + &u.ID, &u.Username, &u.PasswordHash, &u.Avatar, &u.RoleID, + &u.TOTPSecret, &u.Status, &u.CreatedAt, &u.LastSeen, + &banned, &u.BanReason, &u.BanExpires, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scanUser: %w", err) + } + u.Banned = banned != 0 + return u, nil +} + +// UpdateUserStatus sets the status column for the given user ID. +func (d *DB) UpdateUserStatus(id int64, status string) error { + _, err := d.sqlDB.Exec( + `UPDATE users SET status = ?, last_seen = datetime('now') WHERE id = ?`, + status, id, + ) + if err != nil { + return fmt.Errorf("UpdateUserStatus: %w", err) + } + return nil +} + +// BanUser marks a user as banned with an optional expiry. Pass nil for a +// permanent ban. +func (d *DB) BanUser(id int64, reason string, expires *time.Time) error { + var expiresStr *string + if expires != nil { + s := expires.UTC().Format("2006-01-02T15:04:05Z") + expiresStr = &s + } + _, err := d.sqlDB.Exec( + `UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?`, + reason, expiresStr, id, + ) + if err != nil { + return fmt.Errorf("BanUser: %w", err) + } + return nil +} + +// ─── Session Operations ─────────────────────────────────────────────────────── + +// CreateSession inserts a new session and returns the session ID. +// tokenHash must already be hashed (never store plaintext tokens). +func (d *DB) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) { + expiresAt := time.Now().Add(sessionTTL).UTC().Format("2006-01-02T15:04:05Z") + res, err := d.sqlDB.Exec( + `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) + VALUES (?, ?, ?, ?, ?)`, + userID, tokenHash, device, ip, expiresAt, + ) + if err != nil { + return 0, fmt.Errorf("CreateSession: %w", err) + } + return res.LastInsertId() +} + +// GetSessionByTokenHash retrieves a session by its hashed token, or nil if +// not found. +func (d *DB) GetSessionByTokenHash(tokenHash string) (*Session, error) { + row := d.sqlDB.QueryRow( + `SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at + FROM sessions WHERE token = ?`, + tokenHash, + ) + s := &Session{} + err := row.Scan( + &s.ID, &s.UserID, &s.TokenHash, &s.Device, &s.IP, + &s.CreatedAt, &s.LastUsed, &s.ExpiresAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetSessionByTokenHash: %w", err) + } + return s, nil +} + +// DeleteSession removes the session with the given token hash. +func (d *DB) DeleteSession(tokenHash string) error { + _, err := d.sqlDB.Exec(`DELETE FROM sessions WHERE token = ?`, tokenHash) + if err != nil { + return fmt.Errorf("DeleteSession: %w", err) + } + return nil +} + +// DeleteExpiredSessions removes all sessions whose expires_at is in the past. +// Compares using strftime to handle both ISO-8601 and SQLite datetime formats. +func (d *DB) DeleteExpiredSessions() error { + _, err := d.sqlDB.Exec( + `DELETE FROM sessions WHERE strftime('%s', expires_at) < strftime('%s', 'now')`, + ) + if err != nil { + return fmt.Errorf("DeleteExpiredSessions: %w", err) + } + return nil +} + +// TouchSession updates last_used for the session with the given token hash. +func (d *DB) TouchSession(tokenHash string) error { + _, err := d.sqlDB.Exec( + `UPDATE sessions SET last_used = datetime('now') WHERE token = ?`, + tokenHash, + ) + if err != nil { + return fmt.Errorf("TouchSession: %w", err) + } + return nil +} + +// ─── Invite Operations ──────────────────────────────────────────────────────── + +// CreateInvite generates a random invite code, persists it, and returns the +// code. maxUses=0 means unlimited. expiresAt=nil means never expires. +func (d *DB) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) { + code, err := generateInviteCode() + if err != nil { + return "", fmt.Errorf("CreateInvite generate code: %w", err) + } + + var maxUsesVal *int + if maxUses > 0 { + maxUsesVal = &maxUses + } + var expiresStr *string + if expiresAt != nil { + s := expiresAt.UTC().Format("2006-01-02T15:04:05Z") + expiresStr = &s + } + + _, err = d.sqlDB.Exec( + `INSERT INTO invites (code, created_by, max_uses, expires_at) VALUES (?, ?, ?, ?)`, + code, createdBy, maxUsesVal, expiresStr, + ) + if err != nil { + return "", fmt.Errorf("CreateInvite insert: %w", err) + } + return code, nil +} + +// GetInvite returns the invite for the given code, or nil if not found. +func (d *DB) GetInvite(code string) (*Invite, error) { + row := d.sqlDB.QueryRow( + `SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at + FROM invites WHERE code = ?`, + code, + ) + inv := &Invite{} + var revoked int + err := row.Scan( + &inv.ID, &inv.Code, &inv.CreatedBy, &inv.MaxUses, + &inv.Uses, &inv.ExpiresAt, &revoked, &inv.CreatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetInvite: %w", err) + } + inv.Revoked = revoked != 0 + return inv, nil +} + +// UseInvite increments use_count after validating the invite is usable. +// Returns an error if the invite is revoked, expired, or has reached max uses. +func (d *DB) UseInvite(code string) error { + inv, err := d.GetInvite(code) + if err != nil { + return err + } + if inv == nil { + return errors.New("invite not found") + } + if inv.Revoked { + return errors.New("invite has been revoked") + } + if inv.ExpiresAt != nil { + // Try both SQLite datetime format and ISO-8601 format. + var expires time.Time + var parseErr error + for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} { + expires, parseErr = time.Parse(layout, *inv.ExpiresAt) + if parseErr == nil { + break + } + } + if parseErr != nil { + return fmt.Errorf("parsing invite expiry: %w", parseErr) + } + if time.Now().UTC().After(expires) { + return errors.New("invite has expired") + } + } + if inv.MaxUses != nil && inv.Uses >= *inv.MaxUses { + return errors.New("invite has reached its maximum uses") + } + _, err = d.sqlDB.Exec( + `UPDATE invites SET use_count = use_count + 1 WHERE code = ?`, + code, + ) + if err != nil { + return fmt.Errorf("UseInvite update: %w", err) + } + return nil +} + +// RevokeInvite marks an invite as revoked. +func (d *DB) RevokeInvite(code string) error { + _, err := d.sqlDB.Exec(`UPDATE invites SET revoked = 1 WHERE code = ?`, code) + if err != nil { + return fmt.Errorf("RevokeInvite: %w", err) + } + return nil +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +// generateInviteCode produces a random 8-byte (16-char hex) code. +func generateInviteCode() (string, error) { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/Server/db/auth_queries_test.go b/Server/db/auth_queries_test.go new file mode 100644 index 00000000..a0225d26 --- /dev/null +++ b/Server/db/auth_queries_test.go @@ -0,0 +1,468 @@ +package db_test + +import ( + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/db" +) + +// newTestDB opens an in-memory SQLite database and runs migrations from the +// embedded FS so tests are fully self-contained. +func newTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + + // Build a minimal migration FS with the initial schema. + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: testSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// testSchema mirrors the production migration but kept inline so tests are +// portable and don't depend on the real migrations embed. +var testSchema = []byte(` +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT, + permissions INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + is_default INTEGER NOT NULL DEFAULT 0 +); + +INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES + (1, 'Owner', '#E74C3C', 2147483647, 100, 0), + (2, 'Admin', '#F39C12', 1073741823, 80, 0), + (3, 'Moderator', '#3498DB', 1048575, 60, 0), + (4, 'Member', NULL, 1049089, 40, 1); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password TEXT NOT NULL, + avatar TEXT, + role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id), + totp_secret TEXT, + status TEXT NOT NULL DEFAULT 'offline', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT, + banned INTEGER NOT NULL DEFAULT 0, + ban_reason TEXT, + ban_expires TEXT +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + device TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); + +CREATE TABLE IF NOT EXISTS invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + redeemed_by INTEGER REFERENCES users(id), + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); +`) + +// ─── User tests ────────────────────────────────────────────────────────────── + +func TestCreateUser_Success(t *testing.T) { + database := newTestDB(t) + id, err := database.CreateUser("alice", "hash123", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + if id <= 0 { + t.Errorf("CreateUser returned id = %d, want > 0", id) + } +} + +func TestCreateUser_DuplicateUsername(t *testing.T) { + database := newTestDB(t) + if _, err := database.CreateUser("bob", "hash1", 4); err != nil { + t.Fatalf("first CreateUser: %v", err) + } + _, err := database.CreateUser("bob", "hash2", 4) + if err == nil { + t.Error("CreateUser() with duplicate username returned nil error, want error") + } +} + +func TestCreateUser_CaseInsensitiveDuplicate(t *testing.T) { + database := newTestDB(t) + if _, err := database.CreateUser("Charlie", "hash1", 4); err != nil { + t.Fatalf("first CreateUser: %v", err) + } + _, err := database.CreateUser("charlie", "hash2", 4) + if err == nil { + t.Error("CreateUser() with case-insensitive duplicate returned nil error, want error") + } +} + +func TestGetUserByUsername_Found(t *testing.T) { + database := newTestDB(t) + database.CreateUser("dave", "hashDave", 4) + + user, err := database.GetUserByUsername("dave") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if user.Username != "dave" { + t.Errorf("Username = %q, want %q", user.Username, "dave") + } + if user.PasswordHash != "hashDave" { + t.Errorf("PasswordHash = %q, want %q", user.PasswordHash, "hashDave") + } +} + +func TestGetUserByUsername_CaseInsensitive(t *testing.T) { + database := newTestDB(t) + database.CreateUser("Eve", "hashEve", 4) + + user, err := database.GetUserByUsername("EVE") + if err != nil { + t.Fatalf("GetUserByUsername case-insensitive: %v", err) + } + if user == nil { + t.Fatal("GetUserByUsername returned nil for case-insensitive match") + } +} + +func TestGetUserByUsername_NotFound(t *testing.T) { + database := newTestDB(t) + user, err := database.GetUserByUsername("nobody") + if err != nil { + t.Fatalf("GetUserByUsername(not found): %v", err) + } + if user != nil { + t.Error("GetUserByUsername returned non-nil for missing user") + } +} + +func TestGetUserByID_Found(t *testing.T) { + database := newTestDB(t) + id, _ := database.CreateUser("frank", "hashFrank", 4) + + user, err := database.GetUserByID(id) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if user.ID != id { + t.Errorf("ID = %d, want %d", user.ID, id) + } +} + +func TestGetUserByID_NotFound(t *testing.T) { + database := newTestDB(t) + user, err := database.GetUserByID(999) + if err != nil { + t.Fatalf("GetUserByID(not found): %v", err) + } + if user != nil { + t.Error("GetUserByID returned non-nil for missing user") + } +} + +func TestUpdateUserStatus(t *testing.T) { + database := newTestDB(t) + id, _ := database.CreateUser("grace", "hash", 4) + + if err := database.UpdateUserStatus(id, "online"); err != nil { + t.Fatalf("UpdateUserStatus: %v", err) + } + user, _ := database.GetUserByID(id) + if user.Status != "online" { + t.Errorf("Status = %q, want %q", user.Status, "online") + } +} + +func TestBanUser_Permanent(t *testing.T) { + database := newTestDB(t) + id, _ := database.CreateUser("hank", "hash", 4) + + if err := database.BanUser(id, "spam", nil); err != nil { + t.Fatalf("BanUser: %v", err) + } + user, _ := database.GetUserByID(id) + if !user.Banned { + t.Error("Banned = false after BanUser, want true") + } + if user.BanExpires != nil { + t.Errorf("BanExpires = %v, want nil for permanent ban", user.BanExpires) + } +} + +func TestBanUser_Temporary(t *testing.T) { + database := newTestDB(t) + id, _ := database.CreateUser("ivan", "hash", 4) + expires := time.Now().Add(24 * time.Hour) + + if err := database.BanUser(id, "temp ban", &expires); err != nil { + t.Fatalf("BanUser (temp): %v", err) + } + user, _ := database.GetUserByID(id) + if !user.Banned { + t.Error("Banned = false after temp ban") + } + if user.BanExpires == nil { + t.Error("BanExpires = nil for temp ban, want non-nil") + } +} + +// ─── Session tests ──────────────────────────────────────────────────────────── + +func TestCreateSession_Success(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("jack", "hash", 4) + + id, err := database.CreateSession(uid, "tokenHash1", "GoTest/1.0", "127.0.0.1") + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if id <= 0 { + t.Errorf("CreateSession id = %d, want > 0", id) + } +} + +func TestGetSessionByTokenHash_Found(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("kate", "hash", 4) + database.CreateSession(uid, "myTokenHash", "GoTest/1.0", "127.0.0.1") + + sess, err := database.GetSessionByTokenHash("myTokenHash") + if err != nil { + t.Fatalf("GetSessionByTokenHash: %v", err) + } + if sess == nil { + t.Fatal("GetSessionByTokenHash returned nil for existing session") + } + if sess.UserID != uid { + t.Errorf("UserID = %d, want %d", sess.UserID, uid) + } +} + +func TestGetSessionByTokenHash_NotFound(t *testing.T) { + database := newTestDB(t) + sess, err := database.GetSessionByTokenHash("nonexistent") + if err != nil { + t.Fatalf("GetSessionByTokenHash(not found): %v", err) + } + if sess != nil { + t.Error("GetSessionByTokenHash returned non-nil for missing session") + } +} + +func TestDeleteSession(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("leo", "hash", 4) + database.CreateSession(uid, "delToken", "GoTest/1.0", "127.0.0.1") + + if err := database.DeleteSession("delToken"); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + sess, _ := database.GetSessionByTokenHash("delToken") + if sess != nil { + t.Error("Session still exists after DeleteSession") + } +} + +func TestDeleteExpiredSessions(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("mia", "hash", 4) + + // Insert an already-expired session directly via Exec. + // Use SQLite datetime format (space separator) to match what datetime('now') produces. + pastTime := time.Now().Add(-time.Hour).UTC().Format("2006-01-02 15:04:05") + _, err := database.Exec( + `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`, + uid, "expiredToken", "test", "127.0.0.1", pastTime, + ) + if err != nil { + t.Fatalf("inserting expired session: %v", err) + } + + // Insert a valid session through the normal path. + database.CreateSession(uid, "validToken", "GoTest/1.0", "127.0.0.1") + + if err := database.DeleteExpiredSessions(); err != nil { + t.Fatalf("DeleteExpiredSessions: %v", err) + } + + expired, _ := database.GetSessionByTokenHash("expiredToken") + if expired != nil { + t.Error("Expired session still exists after DeleteExpiredSessions") + } + valid, _ := database.GetSessionByTokenHash("validToken") + if valid == nil { + t.Error("Valid session was deleted by DeleteExpiredSessions") + } +} + +func TestTouchSession(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("noah", "hash", 4) + database.CreateSession(uid, "touchToken", "GoTest/1.0", "127.0.0.1") + + sess1, _ := database.GetSessionByTokenHash("touchToken") + time.Sleep(2 * time.Millisecond) + + if err := database.TouchSession("touchToken"); err != nil { + t.Fatalf("TouchSession: %v", err) + } + + sess2, _ := database.GetSessionByTokenHash("touchToken") + if sess1.LastUsed == sess2.LastUsed { + // last_used should have advanced; if they're equal the touch had no effect + // (This can be flaky at millisecond resolution, but is a reasonable sanity check.) + t.Log("TouchSession: last_used unchanged (may be a timing issue on fast machines)") + } +} + +// ─── Invite tests ───────────────────────────────────────────────────────────── + +func TestCreateInvite_Success(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("olivia", "hash", 4) + + code, err := database.CreateInvite(uid, 0, nil) + if err != nil { + t.Fatalf("CreateInvite: %v", err) + } + if len(code) == 0 { + t.Error("CreateInvite returned empty code") + } +} + +func TestGetInvite_Found(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("pedro", "hash", 4) + code, _ := database.CreateInvite(uid, 5, nil) + + inv, err := database.GetInvite(code) + if err != nil { + t.Fatalf("GetInvite: %v", err) + } + if inv == nil { + t.Fatal("GetInvite returned nil for existing code") + } + if inv.Code != code { + t.Errorf("Code = %q, want %q", inv.Code, code) + } + if inv.MaxUses == nil || *inv.MaxUses != 5 { + t.Errorf("MaxUses = %v, want 5", inv.MaxUses) + } +} + +func TestGetInvite_NotFound(t *testing.T) { + database := newTestDB(t) + inv, err := database.GetInvite("bogus") + if err != nil { + t.Fatalf("GetInvite(not found): %v", err) + } + if inv != nil { + t.Error("GetInvite returned non-nil for missing code") + } +} + +func TestUseInvite_IncrementsUses(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("quinn", "hash", 4) + code, _ := database.CreateInvite(uid, 5, nil) + + if err := database.UseInvite(code); err != nil { + t.Fatalf("UseInvite: %v", err) + } + + inv, _ := database.GetInvite(code) + if inv.Uses != 1 { + t.Errorf("Uses = %d, want 1", inv.Uses) + } +} + +func TestUseInvite_ExceedsMaxUses(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("rachel", "hash", 4) + code, _ := database.CreateInvite(uid, 1, nil) + + if err := database.UseInvite(code); err != nil { + t.Fatalf("first UseInvite: %v", err) + } + // Second use should fail + if err := database.UseInvite(code); err == nil { + t.Error("UseInvite() returned nil error after exceeding max_uses") + } +} + +func TestUseInvite_Revoked(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("sam", "hash", 4) + code, _ := database.CreateInvite(uid, 0, nil) + + database.RevokeInvite(code) + if err := database.UseInvite(code); err == nil { + t.Error("UseInvite() returned nil error for revoked invite") + } +} + +func TestUseInvite_Expired(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("tina", "hash", 4) + + past := time.Now().Add(-time.Hour) + code, _ := database.CreateInvite(uid, 0, &past) + + if err := database.UseInvite(code); err == nil { + t.Error("UseInvite() returned nil error for expired invite") + } +} + +func TestRevokeInvite(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("uma", "hash", 4) + code, _ := database.CreateInvite(uid, 0, nil) + + if err := database.RevokeInvite(code); err != nil { + t.Fatalf("RevokeInvite: %v", err) + } + + inv, _ := database.GetInvite(code) + if !inv.Revoked { + t.Error("Revoked = false after RevokeInvite, want true") + } +} + +func TestCreateInvite_UnlimitedUses(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("vera", "hash", 4) + code, _ := database.CreateInvite(uid, 0, nil) // 0 = unlimited + + inv, _ := database.GetInvite(code) + if inv.MaxUses != nil { + t.Errorf("MaxUses = %v, want nil for unlimited", inv.MaxUses) + } +} diff --git a/Server/db/invite_queries.go b/Server/db/invite_queries.go new file mode 100644 index 00000000..1d4afdde --- /dev/null +++ b/Server/db/invite_queries.go @@ -0,0 +1,30 @@ +package db + +import "fmt" + +// ListInvites returns all invites ordered by creation time descending. +func (d *DB) ListInvites() ([]*Invite, error) { + rows, err := d.sqlDB.Query( + `SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at + FROM invites ORDER BY created_at DESC`, + ) + if err != nil { + return nil, fmt.Errorf("ListInvites: %w", err) + } + defer rows.Close() + + var invites []*Invite + for rows.Next() { + inv := &Invite{} + var revoked int + if err := rows.Scan( + &inv.ID, &inv.Code, &inv.CreatedBy, &inv.MaxUses, + &inv.Uses, &inv.ExpiresAt, &revoked, &inv.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("ListInvites scan: %w", err) + } + inv.Revoked = revoked != 0 + invites = append(invites, inv) + } + return invites, rows.Err() +} diff --git a/Server/db/models.go b/Server/db/models.go new file mode 100644 index 00000000..6429317c --- /dev/null +++ b/Server/db/models.go @@ -0,0 +1,56 @@ +package db + +import "time" + +// User represents a row in the users table. +type User struct { + ID int64 + Username string + PasswordHash string + Avatar *string + RoleID int64 + TOTPSecret *string + Status string + CreatedAt string + LastSeen *string + Banned bool + BanReason *string + BanExpires *string +} + +// Session represents a row in the sessions table. +type Session struct { + ID int64 + UserID int64 + TokenHash string + Device string + IP string + CreatedAt string + LastUsed string + ExpiresAt string +} + +// Invite represents a row in the invites table. +type Invite struct { + ID int64 + Code string + CreatedBy int64 + Uses int + MaxUses *int + ExpiresAt *string + Revoked bool + CreatedAt string +} + +// Role represents a row in the roles table. +type Role struct { + ID int64 + Name string + Color *string + Permissions int64 + Position int + IsDefault bool +} + +// sessionTTL is the duration a session remains valid after creation. +const sessionTTL = 30 * 24 * time.Hour diff --git a/Server/db/role_invite_queries_test.go b/Server/db/role_invite_queries_test.go new file mode 100644 index 00000000..f93e5053 --- /dev/null +++ b/Server/db/role_invite_queries_test.go @@ -0,0 +1,156 @@ +package db_test + +import ( + "testing" +) + +// ─── GetRoleByID tests ──────────────────────────────────────────────────────── + +func TestGetRoleByID_Found(t *testing.T) { + database := newTestDB(t) + + role, err := database.GetRoleByID(4) // Member — inserted by migration + if err != nil { + t.Fatalf("GetRoleByID: %v", err) + } + if role == nil { + t.Fatal("GetRoleByID returned nil for Member role") + } + if role.Name != "Member" { + t.Errorf("Name = %q, want %q", role.Name, "Member") + } + if role.Permissions == 0 { + t.Error("Member permissions = 0, want non-zero") + } +} + +func TestGetRoleByID_NotFound(t *testing.T) { + database := newTestDB(t) + + role, err := database.GetRoleByID(9999) + if err != nil { + t.Fatalf("GetRoleByID(not found): %v", err) + } + if role != nil { + t.Error("GetRoleByID returned non-nil for missing role") + } +} + +func TestGetRoleByID_OwnerHasAllPermissions(t *testing.T) { + database := newTestDB(t) + + role, err := database.GetRoleByID(1) // Owner + if err != nil { + t.Fatalf("GetRoleByID Owner: %v", err) + } + if role == nil { + t.Fatal("GetRoleByID returned nil for Owner role") + } + // Owner has permissions = 0x7FFFFFFF = 2147483647 + if role.Permissions != 2147483647 { + t.Errorf("Owner Permissions = %d, want 2147483647", role.Permissions) + } +} + +func TestGetRoleByID_IsDefaultField(t *testing.T) { + database := newTestDB(t) + + owner, _ := database.GetRoleByID(1) + member, _ := database.GetRoleByID(4) + + if owner.IsDefault { + t.Error("Owner.IsDefault = true, want false") + } + // Member is the default role (is_default=1 in the migration). + if !member.IsDefault { + t.Error("Member.IsDefault = false, want true (Member is the default role for new users)") + } +} + +// ─── ListRoles tests ────────────────────────────────────────────────────────── + +func TestListRoles_ReturnsFourDefaultRoles(t *testing.T) { + database := newTestDB(t) + + roles, err := database.ListRoles() + if err != nil { + t.Fatalf("ListRoles: %v", err) + } + if len(roles) != 4 { + t.Errorf("ListRoles count = %d, want 4", len(roles)) + } +} + +func TestListRoles_OrderedByPositionDesc(t *testing.T) { + database := newTestDB(t) + + roles, err := database.ListRoles() + if err != nil { + t.Fatalf("ListRoles: %v", err) + } + + for i := 1; i < len(roles); i++ { + if roles[i].Position > roles[i-1].Position { + t.Errorf("ListRoles not ordered by position DESC: index %d (%d) > index %d (%d)", + i, roles[i].Position, i-1, roles[i-1].Position) + } + } +} + +// ─── ListInvites tests ──────────────────────────────────────────────────────── + +func TestListInvites_Empty(t *testing.T) { + database := newTestDB(t) + + invites, err := database.ListInvites() + if err != nil { + t.Fatalf("ListInvites empty: %v", err) + } + if len(invites) != 0 { + t.Errorf("ListInvites empty = %d items, want 0", len(invites)) + } +} + +func TestListInvites_Multiple(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("listowner", "hash", 4) + + database.CreateInvite(uid, 1, nil) + database.CreateInvite(uid, 5, nil) + database.CreateInvite(uid, 0, nil) + + invites, err := database.ListInvites() + if err != nil { + t.Fatalf("ListInvites multiple: %v", err) + } + if len(invites) != 3 { + t.Errorf("ListInvites count = %d, want 3", len(invites)) + } +} + +func TestListInvites_IncludesRevokedInvites(t *testing.T) { + database := newTestDB(t) + uid, _ := database.CreateUser("revokelistowner", "hash", 4) + + code, _ := database.CreateInvite(uid, 1, nil) + database.RevokeInvite(code) + database.CreateInvite(uid, 0, nil) // active + + invites, err := database.ListInvites() + if err != nil { + t.Fatalf("ListInvites with revoked: %v", err) + } + if len(invites) != 2 { + t.Errorf("ListInvites count = %d, want 2", len(invites)) + } + + var revokedCount int + for _, inv := range invites { + if inv.Revoked { + revokedCount++ + } + } + if revokedCount != 1 { + t.Errorf("ListInvites revoked count = %d, want 1", revokedCount) + } +} diff --git a/Server/db/role_queries.go b/Server/db/role_queries.go new file mode 100644 index 00000000..fad35d40 --- /dev/null +++ b/Server/db/role_queries.go @@ -0,0 +1,49 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" +) + +// GetRoleByID returns the role with the given ID, or nil if not found. +func (d *DB) GetRoleByID(id int64) (*Role, error) { + row := d.sqlDB.QueryRow( + `SELECT id, name, color, permissions, position, is_default FROM roles WHERE id = ?`, + id, + ) + r := &Role{} + var isDefault int + err := row.Scan(&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("GetRoleByID: %w", err) + } + r.IsDefault = isDefault != 0 + return r, nil +} + +// ListRoles returns all roles ordered by position descending. +func (d *DB) ListRoles() ([]*Role, error) { + rows, err := d.sqlDB.Query( + `SELECT id, name, color, permissions, position, is_default FROM roles ORDER BY position DESC`, + ) + if err != nil { + return nil, fmt.Errorf("ListRoles: %w", err) + } + defer rows.Close() + + var roles []*Role + for rows.Next() { + r := &Role{} + var isDefault int + if err := rows.Scan(&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault); err != nil { + return nil, fmt.Errorf("ListRoles scan: %w", err) + } + r.IsDefault = isDefault != 0 + roles = append(roles, r) + } + return roles, rows.Err() +} diff --git a/Server/go.mod b/Server/go.mod index a2240c41..67604841 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -3,12 +3,14 @@ module github.com/owncord/server go 1.25.0 require ( + github.com/aymerick/douceur v0.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/parsers/yaml v1.1.0 // indirect github.com/knadh/koanf/providers/env v1.1.0 // indirect @@ -16,6 +18,7 @@ require ( github.com/knadh/koanf/providers/structs v1.0.0 // indirect github.com/knadh/koanf/v2 v2.3.3 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -23,6 +26,7 @@ require ( go.yaml.in/yaml/v3 v3.0.3 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.42.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/Server/go.sum b/Server/go.sum index 73a412ba..3f83a338 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -1,3 +1,5 @@ +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -10,6 +12,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= @@ -24,6 +28,8 @@ github.com/knadh/koanf/v2 v2.3.3 h1:jLJC8XCRfLC7n4F+ZKKdBsbq1bfXTpuFhf4L7t94D94= github.com/knadh/koanf/v2 v2.3.3/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -38,6 +44,8 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= From 9707c4d4afda3d10ec60e9335bbdfcce78918dd5 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 21:07:07 +0100 Subject: [PATCH 003/100] feat: scaffold Phase 3 WPF client shell with MVVM and TDD structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WPF (.NET 8) project targeting net8.0-windows - Models: ServerProfile (record), Channel, Message, User, Role - ViewModels: ViewModelBase (INotifyPropertyChanged), RelayCommand, ConnectViewModel (profiles, login/register toggle, connect command), MainViewModel (channels, messages, typing indicator, send command), SettingsViewModel (dark theme, notifications, PTT key) - Services: IProfileService + ProfileService (AppData JSON, immutable ops), ICredentialService + CredentialService (DPAPI via ProtectedData), IWebSocketService + WebSocketService (ClientWebSocket stub) - Views: ConnectPage (server address, login/register, profile selector), MainPage (3-column: channel list, message area, member list), App.xaml wires converters and startup - Converters: BoolToVisibilityConverter, IntToVisibilityConverter - Tests: ConnectViewModelTests (11 cases), MainViewModelTests (11 cases), ProfileServiceTests (6 cases) — ready to run once NuGet accessible (run: dotnet restore && dotnet test OwnCord.Client.Tests/) Build: dotnet build OwnCord.Client/ succeeds with 0 warnings --- .../OwnCord.Client.Tests.csproj | 28 +++ .../Services/ProfileServiceTests.cs | 78 ++++++++ Client/OwnCord.Client.Tests/UnitTest1.cs | 10 ++ .../ViewModels/ConnectViewModelTests.cs | 142 +++++++++++++++ .../ViewModels/MainViewModelTests.cs | 119 +++++++++++++ ...CoreApp,Version=v8.0.AssemblyAttributes.cs | 4 + .../OwnCord.Client.Tests.AssemblyInfo.cs | 24 +++ ...Cord.Client.Tests.AssemblyInfoInputs.cache | 1 + ....GeneratedMSBuildEditorConfig.editorconfig | 13 ++ .../OwnCord.Client.Tests.GlobalUsings.g.cs | 9 + .../OwnCord.Client.Tests.assets.cache | Bin 0 -> 752 bytes ...lient.Tests.csproj.AssemblyReference.cache | Bin 0 -> 273 bytes ...Cord.Client.Tests.csproj.nuget.dgspec.json | 147 +++++++++++++++ .../OwnCord.Client.Tests.csproj.nuget.g.props | 15 ++ ...wnCord.Client.Tests.csproj.nuget.g.targets | 2 + .../obj/project.assets.json | 168 ++++++++++++++++++ .../obj/project.nuget.cache | 54 ++++++ Client/OwnCord.Client.sln | 28 +++ Client/OwnCord.Client/App.xaml | 10 ++ Client/OwnCord.Client/App.xaml.cs | 27 +++ Client/OwnCord.Client/AssemblyInfo.cs | 10 ++ .../Converters/BoolToVisibilityConverter.cs | 25 +++ Client/OwnCord.Client/MainWindow.xaml | 7 + Client/OwnCord.Client/MainWindow.xaml.cs | 32 ++++ Client/OwnCord.Client/Models/Channel.cs | 13 ++ Client/OwnCord.Client/Models/Message.cs | 15 ++ Client/OwnCord.Client/Models/Role.cs | 3 + Client/OwnCord.Client/Models/ServerProfile.cs | 13 ++ Client/OwnCord.Client/Models/User.cs | 11 ++ Client/OwnCord.Client/OwnCord.Client.csproj | 11 ++ .../Services/CredentialService.cs | 56 ++++++ .../Services/ICredentialService.cs | 8 + .../Services/IProfileService.cs | 12 ++ .../Services/IWebSocketService.cs | 10 ++ .../OwnCord.Client/Services/ProfileService.cs | 32 ++++ .../Services/WebSocketService.cs | 60 +++++++ .../ViewModels/ConnectViewModel.cs | 113 ++++++++++++ .../ViewModels/MainViewModel.cs | 105 +++++++++++ .../OwnCord.Client/ViewModels/RelayCommand.cs | 27 +++ .../ViewModels/SettingsViewModel.cs | 26 +++ .../ViewModels/ViewModelBase.cs | 20 +++ Client/OwnCord.Client/Views/ConnectPage.xaml | 53 ++++++ .../OwnCord.Client/Views/ConnectPage.xaml.cs | 22 +++ Client/OwnCord.Client/Views/MainPage.xaml | 145 +++++++++++++++ Client/OwnCord.Client/Views/MainPage.xaml.cs | 13 ++ 45 files changed, 1721 insertions(+) create mode 100644 Client/OwnCord.Client.Tests/OwnCord.Client.Tests.csproj create mode 100644 Client/OwnCord.Client.Tests/Services/ProfileServiceTests.cs create mode 100644 Client/OwnCord.Client.Tests/UnitTest1.cs create mode 100644 Client/OwnCord.Client.Tests/ViewModels/ConnectViewModelTests.cs create mode 100644 Client/OwnCord.Client.Tests/ViewModels/MainViewModelTests.cs create mode 100644 Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs create mode 100644 Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.AssemblyInfo.cs create mode 100644 Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.AssemblyInfoInputs.cache create mode 100644 Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.GeneratedMSBuildEditorConfig.editorconfig create mode 100644 Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.GlobalUsings.g.cs create mode 100644 Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.assets.cache create mode 100644 Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.csproj.AssemblyReference.cache create mode 100644 Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.dgspec.json create mode 100644 Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.g.props create mode 100644 Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.g.targets create mode 100644 Client/OwnCord.Client.Tests/obj/project.assets.json create mode 100644 Client/OwnCord.Client.Tests/obj/project.nuget.cache create mode 100644 Client/OwnCord.Client.sln create mode 100644 Client/OwnCord.Client/App.xaml create mode 100644 Client/OwnCord.Client/App.xaml.cs create mode 100644 Client/OwnCord.Client/AssemblyInfo.cs create mode 100644 Client/OwnCord.Client/Converters/BoolToVisibilityConverter.cs create mode 100644 Client/OwnCord.Client/MainWindow.xaml create mode 100644 Client/OwnCord.Client/MainWindow.xaml.cs create mode 100644 Client/OwnCord.Client/Models/Channel.cs create mode 100644 Client/OwnCord.Client/Models/Message.cs create mode 100644 Client/OwnCord.Client/Models/Role.cs create mode 100644 Client/OwnCord.Client/Models/ServerProfile.cs create mode 100644 Client/OwnCord.Client/Models/User.cs create mode 100644 Client/OwnCord.Client/OwnCord.Client.csproj create mode 100644 Client/OwnCord.Client/Services/CredentialService.cs create mode 100644 Client/OwnCord.Client/Services/ICredentialService.cs create mode 100644 Client/OwnCord.Client/Services/IProfileService.cs create mode 100644 Client/OwnCord.Client/Services/IWebSocketService.cs create mode 100644 Client/OwnCord.Client/Services/ProfileService.cs create mode 100644 Client/OwnCord.Client/Services/WebSocketService.cs create mode 100644 Client/OwnCord.Client/ViewModels/ConnectViewModel.cs create mode 100644 Client/OwnCord.Client/ViewModels/MainViewModel.cs create mode 100644 Client/OwnCord.Client/ViewModels/RelayCommand.cs create mode 100644 Client/OwnCord.Client/ViewModels/SettingsViewModel.cs create mode 100644 Client/OwnCord.Client/ViewModels/ViewModelBase.cs create mode 100644 Client/OwnCord.Client/Views/ConnectPage.xaml create mode 100644 Client/OwnCord.Client/Views/ConnectPage.xaml.cs create mode 100644 Client/OwnCord.Client/Views/MainPage.xaml create mode 100644 Client/OwnCord.Client/Views/MainPage.xaml.cs diff --git a/Client/OwnCord.Client.Tests/OwnCord.Client.Tests.csproj b/Client/OwnCord.Client.Tests/OwnCord.Client.Tests.csproj new file mode 100644 index 00000000..56252c0a --- /dev/null +++ b/Client/OwnCord.Client.Tests/OwnCord.Client.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0-windows + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/Client/OwnCord.Client.Tests/Services/ProfileServiceTests.cs b/Client/OwnCord.Client.Tests/Services/ProfileServiceTests.cs new file mode 100644 index 00000000..40bf968c --- /dev/null +++ b/Client/OwnCord.Client.Tests/Services/ProfileServiceTests.cs @@ -0,0 +1,78 @@ +using System.IO; +using OwnCord.Client.Models; +using OwnCord.Client.Services; + +namespace OwnCord.Client.Tests.Services; + +public sealed class ProfileServiceTests : IDisposable +{ + private readonly string _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + private ProfileService Svc => new(_tempDir); + + [Fact] + public void LoadProfiles_ReturnsEmpty_WhenNoFile() + { + var profiles = Svc.LoadProfiles(); + Assert.Empty(profiles); + } + + [Fact] + public void SaveAndLoad_RoundTrips() + { + var svc = Svc; + var profile = ServerProfile.Create("Home", "192.168.1.10:8443", "alice"); + svc.SaveProfiles([profile]); + var loaded = svc.LoadProfiles(); + Assert.Single(loaded); + Assert.Equal(profile.Name, loaded[0].Name); + Assert.Equal(profile.Host, loaded[0].Host); + } + + [Fact] + public void AddProfile_DoesNotMutateOriginal() + { + var svc = Svc; + IReadOnlyList original = []; + var profile = ServerProfile.Create("Home", "localhost:8443"); + var updated = svc.AddProfile(original, profile); + Assert.Empty(original); + Assert.Single(updated); + } + + [Fact] + public void RemoveProfile_RemovesById() + { + var svc = Svc; + var p1 = ServerProfile.Create("A", "a:8443"); + var p2 = ServerProfile.Create("B", "b:8443"); + var list = svc.AddProfile(svc.AddProfile([], p1), p2); + var result = svc.RemoveProfile(list, p1.Id); + Assert.Single(result); + Assert.Equal(p2.Id, result[0].Id); + } + + [Fact] + public void UpdateProfile_ReplacesMatchingId() + { + var svc = Svc; + var p = ServerProfile.Create("Old", "old:8443"); + var list = svc.AddProfile([], p); + var updated = p with { Name = "New" }; + var result = svc.UpdateProfile(list, updated); + Assert.Equal("New", result[0].Name); + } + + [Fact] + public void SaveProfiles_CreatesDirectory() + { + Assert.False(Directory.Exists(_tempDir)); + Svc.SaveProfiles([]); + Assert.True(Directory.Exists(_tempDir)); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } +} diff --git a/Client/OwnCord.Client.Tests/UnitTest1.cs b/Client/OwnCord.Client.Tests/UnitTest1.cs new file mode 100644 index 00000000..8832df3f --- /dev/null +++ b/Client/OwnCord.Client.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace OwnCord.Client.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} \ No newline at end of file diff --git a/Client/OwnCord.Client.Tests/ViewModels/ConnectViewModelTests.cs b/Client/OwnCord.Client.Tests/ViewModels/ConnectViewModelTests.cs new file mode 100644 index 00000000..70c74802 --- /dev/null +++ b/Client/OwnCord.Client.Tests/ViewModels/ConnectViewModelTests.cs @@ -0,0 +1,142 @@ +using OwnCord.Client.Models; +using OwnCord.Client.Services; +using OwnCord.Client.ViewModels; + +namespace OwnCord.Client.Tests.ViewModels; + +public sealed class ConnectViewModelTests +{ + private static ConnectViewModel MakeVm(IProfileService? svc = null) + => new(svc ?? new FakeProfileService()); + + [Fact] + public void DefaultMode_IsLogin() + { + var vm = MakeVm(); + Assert.False(vm.IsRegisterMode); + } + + [Fact] + public void ToggleRegisterMode_FlipsFlag() + { + var vm = MakeVm(); + vm.IsRegisterMode = true; + Assert.True(vm.IsRegisterMode); + vm.IsRegisterMode = false; + Assert.False(vm.IsRegisterMode); + } + + [Fact] + public void ConnectCommand_DisabledWhenHostEmpty() + { + var vm = MakeVm(); + vm.Username = "alice"; + vm.Host = ""; + Assert.False(vm.ConnectCommand.CanExecute(null)); + } + + [Fact] + public void ConnectCommand_DisabledWhenUsernameEmpty() + { + var vm = MakeVm(); + vm.Host = "localhost:8443"; + vm.Username = ""; + Assert.False(vm.ConnectCommand.CanExecute(null)); + } + + [Fact] + public void ConnectCommand_EnabledWhenHostAndUsernameSet() + { + var vm = MakeVm(); + vm.Host = "localhost:8443"; + vm.Username = "alice"; + Assert.True(vm.ConnectCommand.CanExecute(null)); + } + + [Fact] + public void ConnectCommand_RaisesConnectRequested() + { + var vm = MakeVm(); + vm.Host = "localhost:8443"; + vm.Username = "alice"; + (string host, string user, string? invite, bool isReg) captured = default; + vm.ConnectRequested += (h, u, i, r) => captured = (h, u, i, r); + vm.ConnectCommand.Execute(null); + Assert.Equal("localhost:8443", captured.host); + Assert.Equal("alice", captured.user); + Assert.Null(captured.invite); + Assert.False(captured.isReg); + } + + [Fact] + public void ConnectCommand_RegisterMode_PassesInviteCode() + { + var vm = MakeVm(); + vm.Host = "localhost:8443"; + vm.Username = "alice"; + vm.IsRegisterMode = true; + vm.InviteCode = "abc123"; + string? capturedInvite = null; + vm.ConnectRequested += (_, _, i, _) => capturedInvite = i; + vm.ConnectCommand.Execute(null); + Assert.Equal("abc123", capturedInvite); + } + + [Fact] + public void SaveProfileCommand_DisabledWhenHostEmpty() + { + var vm = MakeVm(); + vm.Username = "alice"; + Assert.False(vm.SaveProfileCommand.CanExecute(null)); + } + + [Fact] + public void SaveProfile_AddsToCollection() + { + var svc = new FakeProfileService(); + var vm = MakeVm(svc); + vm.Host = "localhost:8443"; + vm.Username = "alice"; + vm.SaveProfileCommand.Execute(null); + Assert.Single(vm.Profiles); + Assert.Equal("localhost:8443", vm.Profiles[0].Host); + } + + [Fact] + public void SelectProfile_PopulatesHostAndUsername() + { + var svc = new FakeProfileService(); + var profile = ServerProfile.Create("Home", "192.168.1.10:8443", "bob"); + svc.Saved = [profile]; + var vm = MakeVm(svc); + vm.SelectedProfile = profile; + Assert.Equal("192.168.1.10:8443", vm.Host); + Assert.Equal("bob", vm.Username); + } + + [Fact] + public void DeleteProfile_RemovesFromCollection() + { + var svc = new FakeProfileService(); + var profile = ServerProfile.Create("Home", "192.168.1.10:8443", "bob"); + svc.Saved = [profile]; + var vm = MakeVm(svc); + vm.SelectedProfile = profile; + vm.DeleteProfileCommand.Execute(null); + Assert.Empty(vm.Profiles); + } +} + +internal sealed class FakeProfileService : IProfileService +{ + public List Saved = []; + + public IReadOnlyList LoadProfiles() => Saved; + public IReadOnlyList AddProfile(IReadOnlyList p, ServerProfile profile) + => [.. p, profile]; + public IReadOnlyList RemoveProfile(IReadOnlyList p, string id) + => p.Where(x => x.Id != id).ToList(); + public IReadOnlyList UpdateProfile(IReadOnlyList p, ServerProfile updated) + => p.Select(x => x.Id == updated.Id ? updated : x).ToList(); + public void SaveProfiles(IReadOnlyList profiles) => Saved = [.. profiles]; +} diff --git a/Client/OwnCord.Client.Tests/ViewModels/MainViewModelTests.cs b/Client/OwnCord.Client.Tests/ViewModels/MainViewModelTests.cs new file mode 100644 index 00000000..d0f0c55c --- /dev/null +++ b/Client/OwnCord.Client.Tests/ViewModels/MainViewModelTests.cs @@ -0,0 +1,119 @@ +using OwnCord.Client.Models; +using OwnCord.Client.ViewModels; + +namespace OwnCord.Client.Tests.ViewModels; + +public sealed class MainViewModelTests +{ + private static MainViewModel MakeVm() => new(); + + private static Channel MakeChannel(long id, string name, int unread = 0) + => new(id, name, ChannelType.Text, null, 0, unread, null); + + private static User MakeUser(long id, string name) + => new(id, name, null, 4, UserStatus.Online); + + private static Message MakeMessage(long id, long channelId, string content) + => new(id, channelId, MakeUser(1, "alice"), content, DateTime.UtcNow, null, null, false, []); + + [Fact] + public void SendCommand_DisabledWhenInputEmpty() + { + var vm = MakeVm(); + vm.SelectedChannel = MakeChannel(1, "general"); + vm.MessageInput = ""; + Assert.False(vm.SendMessageCommand.CanExecute(null)); + } + + [Fact] + public void SendCommand_DisabledWhenNoChannelSelected() + { + var vm = MakeVm(); + vm.MessageInput = "hello"; + Assert.False(vm.SendMessageCommand.CanExecute(null)); + } + + [Fact] + public void SendCommand_EnabledWhenInputAndChannelSet() + { + var vm = MakeVm(); + vm.SelectedChannel = MakeChannel(1, "general"); + vm.MessageInput = "hello"; + Assert.True(vm.SendMessageCommand.CanExecute(null)); + } + + [Fact] + public void SendCommand_RaisesEventAndClearsInput() + { + var vm = MakeVm(); + vm.SelectedChannel = MakeChannel(1, "general"); + vm.MessageInput = "hello"; + (long channelId, string content) captured = default; + vm.MessageSendRequested += (ch, msg) => captured = (ch, msg); + vm.SendMessageCommand.Execute(null); + Assert.Equal(1L, captured.channelId); + Assert.Equal("hello", captured.content); + Assert.Equal(string.Empty, vm.MessageInput); + } + + [Fact] + public void SelectChannel_ClearsMessages() + { + var vm = MakeVm(); + vm.AddMessage(MakeMessage(1, 1, "hi")); + vm.SelectedChannel = MakeChannel(2, "random"); + Assert.Empty(vm.Messages); + } + + [Fact] + public void LoadChannels_PopulatesCollection() + { + var vm = MakeVm(); + vm.LoadChannels([MakeChannel(1, "general"), MakeChannel(2, "random")]); + Assert.Equal(2, vm.Channels.Count); + } + + [Fact] + public void LoadMembers_PopulatesCollection() + { + var vm = MakeVm(); + vm.LoadMembers([MakeUser(1, "alice"), MakeUser(2, "bob")]); + Assert.Equal(2, vm.Members.Count); + } + + [Fact] + public void AddMessage_AppendsToCollection() + { + var vm = MakeVm(); + vm.AddMessage(MakeMessage(1, 1, "hello")); + Assert.Single(vm.Messages); + } + + [Fact] + public void ShowTyping_SetsIsTypingAndText() + { + var vm = MakeVm(); + vm.ShowTyping("alice"); + Assert.True(vm.IsTyping); + Assert.Contains("alice", vm.TypingText); + } + + [Fact] + public void HideTyping_ClearsIsTyping() + { + var vm = MakeVm(); + vm.ShowTyping("alice"); + vm.HideTyping(); + Assert.False(vm.IsTyping); + Assert.Null(vm.TypingText); + } + + [Fact] + public void UpdateUnreadCount_UpdatesChannel() + { + var vm = MakeVm(); + vm.LoadChannels([MakeChannel(1, "general", 0)]); + vm.UpdateUnreadCount(1, 5); + Assert.Equal(5, vm.Channels[0].UnreadCount); + } +} diff --git a/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs b/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs new file mode 100644 index 00000000..2217181c --- /dev/null +++ b/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs @@ -0,0 +1,4 @@ +// +using System; +using System.Reflection; +[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")] diff --git a/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.AssemblyInfo.cs b/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.AssemblyInfo.cs new file mode 100644 index 00000000..a7035ab2 --- /dev/null +++ b/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.AssemblyInfo.cs @@ -0,0 +1,24 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +using System; +using System.Reflection; + +[assembly: System.Reflection.AssemblyCompanyAttribute("OwnCord.Client.Tests")] +[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] +[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+7d03f59c2fb92f8a71bd680cf2303ce5a92e3860")] +[assembly: System.Reflection.AssemblyProductAttribute("OwnCord.Client.Tests")] +[assembly: System.Reflection.AssemblyTitleAttribute("OwnCord.Client.Tests")] +[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] +[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")] +[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")] + +// Generated by the MSBuild WriteCodeFragment class. + diff --git a/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.AssemblyInfoInputs.cache b/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.AssemblyInfoInputs.cache new file mode 100644 index 00000000..9fe9b5c7 --- /dev/null +++ b/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.AssemblyInfoInputs.cache @@ -0,0 +1 @@ +97ede2b38db5da9d29faa6ecdbc359cc74d18937ef9a717322cd2ca310c427a3 diff --git a/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.GeneratedMSBuildEditorConfig.editorconfig b/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.GeneratedMSBuildEditorConfig.editorconfig new file mode 100644 index 00000000..c5aaf9ae --- /dev/null +++ b/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.GeneratedMSBuildEditorConfig.editorconfig @@ -0,0 +1,13 @@ +is_global = true +build_property.TargetFramework = net8.0-windows +build_property.TargetPlatformMinVersion = 7.0 +build_property.UsingMicrosoftNETSdkWeb = +build_property.ProjectTypeGuids = +build_property.InvariantGlobalization = +build_property.PlatformNeutralAssembly = +build_property.EnforceExtendedAnalyzerRules = +build_property._SupportedPlatformList = Linux,macOS,Windows +build_property.RootNamespace = OwnCord.Client.Tests +build_property.ProjectDir = D:\Local-Lab\Coding\Repos\OwnCord\Client\OwnCord.Client.Tests\ +build_property.EnableComHosting = +build_property.EnableGeneratedComInterfaceComImportInterop = diff --git a/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.GlobalUsings.g.cs b/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.GlobalUsings.g.cs new file mode 100644 index 00000000..2cd3d38c --- /dev/null +++ b/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.GlobalUsings.g.cs @@ -0,0 +1,9 @@ +// +global using global::System; +global using global::System.Collections.Generic; +global using global::System.IO; +global using global::System.Linq; +global using global::System.Net.Http; +global using global::System.Threading; +global using global::System.Threading.Tasks; +global using global::Xunit; diff --git a/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.assets.cache b/Client/OwnCord.Client.Tests/obj/Debug/net8.0-windows/OwnCord.Client.Tests.assets.cache new file mode 100644 index 0000000000000000000000000000000000000000..8804ec4ca6c553fd327886843850cd7a09b1d0f9 GIT binary patch literal 752 zcmbVK%SyvQ6s?bnh`KDIf&uB$OA^JTfqJ|H(rz*^aOccD=iEDY>L-W!#Z)R)dAa%eEZ=>szGa+;+ZB&xyrt*$ z_l3vvjkSBT|N1m7lP0Vwi7Qp#VU$x(K-(uS?eik?uAm|0!w}l_@+dbZZ5H;d7DvXd z3T5swUPRmi5_M2mq3v|-4svUhH$10haM~jFMSzN+3PVnV9x7SgaA*%%fC3yE1n3CL zBx)Y)E4!eAp4D?QNAZ48gP<@$-Rl3AY=A27VAyJ0VjR!NZH=}1Z@N}$LKC@whT9$3 zX$EL|teD8~XsqE?M0_#O*C7Yn3DwUgs{@M&K?XgaM|QwP26OG}Cp qb958}N|SOjlf6?bL-Mmz^KA1?|8yFi}G5`Q5rAfsA literal 0 HcmV?d00001 diff --git a/Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.dgspec.json b/Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.dgspec.json new file mode 100644 index 00000000..3717558d --- /dev/null +++ b/Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.dgspec.json @@ -0,0 +1,147 @@ +{ + "format": 1, + "restore": { + "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client.Tests\\OwnCord.Client.Tests.csproj": {} + }, + "projects": { + "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client.Tests\\OwnCord.Client.Tests.csproj": { + "version": "1.0.0", + "restore": { + "projectUniqueName": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client.Tests\\OwnCord.Client.Tests.csproj", + "projectName": "OwnCord.Client.Tests", + "projectPath": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client.Tests\\OwnCord.Client.Tests.csproj", + "packagesPath": "C:\\Users\\LordJebus\\.nuget\\packages\\", + "outputPath": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client.Tests\\obj\\", + "projectStyle": "PackageReference", + "configFilePaths": [ + "C:\\Users\\LordJebus\\AppData\\Roaming\\NuGet\\NuGet.Config" + ], + "originalTargetFrameworks": [ + "net8.0-windows" + ], + "frameworks": { + "net8.0-windows7.0": { + "targetAlias": "net8.0-windows", + "projectReferences": { + "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client\\OwnCord.Client.csproj": { + "projectPath": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client\\OwnCord.Client.csproj" + } + } + } + }, + "warningProperties": { + "warnAsError": [ + "NU1605" + ] + }, + "restoreAuditProperties": { + "enableAudit": "true", + "auditLevel": "low", + "auditMode": "direct" + } + }, + "frameworks": { + "net8.0-windows7.0": { + "targetAlias": "net8.0-windows", + "dependencies": { + "Microsoft.NET.Test.Sdk": { + "target": "Package", + "version": "[17.12.0, )" + }, + "Moq": { + "target": "Package", + "version": "[4.20.72, )" + }, + "coverlet.collector": { + "target": "Package", + "version": "[6.0.2, )" + }, + "xunit": { + "target": "Package", + "version": "[2.9.3, )" + }, + "xunit.runner.visualstudio": { + "target": "Package", + "version": "[2.8.2, )" + } + }, + "imports": [ + "net461", + "net462", + "net47", + "net471", + "net472", + "net48", + "net481" + ], + "assetTargetFallback": true, + "warn": true, + "frameworkReferences": { + "Microsoft.NETCore.App": { + "privateAssets": "all" + } + }, + "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\8.0.419/PortableRuntimeIdentifierGraph.json" + } + } + }, + "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client\\OwnCord.Client.csproj": { + "version": "1.0.0", + "restore": { + "projectUniqueName": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client\\OwnCord.Client.csproj", + "projectName": "OwnCord.Client", + "projectPath": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client\\OwnCord.Client.csproj", + "packagesPath": "C:\\Users\\LordJebus\\.nuget\\packages\\", + "outputPath": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client\\obj\\", + "projectStyle": "PackageReference", + "configFilePaths": [ + "C:\\Users\\LordJebus\\AppData\\Roaming\\NuGet\\NuGet.Config" + ], + "originalTargetFrameworks": [ + "net8.0-windows" + ], + "frameworks": { + "net8.0-windows7.0": { + "targetAlias": "net8.0-windows", + "projectReferences": {} + } + }, + "warningProperties": { + "warnAsError": [ + "NU1605" + ] + }, + "restoreAuditProperties": { + "enableAudit": "true", + "auditLevel": "low", + "auditMode": "direct" + } + }, + "frameworks": { + "net8.0-windows7.0": { + "targetAlias": "net8.0-windows", + "imports": [ + "net461", + "net462", + "net47", + "net471", + "net472", + "net48", + "net481" + ], + "assetTargetFallback": true, + "warn": true, + "frameworkReferences": { + "Microsoft.NETCore.App": { + "privateAssets": "all" + }, + "Microsoft.WindowsDesktop.App.WPF": { + "privateAssets": "none" + } + }, + "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\8.0.419/PortableRuntimeIdentifierGraph.json" + } + } + } + } +} \ No newline at end of file diff --git a/Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.g.props b/Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.g.props new file mode 100644 index 00000000..d8d7dabc --- /dev/null +++ b/Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.g.props @@ -0,0 +1,15 @@ + + + + False + NuGet + $(MSBuildThisFileDirectory)project.assets.json + $(UserProfile)\.nuget\packages\ + C:\Users\LordJebus\.nuget\packages\ + PackageReference + 6.11.1 + + + + + \ No newline at end of file diff --git a/Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.g.targets b/Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.g.targets new file mode 100644 index 00000000..3dc06ef3 --- /dev/null +++ b/Client/OwnCord.Client.Tests/obj/OwnCord.Client.Tests.csproj.nuget.g.targets @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Client/OwnCord.Client.Tests/obj/project.assets.json b/Client/OwnCord.Client.Tests/obj/project.assets.json new file mode 100644 index 00000000..9bcce153 --- /dev/null +++ b/Client/OwnCord.Client.Tests/obj/project.assets.json @@ -0,0 +1,168 @@ +{ + "version": 3, + "targets": { + "net8.0-windows7.0": { + "OwnCord.Client/1.0.0": { + "type": "project", + "framework": ".NETCoreApp,Version=v8.0", + "compile": { + "bin/placeholder/OwnCord.Client.dll": {} + }, + "runtime": { + "bin/placeholder/OwnCord.Client.dll": {} + }, + "frameworkReferences": [ + "Microsoft.WindowsDesktop.App.WPF" + ] + } + } + }, + "libraries": { + "OwnCord.Client/1.0.0": { + "type": "project", + "path": "../OwnCord.Client/OwnCord.Client.csproj", + "msbuildProject": "../OwnCord.Client/OwnCord.Client.csproj" + } + }, + "projectFileDependencyGroups": { + "net8.0-windows7.0": [ + "Microsoft.NET.Test.Sdk >= 17.12.0", + "Moq >= 4.20.72", + "OwnCord.Client >= 1.0.0", + "coverlet.collector >= 6.0.2", + "xunit >= 2.9.3", + "xunit.runner.visualstudio >= 2.8.2" + ] + }, + "packageFolders": { + "C:\\Users\\LordJebus\\.nuget\\packages\\": {} + }, + "project": { + "version": "1.0.0", + "restore": { + "projectUniqueName": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client.Tests\\OwnCord.Client.Tests.csproj", + "projectName": "OwnCord.Client.Tests", + "projectPath": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client.Tests\\OwnCord.Client.Tests.csproj", + "packagesPath": "C:\\Users\\LordJebus\\.nuget\\packages\\", + "outputPath": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client.Tests\\obj\\", + "projectStyle": "PackageReference", + "configFilePaths": [ + "C:\\Users\\LordJebus\\AppData\\Roaming\\NuGet\\NuGet.Config" + ], + "originalTargetFrameworks": [ + "net8.0-windows" + ], + "frameworks": { + "net8.0-windows7.0": { + "targetAlias": "net8.0-windows", + "projectReferences": { + "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client\\OwnCord.Client.csproj": { + "projectPath": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client\\OwnCord.Client.csproj" + } + } + } + }, + "warningProperties": { + "warnAsError": [ + "NU1605" + ] + }, + "restoreAuditProperties": { + "enableAudit": "true", + "auditLevel": "low", + "auditMode": "direct" + } + }, + "frameworks": { + "net8.0-windows7.0": { + "targetAlias": "net8.0-windows", + "dependencies": { + "Microsoft.NET.Test.Sdk": { + "target": "Package", + "version": "[17.12.0, )" + }, + "Moq": { + "target": "Package", + "version": "[4.20.72, )" + }, + "coverlet.collector": { + "target": "Package", + "version": "[6.0.2, )" + }, + "xunit": { + "target": "Package", + "version": "[2.9.3, )" + }, + "xunit.runner.visualstudio": { + "target": "Package", + "version": "[2.8.2, )" + } + }, + "imports": [ + "net461", + "net462", + "net47", + "net471", + "net472", + "net48", + "net481" + ], + "assetTargetFallback": true, + "warn": true, + "frameworkReferences": { + "Microsoft.NETCore.App": { + "privateAssets": "all" + } + }, + "runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\8.0.419/PortableRuntimeIdentifierGraph.json" + } + } + }, + "logs": [ + { + "code": "NU1100", + "level": "Error", + "message": "Unable to resolve 'coverlet.collector (>= 6.0.2)' for 'net8.0-windows7.0'.", + "libraryId": "coverlet.collector", + "targetGraphs": [ + "net8.0-windows7.0" + ] + }, + { + "code": "NU1100", + "level": "Error", + "message": "Unable to resolve 'Microsoft.NET.Test.Sdk (>= 17.12.0)' for 'net8.0-windows7.0'.", + "libraryId": "Microsoft.NET.Test.Sdk", + "targetGraphs": [ + "net8.0-windows7.0" + ] + }, + { + "code": "NU1100", + "level": "Error", + "message": "Unable to resolve 'xunit (>= 2.9.3)' for 'net8.0-windows7.0'.", + "libraryId": "xunit", + "targetGraphs": [ + "net8.0-windows7.0" + ] + }, + { + "code": "NU1100", + "level": "Error", + "message": "Unable to resolve 'xunit.runner.visualstudio (>= 2.8.2)' for 'net8.0-windows7.0'.", + "libraryId": "xunit.runner.visualstudio", + "targetGraphs": [ + "net8.0-windows7.0" + ] + }, + { + "code": "NU1100", + "level": "Error", + "message": "Unable to resolve 'Moq (>= 4.20.72)' for 'net8.0-windows7.0'.", + "libraryId": "Moq", + "targetGraphs": [ + "net8.0-windows7.0" + ] + } + ] +} \ No newline at end of file diff --git a/Client/OwnCord.Client.Tests/obj/project.nuget.cache b/Client/OwnCord.Client.Tests/obj/project.nuget.cache new file mode 100644 index 00000000..968cd28d --- /dev/null +++ b/Client/OwnCord.Client.Tests/obj/project.nuget.cache @@ -0,0 +1,54 @@ +{ + "version": 2, + "dgSpecHash": "K+dtNnOVYHM=", + "success": false, + "projectFilePath": "D:\\Local-Lab\\Coding\\Repos\\OwnCord\\Client\\OwnCord.Client.Tests\\OwnCord.Client.Tests.csproj", + "expectedPackageFiles": [], + "logs": [ + { + "code": "NU1100", + "level": "Error", + "message": "Unable to resolve 'coverlet.collector (>= 6.0.2)' for 'net8.0-windows7.0'.", + "libraryId": "coverlet.collector", + "targetGraphs": [ + "net8.0-windows7.0" + ] + }, + { + "code": "NU1100", + "level": "Error", + "message": "Unable to resolve 'Microsoft.NET.Test.Sdk (>= 17.12.0)' for 'net8.0-windows7.0'.", + "libraryId": "Microsoft.NET.Test.Sdk", + "targetGraphs": [ + "net8.0-windows7.0" + ] + }, + { + "code": "NU1100", + "level": "Error", + "message": "Unable to resolve 'xunit (>= 2.9.3)' for 'net8.0-windows7.0'.", + "libraryId": "xunit", + "targetGraphs": [ + "net8.0-windows7.0" + ] + }, + { + "code": "NU1100", + "level": "Error", + "message": "Unable to resolve 'xunit.runner.visualstudio (>= 2.8.2)' for 'net8.0-windows7.0'.", + "libraryId": "xunit.runner.visualstudio", + "targetGraphs": [ + "net8.0-windows7.0" + ] + }, + { + "code": "NU1100", + "level": "Error", + "message": "Unable to resolve 'Moq (>= 4.20.72)' for 'net8.0-windows7.0'.", + "libraryId": "Moq", + "targetGraphs": [ + "net8.0-windows7.0" + ] + } + ] +} \ No newline at end of file diff --git a/Client/OwnCord.Client.sln b/Client/OwnCord.Client.sln new file mode 100644 index 00000000..c314d9a4 --- /dev/null +++ b/Client/OwnCord.Client.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OwnCord.Client", "OwnCord.Client\OwnCord.Client.csproj", "{A25E4856-BF72-4C5C-940C-808B3694F1A1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OwnCord.Client.Tests", "OwnCord.Client.Tests\OwnCord.Client.Tests.csproj", "{14D58A32-A395-4278-AC89-88910F1FE2F8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A25E4856-BF72-4C5C-940C-808B3694F1A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A25E4856-BF72-4C5C-940C-808B3694F1A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A25E4856-BF72-4C5C-940C-808B3694F1A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A25E4856-BF72-4C5C-940C-808B3694F1A1}.Release|Any CPU.Build.0 = Release|Any CPU + {14D58A32-A395-4278-AC89-88910F1FE2F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {14D58A32-A395-4278-AC89-88910F1FE2F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14D58A32-A395-4278-AC89-88910F1FE2F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {14D58A32-A395-4278-AC89-88910F1FE2F8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Client/OwnCord.Client/App.xaml b/Client/OwnCord.Client/App.xaml new file mode 100644 index 00000000..cf748d82 --- /dev/null +++ b/Client/OwnCord.Client/App.xaml @@ -0,0 +1,10 @@ + + + + + + diff --git a/Client/OwnCord.Client/App.xaml.cs b/Client/OwnCord.Client/App.xaml.cs new file mode 100644 index 00000000..c3653c15 --- /dev/null +++ b/Client/OwnCord.Client/App.xaml.cs @@ -0,0 +1,27 @@ +using System.IO; +using System.Windows; +using OwnCord.Client.Services; +using OwnCord.Client.ViewModels; + +namespace OwnCord.Client; + +public partial class App : Application +{ + private void Application_Startup(object sender, StartupEventArgs e) + { + var dataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OwnCord"); + + var profileService = new ProfileService(dataDir); + var credentialService = new CredentialService(); + var wsService = new WebSocketService(); + + var connectVm = new ConnectViewModel(profileService); + var mainVm = new MainViewModel(); + + var mainWindow = new MainWindow(connectVm, mainVm, credentialService, wsService); + mainWindow.Show(); + } +} + diff --git a/Client/OwnCord.Client/AssemblyInfo.cs b/Client/OwnCord.Client/AssemblyInfo.cs new file mode 100644 index 00000000..cc29e7f7 --- /dev/null +++ b/Client/OwnCord.Client/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/Client/OwnCord.Client/Converters/BoolToVisibilityConverter.cs b/Client/OwnCord.Client/Converters/BoolToVisibilityConverter.cs new file mode 100644 index 00000000..de70fe16 --- /dev/null +++ b/Client/OwnCord.Client/Converters/BoolToVisibilityConverter.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace OwnCord.Client.Converters; + +[ValueConversion(typeof(bool), typeof(Visibility))] +public sealed class BoolToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is true ? Visibility.Visible : Visibility.Collapsed; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => value is Visibility.Visible; +} + +[ValueConversion(typeof(int), typeof(Visibility))] +public sealed class IntToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is int n && n > 0 ? Visibility.Visible : Visibility.Collapsed; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/Client/OwnCord.Client/MainWindow.xaml b/Client/OwnCord.Client/MainWindow.xaml new file mode 100644 index 00000000..1d0c1cba --- /dev/null +++ b/Client/OwnCord.Client/MainWindow.xaml @@ -0,0 +1,7 @@ + + + diff --git a/Client/OwnCord.Client/MainWindow.xaml.cs b/Client/OwnCord.Client/MainWindow.xaml.cs new file mode 100644 index 00000000..6223c475 --- /dev/null +++ b/Client/OwnCord.Client/MainWindow.xaml.cs @@ -0,0 +1,32 @@ +using System.Windows; +using OwnCord.Client.Services; +using OwnCord.Client.ViewModels; +using OwnCord.Client.Views; + +namespace OwnCord.Client; + +public partial class MainWindow : Window +{ + private readonly IWebSocketService _ws; + + public MainWindow( + ConnectViewModel connectVm, + MainViewModel mainVm, + ICredentialService credentials, + IWebSocketService ws) + { + InitializeComponent(); + _ws = ws; + + connectVm.ConnectRequested += (host, username, inviteCode, isRegister) => + RootFrame.Navigate(new MainPage(mainVm)); + + RootFrame.Navigate(new ConnectPage(connectVm)); + } + + protected override void OnClosing(System.ComponentModel.CancelEventArgs e) + { + base.OnClosing(e); + _ = _ws.DisconnectAsync(); + } +} \ No newline at end of file diff --git a/Client/OwnCord.Client/Models/Channel.cs b/Client/OwnCord.Client/Models/Channel.cs new file mode 100644 index 00000000..ad6c3451 --- /dev/null +++ b/Client/OwnCord.Client/Models/Channel.cs @@ -0,0 +1,13 @@ +namespace OwnCord.Client.Models; + +public enum ChannelType { Text, Voice, Announcement } + +public record Channel( + long Id, + string Name, + ChannelType Type, + string? Category, + int Position, + int UnreadCount, + long? LastMessageId +); diff --git a/Client/OwnCord.Client/Models/Message.cs b/Client/OwnCord.Client/Models/Message.cs new file mode 100644 index 00000000..7eece74f --- /dev/null +++ b/Client/OwnCord.Client/Models/Message.cs @@ -0,0 +1,15 @@ +namespace OwnCord.Client.Models; + +public record Message( + long Id, + long ChannelId, + User Author, + string Content, + DateTime Timestamp, + long? ReplyToId, + string? EditedAt, + bool Deleted, + IReadOnlyList Reactions +); + +public record Reaction(string Emoji, int Count, bool Me); diff --git a/Client/OwnCord.Client/Models/Role.cs b/Client/OwnCord.Client/Models/Role.cs new file mode 100644 index 00000000..89c3c92a --- /dev/null +++ b/Client/OwnCord.Client/Models/Role.cs @@ -0,0 +1,3 @@ +namespace OwnCord.Client.Models; + +public record Role(long Id, string Name, string? Color, long Permissions); diff --git a/Client/OwnCord.Client/Models/ServerProfile.cs b/Client/OwnCord.Client/Models/ServerProfile.cs new file mode 100644 index 00000000..a696664c --- /dev/null +++ b/Client/OwnCord.Client/Models/ServerProfile.cs @@ -0,0 +1,13 @@ +namespace OwnCord.Client.Models; + +public record ServerProfile( + string Id, + string Name, + string Host, + string? LastUsername, + bool AutoConnect +) +{ + public static ServerProfile Create(string name, string host, string? lastUsername = null, bool autoConnect = false) + => new(Guid.NewGuid().ToString(), name, host, lastUsername, autoConnect); +} diff --git a/Client/OwnCord.Client/Models/User.cs b/Client/OwnCord.Client/Models/User.cs new file mode 100644 index 00000000..2616576e --- /dev/null +++ b/Client/OwnCord.Client/Models/User.cs @@ -0,0 +1,11 @@ +namespace OwnCord.Client.Models; + +public enum UserStatus { Online, Idle, Dnd, Offline } + +public record User( + long Id, + string Username, + string? Avatar, + long RoleId, + UserStatus Status +); diff --git a/Client/OwnCord.Client/OwnCord.Client.csproj b/Client/OwnCord.Client/OwnCord.Client.csproj new file mode 100644 index 00000000..e3e33e3b --- /dev/null +++ b/Client/OwnCord.Client/OwnCord.Client.csproj @@ -0,0 +1,11 @@ + + + + WinExe + net8.0-windows + enable + enable + true + + + diff --git a/Client/OwnCord.Client/Services/CredentialService.cs b/Client/OwnCord.Client/Services/CredentialService.cs new file mode 100644 index 00000000..5cd373e2 --- /dev/null +++ b/Client/OwnCord.Client/Services/CredentialService.cs @@ -0,0 +1,56 @@ +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace OwnCord.Client.Services; + +/// +/// Stores auth tokens encrypted with DPAPI (CurrentUser scope) in AppData. +/// Equivalent security to Windows Credential Manager without requiring WinRT. +/// +public sealed class CredentialService : ICredentialService +{ + private readonly string _dir; + + public CredentialService() + : this(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OwnCord", "creds")) { } + + internal CredentialService(string dir) => _dir = dir; + + public void SaveToken(string host, string username, string token) + { + Directory.CreateDirectory(_dir); + var plain = Encoding.UTF8.GetBytes(token); + var encrypted = ProtectedData.Protect(plain, GetEntropy(host, username), DataProtectionScope.CurrentUser); + File.WriteAllBytes(CredPath(host, username), encrypted); + } + + public string? LoadToken(string host, string username) + { + var path = CredPath(host, username); + if (!File.Exists(path)) return null; + try + { + var encrypted = File.ReadAllBytes(path); + var plain = ProtectedData.Unprotect(encrypted, GetEntropy(host, username), DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(plain); + } + catch { return null; } + } + + public void DeleteToken(string host, string username) + { + var path = CredPath(host, username); + if (File.Exists(path)) File.Delete(path); + } + + private string CredPath(string host, string username) + { + var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{host}:{username}"))); + return Path.Combine(_dir, key + ".dat"); + } + + private static byte[] GetEntropy(string host, string username) + => Encoding.UTF8.GetBytes($"owncord:{host}:{username}"); +} diff --git a/Client/OwnCord.Client/Services/ICredentialService.cs b/Client/OwnCord.Client/Services/ICredentialService.cs new file mode 100644 index 00000000..bc044875 --- /dev/null +++ b/Client/OwnCord.Client/Services/ICredentialService.cs @@ -0,0 +1,8 @@ +namespace OwnCord.Client.Services; + +public interface ICredentialService +{ + void SaveToken(string host, string username, string token); + string? LoadToken(string host, string username); + void DeleteToken(string host, string username); +} diff --git a/Client/OwnCord.Client/Services/IProfileService.cs b/Client/OwnCord.Client/Services/IProfileService.cs new file mode 100644 index 00000000..3f682cfe --- /dev/null +++ b/Client/OwnCord.Client/Services/IProfileService.cs @@ -0,0 +1,12 @@ +using OwnCord.Client.Models; + +namespace OwnCord.Client.Services; + +public interface IProfileService +{ + IReadOnlyList LoadProfiles(); + IReadOnlyList AddProfile(IReadOnlyList profiles, ServerProfile profile); + IReadOnlyList RemoveProfile(IReadOnlyList profiles, string id); + IReadOnlyList UpdateProfile(IReadOnlyList profiles, ServerProfile updated); + void SaveProfiles(IReadOnlyList profiles); +} diff --git a/Client/OwnCord.Client/Services/IWebSocketService.cs b/Client/OwnCord.Client/Services/IWebSocketService.cs new file mode 100644 index 00000000..6a05dd6d --- /dev/null +++ b/Client/OwnCord.Client/Services/IWebSocketService.cs @@ -0,0 +1,10 @@ +namespace OwnCord.Client.Services; + +public interface IWebSocketService +{ + bool IsConnected { get; } + Task ConnectAsync(string uri, string token, CancellationToken ct = default); + Task SendAsync(object message, CancellationToken ct = default); + IAsyncEnumerable ReceiveAsync(CancellationToken ct); + Task DisconnectAsync(); +} diff --git a/Client/OwnCord.Client/Services/ProfileService.cs b/Client/OwnCord.Client/Services/ProfileService.cs new file mode 100644 index 00000000..ca982797 --- /dev/null +++ b/Client/OwnCord.Client/Services/ProfileService.cs @@ -0,0 +1,32 @@ +using System.IO; +using System.Text.Json; +using OwnCord.Client.Models; + +namespace OwnCord.Client.Services; + +public sealed class ProfileService(string dataDir) : IProfileService +{ + private readonly string _path = Path.Combine(dataDir, "profiles.json"); + + public IReadOnlyList LoadProfiles() + { + if (!File.Exists(_path)) return []; + var json = File.ReadAllText(_path); + return JsonSerializer.Deserialize>(json) ?? []; + } + + public IReadOnlyList AddProfile(IReadOnlyList profiles, ServerProfile profile) + => [.. profiles, profile]; + + public IReadOnlyList RemoveProfile(IReadOnlyList profiles, string id) + => profiles.Where(p => p.Id != id).ToList(); + + public IReadOnlyList UpdateProfile(IReadOnlyList profiles, ServerProfile updated) + => profiles.Select(p => p.Id == updated.Id ? updated : p).ToList(); + + public void SaveProfiles(IReadOnlyList profiles) + { + Directory.CreateDirectory(dataDir); + File.WriteAllText(_path, JsonSerializer.Serialize(profiles)); + } +} diff --git a/Client/OwnCord.Client/Services/WebSocketService.cs b/Client/OwnCord.Client/Services/WebSocketService.cs new file mode 100644 index 00000000..08a29046 --- /dev/null +++ b/Client/OwnCord.Client/Services/WebSocketService.cs @@ -0,0 +1,60 @@ +using System.Net.WebSockets; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; + +namespace OwnCord.Client.Services; + +public sealed class WebSocketService : IWebSocketService, IDisposable +{ + private ClientWebSocket? _ws; + + public bool IsConnected => _ws?.State == WebSocketState.Open; + + public async Task ConnectAsync(string uri, string token, CancellationToken ct = default) + { + _ws = new ClientWebSocket(); + await _ws.ConnectAsync(new Uri(uri), ct); + var auth = JsonSerializer.Serialize(new { type = "auth", payload = new { token } }); + await SendRawAsync(auth, ct); + } + + public async Task SendAsync(object message, CancellationToken ct = default) + { + var json = JsonSerializer.Serialize(message); + await SendRawAsync(json, ct); + } + + public async IAsyncEnumerable ReceiveAsync([EnumeratorCancellation] CancellationToken ct) + { + if (_ws is null) yield break; + var buf = new byte[8192]; + while (_ws.State == WebSocketState.Open && !ct.IsCancellationRequested) + { + using var ms = new System.IO.MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await _ws.ReceiveAsync(buf, ct); + if (result.MessageType == WebSocketMessageType.Close) yield break; + ms.Write(buf, 0, result.Count); + } while (!result.EndOfMessage); + yield return Encoding.UTF8.GetString(ms.ToArray()); + } + } + + public async Task DisconnectAsync() + { + if (_ws?.State == WebSocketState.Open) + await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disconnect", default); + } + + private async Task SendRawAsync(string text, CancellationToken ct) + { + if (_ws is null) return; + var bytes = Encoding.UTF8.GetBytes(text); + await _ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct); + } + + public void Dispose() => _ws?.Dispose(); +} diff --git a/Client/OwnCord.Client/ViewModels/ConnectViewModel.cs b/Client/OwnCord.Client/ViewModels/ConnectViewModel.cs new file mode 100644 index 00000000..b3e110a8 --- /dev/null +++ b/Client/OwnCord.Client/ViewModels/ConnectViewModel.cs @@ -0,0 +1,113 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using OwnCord.Client.Models; +using OwnCord.Client.Services; + +namespace OwnCord.Client.ViewModels; + +public sealed class ConnectViewModel : ViewModelBase +{ + private readonly IProfileService _profiles; + + private string _host = string.Empty; + private string _username = string.Empty; + private string _inviteCode = string.Empty; + private bool _isRegisterMode; + private ServerProfile? _selectedProfile; + + public ConnectViewModel(IProfileService profiles) + { + _profiles = profiles; + ConnectCommand = new RelayCommand(OnConnect, CanConnect); + SaveProfileCommand = new RelayCommand(OnSaveProfile, CanSaveProfile); + DeleteProfileCommand = new RelayCommand(OnDeleteProfile, () => SelectedProfile is not null); + Profiles = new ObservableCollection(profiles.LoadProfiles()); + } + + public string Host + { + get => _host; + set + { + if (SetField(ref _host, value)) + RaiseCanExecuteChanged(); + } + } + + public string Username + { + get => _username; + set + { + if (SetField(ref _username, value)) + RaiseCanExecuteChanged(); + } + } + + public string InviteCode + { + get => _inviteCode; + set => SetField(ref _inviteCode, value); + } + + public bool IsRegisterMode + { + get => _isRegisterMode; + set => SetField(ref _isRegisterMode, value); + } + + public ServerProfile? SelectedProfile + { + get => _selectedProfile; + set + { + if (SetField(ref _selectedProfile, value) && value is not null) + { + Host = value.Host; + Username = value.LastUsername ?? string.Empty; + } + ((RelayCommand)DeleteProfileCommand).RaiseCanExecuteChanged(); + } + } + + public ObservableCollection Profiles { get; } + + public ICommand ConnectCommand { get; } + public ICommand SaveProfileCommand { get; } + public ICommand DeleteProfileCommand { get; } + + public event Action? ConnectRequested; + + private bool CanConnect() => + !string.IsNullOrWhiteSpace(Host) && + !string.IsNullOrWhiteSpace(Username); + + private void OnConnect() => + ConnectRequested?.Invoke(Host, Username, IsRegisterMode ? InviteCode : null, IsRegisterMode); + + private bool CanSaveProfile() => + !string.IsNullOrWhiteSpace(Host) && !string.IsNullOrWhiteSpace(Username); + + private void OnSaveProfile() + { + var profile = ServerProfile.Create(Host, Host, Username); + var updated = _profiles.AddProfile([.. Profiles], profile); + _profiles.SaveProfiles(updated); + Profiles.Add(profile); + } + + private void OnDeleteProfile() + { + if (SelectedProfile is null) return; + var updated = _profiles.RemoveProfile([.. Profiles], SelectedProfile.Id); + _profiles.SaveProfiles(updated); + Profiles.Remove(SelectedProfile); + SelectedProfile = null; + } + + private void RaiseCanExecuteChanged() + { + ((RelayCommand)ConnectCommand).RaiseCanExecuteChanged(); + ((RelayCommand)SaveProfileCommand).RaiseCanExecuteChanged(); + } +} diff --git a/Client/OwnCord.Client/ViewModels/MainViewModel.cs b/Client/OwnCord.Client/ViewModels/MainViewModel.cs new file mode 100644 index 00000000..63810cf4 --- /dev/null +++ b/Client/OwnCord.Client/ViewModels/MainViewModel.cs @@ -0,0 +1,105 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using OwnCord.Client.Models; + +namespace OwnCord.Client.ViewModels; + +public sealed class MainViewModel : ViewModelBase +{ + private Channel? _selectedChannel; + private string _messageInput = string.Empty; + private bool _isTyping; + + public MainViewModel() + { + Channels = []; + Members = []; + Messages = []; + SendMessageCommand = new RelayCommand(OnSendMessage, () => !string.IsNullOrWhiteSpace(MessageInput) && SelectedChannel is not null); + } + + public ObservableCollection Channels { get; } + public ObservableCollection Members { get; } + public ObservableCollection Messages { get; } + + public Channel? SelectedChannel + { + get => _selectedChannel; + set + { + if (SetField(ref _selectedChannel, value)) + { + Messages.Clear(); + ((RelayCommand)SendMessageCommand).RaiseCanExecuteChanged(); + } + } + } + + public string MessageInput + { + get => _messageInput; + set + { + if (SetField(ref _messageInput, value)) + ((RelayCommand)SendMessageCommand).RaiseCanExecuteChanged(); + } + } + + public bool IsTyping + { + get => _isTyping; + set => SetField(ref _isTyping, value); + } + + public string? TypingText { get; private set; } + + public ICommand SendMessageCommand { get; } + + public event Action? MessageSendRequested; + + public void LoadChannels(IEnumerable channels) + { + Channels.Clear(); + foreach (var ch in channels) Channels.Add(ch); + } + + public void LoadMembers(IEnumerable members) + { + Members.Clear(); + foreach (var m in members) Members.Add(m); + } + + public void AddMessage(Message message) + { + Messages.Add(message); + } + + public void UpdateUnreadCount(long channelId, int count) + { + var idx = Channels.ToList().FindIndex(c => c.Id == channelId); + if (idx < 0) return; + var updated = Channels[idx] with { UnreadCount = count }; + Channels[idx] = updated; + } + + public void ShowTyping(string username) + { + TypingText = $"{username} is typing..."; + IsTyping = true; + OnPropertyChanged(nameof(TypingText)); + } + + public void HideTyping() + { + IsTyping = false; + TypingText = null; + OnPropertyChanged(nameof(TypingText)); + } + + private void OnSendMessage() + { + if (SelectedChannel is null || string.IsNullOrWhiteSpace(MessageInput)) return; + MessageSendRequested?.Invoke(SelectedChannel.Id, MessageInput); + MessageInput = string.Empty; + } +} diff --git a/Client/OwnCord.Client/ViewModels/RelayCommand.cs b/Client/OwnCord.Client/ViewModels/RelayCommand.cs new file mode 100644 index 00000000..8bb027bc --- /dev/null +++ b/Client/OwnCord.Client/ViewModels/RelayCommand.cs @@ -0,0 +1,27 @@ +using System.Windows.Input; + +namespace OwnCord.Client.ViewModels; + +public sealed class RelayCommand(Action execute, Func? canExecute = null) : ICommand +{ + public event EventHandler? CanExecuteChanged; + + public bool CanExecute(object? parameter) => canExecute?.Invoke() ?? true; + + public void Execute(object? parameter) => execute(); + + public void RaiseCanExecuteChanged() => + CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} + +public sealed class RelayCommand(Action execute, Func? canExecute = null) : ICommand +{ + public event EventHandler? CanExecuteChanged; + + public bool CanExecute(object? parameter) => canExecute?.Invoke((T?)parameter) ?? true; + + public void Execute(object? parameter) => execute((T?)parameter); + + public void RaiseCanExecuteChanged() => + CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/Client/OwnCord.Client/ViewModels/SettingsViewModel.cs b/Client/OwnCord.Client/ViewModels/SettingsViewModel.cs new file mode 100644 index 00000000..3f6bb120 --- /dev/null +++ b/Client/OwnCord.Client/ViewModels/SettingsViewModel.cs @@ -0,0 +1,26 @@ +namespace OwnCord.Client.ViewModels; + +public sealed class SettingsViewModel : ViewModelBase +{ + private bool _isDarkTheme; + private bool _mentionsOnly; + private string _pushToTalkKey = "F4"; + + public bool IsDarkTheme + { + get => _isDarkTheme; + set => SetField(ref _isDarkTheme, value); + } + + public bool MentionsOnly + { + get => _mentionsOnly; + set => SetField(ref _mentionsOnly, value); + } + + public string PushToTalkKey + { + get => _pushToTalkKey; + set => SetField(ref _pushToTalkKey, value); + } +} diff --git a/Client/OwnCord.Client/ViewModels/ViewModelBase.cs b/Client/OwnCord.Client/ViewModels/ViewModelBase.cs new file mode 100644 index 00000000..f0ccf1ba --- /dev/null +++ b/Client/OwnCord.Client/ViewModels/ViewModelBase.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace OwnCord.Client.ViewModels; + +public abstract class ViewModelBase : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + protected bool SetField(ref T field, T value, [CallerMemberName] string? name = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + OnPropertyChanged(name); + return true; + } +} diff --git a/Client/OwnCord.Client/Views/ConnectPage.xaml b/Client/OwnCord.Client/Views/ConnectPage.xaml new file mode 100644 index 00000000..75e5515f --- /dev/null +++ b/Client/OwnCord.Client/Views/ConnectPage.xaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + diff --git a/Server/api/router.go b/Server/api/router.go index 8f2ce3b8..3563af47 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/owncord/server/admin" "github.com/owncord/server/auth" "github.com/owncord/server/config" "github.com/owncord/server/db" @@ -46,11 +47,17 @@ func NewRouter(cfg *config.Config, database *db.DB) http.Handler { // Channel and message REST routes. MountChannelRoutes(r, database) + // Voice credentials REST route. + MountVoiceRoutes(r, cfg, database) + // WebSocket hub — WS does its own in-band auth, so no AuthMiddleware here. hub := ws.NewHub(database, limiter) go hub.Run() r.Get("/api/v1/ws", ws.ServeWS(hub, database)) + // Admin panel: static files + REST API (Phase 6). + r.Mount("/admin", admin.NewHandler(database)) + return r } diff --git a/Server/api/voice_handler.go b/Server/api/voice_handler.go new file mode 100644 index 00000000..23368b7e --- /dev/null +++ b/Server/api/voice_handler.go @@ -0,0 +1,121 @@ +package api + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +const voiceCredentialTTL = 24 * time.Hour + +// iceServer describes a single ICE server entry for WebRTC peer connections. +type iceServer struct { + URLs string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` +} + +// voiceCredentialsResponse is the JSON body for GET /api/v1/voice/credentials. +type voiceCredentialsResponse struct { + ICEServers []iceServer `json:"ice_servers"` + ExpiresIn int `json:"expires_in"` +} + +// turnCredentials holds the generated TURN username and HMAC credential. +type turnCredentials struct { + Username string + Credential string +} + +// MountVoiceRoutes registers the voice REST endpoints on r. +func MountVoiceRoutes(r chi.Router, cfg *config.Config, database *db.DB) { + r.Route("/api/v1/voice", func(r chi.Router) { + r.Use(AuthMiddleware(database)) + r.Get("/credentials", handleVoiceCredentials(cfg, database)) + }) +} + +// handleVoiceCredentials returns ICE server credentials for WebRTC. +// Requires a valid session (AuthMiddleware). Generates time-limited TURN +// credentials using HMAC-SHA1 as per the coturn REST API spec. +func handleVoiceCredentials(cfg *config.Config, _ *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, ok := r.Context().Value(UserKey).(*db.User) + if !ok || user == nil { + writeJSON(w, http.StatusUnauthorized, errorResponse{ + Error: "UNAUTHORIZED", + Message: "authentication required", + }) + return + } + + host := serverHost(r) + servers := buildICEServers(user.ID, cfg, host) + + writeJSON(w, http.StatusOK, voiceCredentialsResponse{ + ICEServers: servers, + ExpiresIn: int(voiceCredentialTTL.Seconds()), + }) + } +} + +// buildICEServers constructs the ICE server list for the given user. +func buildICEServers(userID int64, cfg *config.Config, host string) []iceServer { + servers := []iceServer{ + {URLs: fmt.Sprintf("stun:%s:%d", host, cfg.Voice.STUNPort)}, + } + + if cfg.Voice.TURNEnabled && cfg.Voice.TURNSecret != "" { + creds := generateTURNCredentials(userID, cfg.Voice.TURNSecret) + servers = append(servers, iceServer{ + URLs: fmt.Sprintf("turn:%s:%d", host, cfg.Voice.TURNPort), + Username: creds.Username, + Credential: creds.Credential, + }) + } + + return servers +} + +// generateTURNCredentials produces time-limited TURN credentials using HMAC-SHA1. +// Username format: ":" +// Credential: base64(HMAC-SHA1(secret, username)) +func generateTURNCredentials(userID int64, secret string) turnCredentials { + expiry := time.Now().Add(voiceCredentialTTL).Unix() + username := fmt.Sprintf("%d:%d", expiry, userID) + + mac := hmac.New(sha1.New, []byte(secret)) + mac.Write([]byte(username)) + credential := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + return turnCredentials{ + Username: username, + Credential: credential, + } +} + +// serverHost extracts the host for ICE server URLs from the request or falls +// back to "localhost". +func serverHost(r *http.Request) string { + if host := r.Host; host != "" { + // Strip port if present. + for i := len(host) - 1; i >= 0; i-- { + if host[i] == ':' { + return host[:i] + } + if host[i] == ']' { + // IPv6 with no port. + return host + } + } + return host + } + return "localhost" +} diff --git a/Server/api/voice_handler_test.go b/Server/api/voice_handler_test.go new file mode 100644 index 00000000..ac0cf4a9 --- /dev/null +++ b/Server/api/voice_handler_test.go @@ -0,0 +1,344 @@ +package api_test + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + "time" + + "github.com/go-chi/chi/v5" + "github.com/owncord/server/api" + "github.com/owncord/server/auth" + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// newVoiceAPITestDB opens an in-memory DB for voice API tests. +func newVoiceAPITestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: apiTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// buildVoiceRouter returns a chi router with voice routes mounted. +func buildVoiceRouter(database *db.DB, cfg *config.Config) http.Handler { + r := chi.NewRouter() + api.MountVoiceRoutes(r, cfg, database) + return r +} + +// seedAPIUser creates a user+session and returns a valid bearer token. +func seedVoiceAPIUser(t *testing.T, database *db.DB, username string) string { + t.Helper() + _, err := database.CreateUser(username, "hash", 4) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + user, err := database.GetUserByUsername(username) + if err != nil || user == nil { + t.Fatalf("GetUserByUsername: %v", err) + } + token := "test-token-" + username + hash := auth.HashToken(token) + future := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02 15:04:05") + _, err = database.Exec( + `INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`, + user.ID, hash, "test", "127.0.0.1", future, + ) + if err != nil { + t.Fatalf("insert session: %v", err) + } + return token +} + +// voiceGetWithToken performs a GET with Authorization: Bearer header. +func voiceGetWithToken(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +// defaultVoiceCfg returns a Config with a known TURN secret for testing. +func defaultVoiceCfg() *config.Config { + return &config.Config{ + Server: config.ServerConfig{Name: "Test"}, + Voice: config.VoiceConfig{ + TURNSecret: "test-secret-key-12345", + STUNPort: 3478, + TURNPort: 3478, + TURNEnabled: true, + }, + } +} + +// ─── GET /api/v1/voice/credentials ─────────────────────────────────────────── + +func TestVoiceCredentials_Authenticated_Returns200(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "alice") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + if rr.Code != http.StatusOK { + t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestVoiceCredentials_Unauthenticated_Returns401(t *testing.T) { + database := newVoiceAPITestDB(t) + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + req := httptest.NewRequest(http.MethodGet, "/api/v1/voice/credentials", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rr.Code) + } +} + +func TestVoiceCredentials_ResponseContainsIceServers(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "bob") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]interface{} + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + iceServers, ok := resp["ice_servers"] + if !ok { + t.Fatal("response missing ice_servers field") + } + servers, ok := iceServers.([]interface{}) + if !ok || len(servers) == 0 { + t.Error("ice_servers is empty or wrong type") + } +} + +func TestVoiceCredentials_ContainsSTUNEntry(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "carol") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + + servers := resp["ice_servers"].([]interface{}) + foundSTUN := false + for _, s := range servers { + entry := s.(map[string]interface{}) + if urls, ok := entry["urls"].(string); ok { + if len(urls) > 5 && urls[:5] == "stun:" { + foundSTUN = true + break + } + } + } + if !foundSTUN { + t.Error("ice_servers does not contain a STUN entry") + } +} + +func TestVoiceCredentials_ContainsTURNEntry(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "dave") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + + servers := resp["ice_servers"].([]interface{}) + foundTURN := false + for _, s := range servers { + entry := s.(map[string]interface{}) + if urls, ok := entry["urls"].(string); ok { + if len(urls) > 5 && urls[:5] == "turn:" { + foundTURN = true + // TURN entries must have username and credential. + if _, hasUser := entry["username"]; !hasUser { + t.Error("TURN entry missing username") + } + if _, hasCred := entry["credential"]; !hasCred { + t.Error("TURN entry missing credential") + } + break + } + } + } + if !foundTURN { + t.Error("ice_servers does not contain a TURN entry") + } +} + +func TestVoiceCredentials_TURNCredentialIsValidHMAC(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "eve") + secret := "test-secret-key-12345" + cfg := &config.Config{ + Voice: config.VoiceConfig{ + TURNSecret: secret, + STUNPort: 3478, + TURNPort: 3478, + TURNEnabled: true, + }, + } + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + + servers := resp["ice_servers"].([]interface{}) + for _, s := range servers { + entry := s.(map[string]interface{}) + urls, _ := entry["urls"].(string) + if len(urls) < 5 || urls[:5] != "turn:" { + continue + } + username, _ := entry["username"].(string) + credential, _ := entry["credential"].(string) + + if username == "" || credential == "" { + t.Fatal("TURN entry has empty username or credential") + } + + // Verify HMAC-SHA1: credential should be base64(HMAC-SHA1(secret, username)). + mac := hmac.New(sha1.New, []byte(secret)) + mac.Write([]byte(username)) + expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + if credential != expected { + t.Errorf("TURN credential HMAC mismatch\n got: %s\n want: %s", credential, expected) + } + return + } + t.Error("no TURN entry found to validate HMAC") +} + +func TestVoiceCredentials_UsernameContainsTimestampAndUserID(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "frank") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + + servers := resp["ice_servers"].([]interface{}) + for _, s := range servers { + entry := s.(map[string]interface{}) + urls, _ := entry["urls"].(string) + if len(urls) < 5 || urls[:5] != "turn:" { + continue + } + username, _ := entry["username"].(string) + + // Username format: ":". + var ts, uid int64 + if _, err := fmt.Sscanf(username, "%d:%d", &ts, &uid); err != nil { + t.Errorf("TURN username %q is not in format :: %v", username, err) + } + if ts <= time.Now().Unix() { + t.Errorf("TURN username timestamp %d is in the past, want future", ts) + } + if uid <= 0 { + t.Errorf("TURN username userID %d must be positive", uid) + } + return + } + t.Error("no TURN entry found to validate username format") +} + +func TestVoiceCredentials_ResponseContainsExpiresIn(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "grace") + cfg := defaultVoiceCfg() + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + + expiresIn, ok := resp["expires_in"] + if !ok { + t.Fatal("response missing expires_in field") + } + // expires_in should be 86400 (24 hours in seconds). + val, ok := expiresIn.(float64) + if !ok || val != 86400 { + t.Errorf("expires_in = %v, want 86400", expiresIn) + } +} + +func TestVoiceCredentials_TURNDisabled_NoTURNEntry(t *testing.T) { + database := newVoiceAPITestDB(t) + token := seedVoiceAPIUser(t, database, "henry") + cfg := &config.Config{ + Voice: config.VoiceConfig{ + TURNSecret: "secret", + STUNPort: 3478, + TURNPort: 3478, + TURNEnabled: false, // TURN disabled + }, + } + + router := buildVoiceRouter(database, cfg) + rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + + var resp map[string]interface{} + json.NewDecoder(rr.Body).Decode(&resp) + + servers := resp["ice_servers"].([]interface{}) + for _, s := range servers { + entry := s.(map[string]interface{}) + if urls, _ := entry["urls"].(string); len(urls) >= 5 && urls[:5] == "turn:" { + t.Error("TURN entry present when TURNEnabled=false") + } + } +} diff --git a/Server/config/config.go b/Server/config/config.go index 918407b1..53cfe542 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -20,6 +20,15 @@ type Config struct { Database DatabaseConfig `koanf:"database"` TLS TLSConfig `koanf:"tls"` Upload UploadConfig `koanf:"upload"` + Voice VoiceConfig `koanf:"voice"` +} + +// VoiceConfig holds STUN/TURN server settings for WebRTC signaling. +type VoiceConfig struct { + TURNSecret string `koanf:"turn_secret"` // HMAC-SHA1 secret; auto-generated if empty + STUNPort int `koanf:"stun_port"` // default 3478 + TURNPort int `koanf:"turn_port"` // default 3478 + TURNEnabled bool `koanf:"turn_enabled"` // default true } // ServerConfig holds HTTP server settings. @@ -66,6 +75,11 @@ func defaults() Config { MaxSizeMB: 100, StorageDir: "data/uploads", }, + Voice: VoiceConfig{ + STUNPort: 3478, + TURNPort: 3478, + TURNEnabled: true, + }, } } diff --git a/Server/db/admin_queries.go b/Server/db/admin_queries.go new file mode 100644 index 00000000..cb58a416 --- /dev/null +++ b/Server/db/admin_queries.go @@ -0,0 +1,307 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" +) + +// ─── Permission constants ───────────────────────────────────────────────────── + +const ( + permAdministrator = int64(0x40000000) + permManageServer = int64(0x2000000) + permViewAuditLog = int64(0x8000000) +) + +// ─── Server Stats ───────────────────────────────────────────────────────────── + +// GetServerStats returns aggregate counts for the admin dashboard. +// DBSizeBytes is 0 for in-memory databases (page_count * page_size returns +// a meaningful value only for file-backed databases). +func (d *DB) GetServerStats() (*ServerStats, error) { + stats := &ServerStats{} + + if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&stats.UserCount); err != nil { + return nil, fmt.Errorf("GetServerStats users: %w", err) + } + if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM messages WHERE deleted = 0`).Scan(&stats.MessageCount); err != nil { + return nil, fmt.Errorf("GetServerStats messages: %w", err) + } + if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM channels`).Scan(&stats.ChannelCount); err != nil { + return nil, fmt.Errorf("GetServerStats channels: %w", err) + } + if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM invites WHERE revoked = 0`).Scan(&stats.InviteCount); err != nil { + return nil, fmt.Errorf("GetServerStats invites: %w", err) + } + + // page_count * page_size gives the database size in bytes. + // For :memory: databases this still works (returns the in-memory size). + var pageCount, pageSize int64 + if err := d.sqlDB.QueryRow(`PRAGMA page_count`).Scan(&pageCount); err != nil { + return nil, fmt.Errorf("GetServerStats page_count: %w", err) + } + if err := d.sqlDB.QueryRow(`PRAGMA page_size`).Scan(&pageSize); err != nil { + return nil, fmt.Errorf("GetServerStats page_size: %w", err) + } + stats.DBSizeBytes = pageCount * pageSize + + return stats, nil +} + +// ─── User Management ────────────────────────────────────────────────────────── + +// ListAllUsers returns users joined with their role name, ordered by ID. +// limit=0 returns no rows. +func (d *DB) ListAllUsers(limit, offset int) ([]UserWithRole, error) { + rows, err := d.sqlDB.Query( + `SELECT u.id, u.username, u.password, u.avatar, u.role_id, u.totp_secret, + u.status, u.created_at, u.last_seen, u.banned, u.ban_reason, u.ban_expires, + COALESCE(r.name, '') AS role_name + FROM users u + LEFT JOIN roles r ON r.id = u.role_id + ORDER BY u.id ASC + LIMIT ? OFFSET ?`, + limit, offset, + ) + if err != nil { + return nil, fmt.Errorf("ListAllUsers: %w", err) + } + defer rows.Close() + + var result []UserWithRole + for rows.Next() { + var uwr UserWithRole + var banned int + err := rows.Scan( + &uwr.ID, &uwr.Username, &uwr.PasswordHash, &uwr.Avatar, &uwr.RoleID, + &uwr.TOTPSecret, &uwr.Status, &uwr.CreatedAt, &uwr.LastSeen, + &banned, &uwr.BanReason, &uwr.BanExpires, + &uwr.RoleName, + ) + if err != nil { + return nil, fmt.Errorf("ListAllUsers scan: %w", err) + } + uwr.Banned = banned != 0 + result = append(result, uwr) + } + if rows.Err() != nil { + return nil, fmt.Errorf("ListAllUsers rows: %w", rows.Err()) + } + if result == nil { + result = []UserWithRole{} + } + return result, nil +} + +// UpdateUserRole changes the role_id of a user. +func (d *DB) UpdateUserRole(userID, roleID int64) error { + _, err := d.sqlDB.Exec( + `UPDATE users SET role_id = ? WHERE id = ?`, + roleID, userID, + ) + if err != nil { + return fmt.Errorf("UpdateUserRole: %w", err) + } + return nil +} + +// ForceLogoutUser deletes all sessions for the given user ID. +func (d *DB) ForceLogoutUser(userID int64) error { + _, err := d.sqlDB.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID) + if err != nil { + return fmt.Errorf("ForceLogoutUser: %w", err) + } + return nil +} + +// GetUserSessions returns all active sessions for the given user ID. +func (d *DB) GetUserSessions(userID int64) ([]Session, error) { + rows, err := d.sqlDB.Query( + `SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at + FROM sessions WHERE user_id = ? ORDER BY created_at DESC`, + userID, + ) + if err != nil { + return nil, fmt.Errorf("GetUserSessions: %w", err) + } + defer rows.Close() + + var sessions []Session + for rows.Next() { + var s Session + err := rows.Scan( + &s.ID, &s.UserID, &s.TokenHash, &s.Device, &s.IP, + &s.CreatedAt, &s.LastUsed, &s.ExpiresAt, + ) + if err != nil { + return nil, fmt.Errorf("GetUserSessions scan: %w", err) + } + sessions = append(sessions, s) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetUserSessions rows: %w", rows.Err()) + } + if sessions == nil { + sessions = []Session{} + } + return sessions, nil +} + +// ─── Channel Management (admin) ─────────────────────────────────────────────── + +// AdminCreateChannel creates a channel with full field control including position. +func (d *DB) AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) { + res, err := d.sqlDB.Exec( + `INSERT INTO channels (name, type, category, topic, position) + VALUES (?, ?, ?, ?, ?)`, + name, chanType, nullableString(category), nullableString(topic), position, + ) + if err != nil { + return 0, fmt.Errorf("AdminCreateChannel: %w", err) + } + return res.LastInsertId() +} + +// AdminUpdateChannel updates all mutable channel fields. +func (d *DB) AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error { + archivedInt := 0 + if archived { + archivedInt = 1 + } + _, err := d.sqlDB.Exec( + `UPDATE channels + SET name = ?, topic = ?, slow_mode = ?, position = ?, archived = ? + WHERE id = ?`, + name, nullableString(topic), slowMode, position, archivedInt, id, + ) + if err != nil { + return fmt.Errorf("AdminUpdateChannel: %w", err) + } + return nil +} + +// AdminDeleteChannel removes a channel by ID (cascades to messages, etc.). +func (d *DB) AdminDeleteChannel(id int64) error { + _, err := d.sqlDB.Exec(`DELETE FROM channels WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("AdminDeleteChannel: %w", err) + } + return nil +} + +// ─── Audit Log ──────────────────────────────────────────────────────────────── + +// LogAudit inserts an audit log entry. +func (d *DB) LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error { + _, err := d.sqlDB.Exec( + `INSERT INTO audit_log (actor_id, action, target_type, target_id, detail) + VALUES (?, ?, ?, ?, ?)`, + actorID, action, targetType, targetID, detail, + ) + if err != nil { + return fmt.Errorf("LogAudit: %w", err) + } + return nil +} + +// GetAuditLog returns audit log entries ordered newest-first with pagination. +func (d *DB) GetAuditLog(limit, offset int) ([]AuditEntry, error) { + rows, err := d.sqlDB.Query( + `SELECT a.id, a.actor_id, COALESCE(u.username, ''), a.action, + a.target_type, a.target_id, a.detail, a.created_at + FROM audit_log a + LEFT JOIN users u ON u.id = a.actor_id + ORDER BY a.id DESC + LIMIT ? OFFSET ?`, + limit, offset, + ) + if err != nil { + return nil, fmt.Errorf("GetAuditLog: %w", err) + } + defer rows.Close() + + var entries []AuditEntry + for rows.Next() { + var e AuditEntry + if err := rows.Scan( + &e.ID, &e.ActorID, &e.ActorName, &e.Action, + &e.TargetType, &e.TargetID, &e.Detail, &e.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("GetAuditLog scan: %w", err) + } + entries = append(entries, e) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetAuditLog rows: %w", rows.Err()) + } + if entries == nil { + entries = []AuditEntry{} + } + return entries, nil +} + +// ─── Settings ───────────────────────────────────────────────────────────────── + +// GetSetting returns the value for the given settings key. +// Returns an error (wrapping sql.ErrNoRows) when the key does not exist. +func (d *DB) GetSetting(key string) (string, error) { + var value string + err := d.sqlDB.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("GetSetting: key %q not found", key) + } + if err != nil { + return "", fmt.Errorf("GetSetting: %w", err) + } + return value, nil +} + +// SetSetting upserts a setting value for the given key. +func (d *DB) SetSetting(key, value string) error { + _, err := d.sqlDB.Exec( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + key, value, + ) + if err != nil { + return fmt.Errorf("SetSetting: %w", err) + } + return nil +} + +// GetAllSettings returns all settings as a key→value map. +func (d *DB) GetAllSettings() (map[string]string, error) { + rows, err := d.sqlDB.Query(`SELECT key, value FROM settings`) + if err != nil { + return nil, fmt.Errorf("GetAllSettings: %w", err) + } + defer rows.Close() + + result := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("GetAllSettings scan: %w", err) + } + result[k] = v + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetAllSettings rows: %w", rows.Err()) + } + return result, nil +} + +// ─── Backup ─────────────────────────────────────────────────────────────────── + +// BackupTo creates an online backup of the database at the given path using +// SQLite's VACUUM INTO statement. The destination path must not already exist. +// This only works meaningfully for file-backed databases; in-memory databases +// will produce a valid but potentially minimal backup file. +func (d *DB) BackupTo(path string) error { + _, err := d.sqlDB.Exec(fmt.Sprintf("VACUUM INTO '%s'", path)) + if err != nil { + return fmt.Errorf("BackupTo: %w", err) + } + return nil +} diff --git a/Server/db/admin_queries_test.go b/Server/db/admin_queries_test.go new file mode 100644 index 00000000..982eda33 --- /dev/null +++ b/Server/db/admin_queries_test.go @@ -0,0 +1,729 @@ +package db_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "github.com/owncord/server/db" +) + +// adminTestSchema extends testSchema with tables needed for admin queries. +var adminTestSchema = append(testSchema, []byte(` +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + content TEXT NOT NULL, + reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL, + edited_at TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER NOT NULL REFERENCES users(id), + action TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT '', + target_id INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +INSERT OR IGNORE INTO settings (key, value) VALUES + ('server_name', 'OwnCord Server'), + ('motd', 'Welcome!'); +`)...) + +// newAdminTestDB opens an in-memory database with the admin-extended schema. +func newAdminTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: adminTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// ─── GetServerStats ──────────────────────────────────────────────────────────── + +func TestGetServerStats_EmptyDB(t *testing.T) { + database := newAdminTestDB(t) + + stats, err := database.GetServerStats() + if err != nil { + t.Fatalf("GetServerStats() error: %v", err) + } + if stats == nil { + t.Fatal("GetServerStats() returned nil") + } + if stats.UserCount != 0 { + t.Errorf("UserCount = %d, want 0", stats.UserCount) + } + if stats.MessageCount != 0 { + t.Errorf("MessageCount = %d, want 0", stats.MessageCount) + } + if stats.ChannelCount != 0 { + t.Errorf("ChannelCount = %d, want 0", stats.ChannelCount) + } + if stats.InviteCount != 0 { + t.Errorf("InviteCount = %d, want 0", stats.InviteCount) + } + if stats.DBSizeBytes < 0 { + t.Errorf("DBSizeBytes = %d, want >= 0", stats.DBSizeBytes) + } +} + +func TestGetServerStats_WithData(t *testing.T) { + database := newAdminTestDB(t) + + _, err := database.CreateUser("statuser", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + _, err = database.CreateChannel("general", "text", "", "", 0) + if err != nil { + t.Fatalf("CreateChannel error: %v", err) + } + + stats, err := database.GetServerStats() + if err != nil { + t.Fatalf("GetServerStats() error: %v", err) + } + if stats.UserCount != 1 { + t.Errorf("UserCount = %d, want 1", stats.UserCount) + } + if stats.ChannelCount != 1 { + t.Errorf("ChannelCount = %d, want 1", stats.ChannelCount) + } +} + +// ─── ListAllUsers ────────────────────────────────────────────────────────────── + +func TestListAllUsers_Empty(t *testing.T) { + database := newAdminTestDB(t) + + users, err := database.ListAllUsers(50, 0) + if err != nil { + t.Fatalf("ListAllUsers() error: %v", err) + } + if len(users) != 0 { + t.Errorf("ListAllUsers() = %d users, want 0", len(users)) + } +} + +func TestListAllUsers_WithRoleName(t *testing.T) { + database := newAdminTestDB(t) + + _, err := database.CreateUser("alice", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + users, err := database.ListAllUsers(50, 0) + if err != nil { + t.Fatalf("ListAllUsers() error: %v", err) + } + if len(users) != 1 { + t.Fatalf("ListAllUsers() = %d users, want 1", len(users)) + } + if users[0].Username != "alice" { + t.Errorf("Username = %q, want 'alice'", users[0].Username) + } + // RoleName comes from JOIN with roles table + if users[0].RoleName == "" { + t.Error("RoleName should not be empty — JOIN with roles table failed") + } +} + +func TestListAllUsers_Pagination(t *testing.T) { + database := newAdminTestDB(t) + + for i := 0; i < 5; i++ { + _, err := database.CreateUser( + strings.Repeat("u", i+1), + "hash", + 4, + ) + if err != nil { + t.Fatalf("CreateUser[%d] error: %v", i, err) + } + } + + page1, err := database.ListAllUsers(3, 0) + if err != nil { + t.Fatalf("ListAllUsers page1 error: %v", err) + } + if len(page1) != 3 { + t.Errorf("page1 len = %d, want 3", len(page1)) + } + + page2, err := database.ListAllUsers(3, 3) + if err != nil { + t.Fatalf("ListAllUsers page2 error: %v", err) + } + if len(page2) != 2 { + t.Errorf("page2 len = %d, want 2", len(page2)) + } +} + +func TestListAllUsers_ZeroLimit(t *testing.T) { + database := newAdminTestDB(t) + database.CreateUser("zerotest", "hash", 4) + + users, err := database.ListAllUsers(0, 0) + if err != nil { + t.Fatalf("ListAllUsers(0, 0) error: %v", err) + } + // limit=0 should return nothing + if len(users) != 0 { + t.Errorf("ListAllUsers(0, 0) = %d users, want 0", len(users)) + } +} + +// ─── UpdateUserRole ──────────────────────────────────────────────────────────── + +func TestUpdateUserRole(t *testing.T) { + database := newAdminTestDB(t) + + uid, err := database.CreateUser("roleuser", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + if err := database.UpdateUserRole(uid, 2); err != nil { + t.Fatalf("UpdateUserRole() error: %v", err) + } + + user, err := database.GetUserByID(uid) + if err != nil { + t.Fatalf("GetUserByID error: %v", err) + } + if user.RoleID != 2 { + t.Errorf("RoleID = %d, want 2", user.RoleID) + } +} + +func TestUpdateUserRole_NonexistentUser(t *testing.T) { + database := newAdminTestDB(t) + + // UPDATE with no matching rows is not an error + err := database.UpdateUserRole(99999, 2) + if err != nil { + t.Errorf("UpdateUserRole() for nonexistent user returned unexpected error: %v", err) + } +} + +// ─── ForceLogoutUser ─────────────────────────────────────────────────────────── + +func TestForceLogoutUser_DeletesSessions(t *testing.T) { + database := newAdminTestDB(t) + + uid, err := database.CreateUser("logoutuser", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + database.CreateSession(uid, "token1hash", "device1", "127.0.0.1") + database.CreateSession(uid, "token2hash", "device2", "127.0.0.1") + + sessions, err := database.GetUserSessions(uid) + if err != nil { + t.Fatalf("GetUserSessions error: %v", err) + } + if len(sessions) != 2 { + t.Fatalf("expected 2 sessions before logout, got %d", len(sessions)) + } + + if err := database.ForceLogoutUser(uid); err != nil { + t.Fatalf("ForceLogoutUser() error: %v", err) + } + + sessions, err = database.GetUserSessions(uid) + if err != nil { + t.Fatalf("GetUserSessions after logout error: %v", err) + } + if len(sessions) != 0 { + t.Errorf("expected 0 sessions after ForceLogoutUser, got %d", len(sessions)) + } +} + +func TestForceLogoutUser_NoSessions(t *testing.T) { + database := newAdminTestDB(t) + + uid, err := database.CreateUser("nosessions", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + if err := database.ForceLogoutUser(uid); err != nil { + t.Errorf("ForceLogoutUser() on user with no sessions returned error: %v", err) + } +} + +// ─── GetUserSessions ────────────────────────────────────────────────────────── + +func TestGetUserSessions_Empty(t *testing.T) { + database := newAdminTestDB(t) + + uid, err := database.CreateUser("sessionuser", "hash", 4) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + sessions, err := database.GetUserSessions(uid) + if err != nil { + t.Fatalf("GetUserSessions() error: %v", err) + } + if len(sessions) != 0 { + t.Errorf("GetUserSessions() = %d, want 0", len(sessions)) + } +} + +func TestGetUserSessions_IsolatedByUser(t *testing.T) { + database := newAdminTestDB(t) + + uid1, _ := database.CreateUser("user1sess", "hash", 4) + uid2, _ := database.CreateUser("user2sess", "hash", 4) + + database.CreateSession(uid1, "u1t1", "web", "1.2.3.4") + database.CreateSession(uid1, "u1t2", "mobile", "1.2.3.5") + database.CreateSession(uid2, "u2t1", "web", "1.2.3.6") + + sessions, err := database.GetUserSessions(uid1) + if err != nil { + t.Fatalf("GetUserSessions() error: %v", err) + } + if len(sessions) != 2 { + t.Errorf("GetUserSessions(uid1) = %d sessions, want 2", len(sessions)) + } + for _, s := range sessions { + if s.UserID != uid1 { + t.Errorf("session UserID = %d, want %d", s.UserID, uid1) + } + } +} + +// ─── AdminCreateChannel ──────────────────────────────────────────────────────── + +func TestAdminCreateChannel(t *testing.T) { + database := newAdminTestDB(t) + + id, err := database.AdminCreateChannel("announce", "text", "General", "Announcements", 1) + if err != nil { + t.Fatalf("AdminCreateChannel() error: %v", err) + } + if id <= 0 { + t.Errorf("AdminCreateChannel() id = %d, want > 0", id) + } + + ch, err := database.GetChannel(id) + if err != nil { + t.Fatalf("GetChannel() error: %v", err) + } + if ch == nil { + t.Fatal("GetChannel() returned nil after AdminCreateChannel") + } + if ch.Name != "announce" { + t.Errorf("Name = %q, want 'announce'", ch.Name) + } + if ch.Type != "text" { + t.Errorf("Type = %q, want 'text'", ch.Type) + } + if ch.Category != "General" { + t.Errorf("Category = %q, want 'General'", ch.Category) + } + if ch.Topic != "Announcements" { + t.Errorf("Topic = %q, want 'Announcements'", ch.Topic) + } + if ch.Position != 1 { + t.Errorf("Position = %d, want 1", ch.Position) + } +} + +func TestAdminCreateChannel_EmptyOptionals(t *testing.T) { + database := newAdminTestDB(t) + + id, err := database.AdminCreateChannel("simple", "voice", "", "", 0) + if err != nil { + t.Fatalf("AdminCreateChannel() error: %v", err) + } + + ch, err := database.GetChannel(id) + if err != nil { + t.Fatalf("GetChannel() error: %v", err) + } + if ch.Category != "" { + t.Errorf("Category = %q, want ''", ch.Category) + } + if ch.Topic != "" { + t.Errorf("Topic = %q, want ''", ch.Topic) + } +} + +// ─── AdminUpdateChannel ──────────────────────────────────────────────────────── + +func TestAdminUpdateChannel(t *testing.T) { + database := newAdminTestDB(t) + + id, err := database.AdminCreateChannel("old-name", "text", "", "", 0) + if err != nil { + t.Fatalf("AdminCreateChannel() error: %v", err) + } + + if err := database.AdminUpdateChannel(id, "new-name", "new topic", 5, 2, true); err != nil { + t.Fatalf("AdminUpdateChannel() error: %v", err) + } + + ch, err := database.GetChannel(id) + if err != nil { + t.Fatalf("GetChannel() error: %v", err) + } + if ch.Name != "new-name" { + t.Errorf("Name = %q, want 'new-name'", ch.Name) + } + if ch.Topic != "new topic" { + t.Errorf("Topic = %q, want 'new topic'", ch.Topic) + } + if ch.SlowMode != 5 { + t.Errorf("SlowMode = %d, want 5", ch.SlowMode) + } + if ch.Position != 2 { + t.Errorf("Position = %d, want 2", ch.Position) + } + if !ch.Archived { + t.Error("Archived = false, want true") + } +} + +func TestAdminUpdateChannel_Unarchive(t *testing.T) { + database := newAdminTestDB(t) + + id, _ := database.AdminCreateChannel("arch-ch", "text", "", "", 0) + database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, true) + + ch, _ := database.GetChannel(id) + if !ch.Archived { + t.Fatal("channel should be archived") + } + + // Unarchive + database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, false) + ch, _ = database.GetChannel(id) + if ch.Archived { + t.Error("Archived = true after unarchiving, want false") + } +} + +// ─── AdminDeleteChannel ──────────────────────────────────────────────────────── + +func TestAdminDeleteChannel(t *testing.T) { + database := newAdminTestDB(t) + + id, err := database.AdminCreateChannel("to-delete", "text", "", "", 0) + if err != nil { + t.Fatalf("AdminCreateChannel() error: %v", err) + } + + if err := database.AdminDeleteChannel(id); err != nil { + t.Fatalf("AdminDeleteChannel() error: %v", err) + } + + ch, err := database.GetChannel(id) + if err != nil { + t.Fatalf("GetChannel() after delete error: %v", err) + } + if ch != nil { + t.Error("channel should not exist after AdminDeleteChannel") + } +} + +func TestAdminDeleteChannel_NonExistent(t *testing.T) { + database := newAdminTestDB(t) + + // Deleting nonexistent channel should not error + if err := database.AdminDeleteChannel(99999); err != nil { + t.Errorf("AdminDeleteChannel(nonexistent) error: %v", err) + } +} + +// ─── LogAudit / GetAuditLog ──────────────────────────────────────────────────── + +func TestLogAudit_AndRetrieve(t *testing.T) { + database := newAdminTestDB(t) + + uid, err := database.CreateUser("auditor", "hash", 1) + if err != nil { + t.Fatalf("CreateUser error: %v", err) + } + + if err := database.LogAudit(uid, "USER_BANNED", "user", 42, "banned for spam"); err != nil { + t.Fatalf("LogAudit() error: %v", err) + } + + entries, err := database.GetAuditLog(10, 0) + if err != nil { + t.Fatalf("GetAuditLog() error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("GetAuditLog() = %d entries, want 1", len(entries)) + } + + e := entries[0] + if e.ActorID != uid { + t.Errorf("ActorID = %d, want %d", e.ActorID, uid) + } + if e.Action != "USER_BANNED" { + t.Errorf("Action = %q, want 'USER_BANNED'", e.Action) + } + if e.TargetType != "user" { + t.Errorf("TargetType = %q, want 'user'", e.TargetType) + } + if e.TargetID != 42 { + t.Errorf("TargetID = %d, want 42", e.TargetID) + } + if e.Detail != "banned for spam" { + t.Errorf("Detail = %q, want 'banned for spam'", e.Detail) + } + if e.ActorName != "auditor" { + t.Errorf("ActorName = %q, want 'auditor'", e.ActorName) + } + if e.CreatedAt == "" { + t.Error("CreatedAt should not be empty") + } +} + +func TestGetAuditLog_Empty(t *testing.T) { + database := newAdminTestDB(t) + + entries, err := database.GetAuditLog(10, 0) + if err != nil { + t.Fatalf("GetAuditLog() error: %v", err) + } + if len(entries) != 0 { + t.Errorf("GetAuditLog() = %d entries, want 0", len(entries)) + } +} + +func TestGetAuditLog_Pagination(t *testing.T) { + database := newAdminTestDB(t) + + uid, _ := database.CreateUser("auditpager", "hash", 1) + for i := 0; i < 5; i++ { + database.LogAudit(uid, "ACTION", "target", int64(i), "detail") + } + + page1, err := database.GetAuditLog(3, 0) + if err != nil { + t.Fatalf("GetAuditLog page1 error: %v", err) + } + if len(page1) != 3 { + t.Errorf("page1 len = %d, want 3", len(page1)) + } + + page2, err := database.GetAuditLog(3, 3) + if err != nil { + t.Fatalf("GetAuditLog page2 error: %v", err) + } + if len(page2) != 2 { + t.Errorf("page2 len = %d, want 2", len(page2)) + } +} + +func TestGetAuditLog_NewestFirst(t *testing.T) { + database := newAdminTestDB(t) + + uid, _ := database.CreateUser("auditorder", "hash", 1) + database.LogAudit(uid, "FIRST", "", 0, "") + database.LogAudit(uid, "SECOND", "", 0, "") + + entries, err := database.GetAuditLog(10, 0) + if err != nil { + t.Fatalf("GetAuditLog() error: %v", err) + } + if len(entries) < 2 { + t.Fatalf("expected at least 2 entries, got %d", len(entries)) + } + if entries[0].ID <= entries[1].ID { + t.Error("GetAuditLog should return newest entries first (highest ID first)") + } +} + +// ─── GetSetting / SetSetting / GetAllSettings ────────────────────────────────── + +func TestGetSetting_Exists(t *testing.T) { + database := newAdminTestDB(t) + + val, err := database.GetSetting("server_name") + if err != nil { + t.Fatalf("GetSetting() error: %v", err) + } + if val == "" { + t.Error("server_name should not be empty") + } +} + +func TestGetSetting_NotFound(t *testing.T) { + database := newAdminTestDB(t) + + _, err := database.GetSetting("nonexistent_key_xyz") + if err == nil { + t.Error("GetSetting() for nonexistent key should return error") + } +} + +func TestSetSetting_NewKey(t *testing.T) { + database := newAdminTestDB(t) + + if err := database.SetSetting("custom_key", "custom_val"); err != nil { + t.Fatalf("SetSetting() error: %v", err) + } + + val, err := database.GetSetting("custom_key") + if err != nil { + t.Fatalf("GetSetting() after SetSetting error: %v", err) + } + if val != "custom_val" { + t.Errorf("val = %q, want 'custom_val'", val) + } +} + +func TestSetSetting_UpdateExisting(t *testing.T) { + database := newAdminTestDB(t) + + if err := database.SetSetting("server_name", "My Custom Server"); err != nil { + t.Fatalf("SetSetting() update error: %v", err) + } + + val, err := database.GetSetting("server_name") + if err != nil { + t.Fatalf("GetSetting() error: %v", err) + } + if val != "My Custom Server" { + t.Errorf("val = %q, want 'My Custom Server'", val) + } +} + +func TestGetAllSettings_ReturnsMap(t *testing.T) { + database := newAdminTestDB(t) + + settings, err := database.GetAllSettings() + if err != nil { + t.Fatalf("GetAllSettings() error: %v", err) + } + if len(settings) == 0 { + t.Error("GetAllSettings() should return default settings") + } + if _, ok := settings["server_name"]; !ok { + t.Error("GetAllSettings() missing 'server_name'") + } +} + +func TestGetAllSettings_AfterClearing(t *testing.T) { + database := newAdminTestDB(t) + + database.Exec("DELETE FROM settings") + + settings, err := database.GetAllSettings() + if err != nil { + t.Fatalf("GetAllSettings() after clearing error: %v", err) + } + if len(settings) != 0 { + t.Errorf("GetAllSettings() after clearing = %d entries, want 0", len(settings)) + } +} + +// ─── BackupTo ───────────────────────────────────────────────────────────────── + +func TestBackupTo(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "source.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: adminTestSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + + backupPath := filepath.Join(tmpDir, "backup.db") + if err := database.BackupTo(backupPath); err != nil { + t.Fatalf("BackupTo() error: %v", err) + } + + info, err := os.Stat(backupPath) + if err != nil { + t.Fatalf("backup file does not exist: %v", err) + } + if info.Size() == 0 { + t.Error("backup file is empty") + } +} + +func TestBackupTo_CreatesDirectoryFile(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "src.db") + + database, err := db.Open(dbPath) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: adminTestSchema}, + } + db.MigrateFS(database, migrFS) + + // Create nested backup path + backupDir := filepath.Join(tmpDir, "backups") + os.MkdirAll(backupDir, 0o755) + backupPath := filepath.Join(backupDir, "chatserver_20260314_120000.db") + + if err := database.BackupTo(backupPath); err != nil { + t.Fatalf("BackupTo() error: %v", err) + } + + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + t.Error("backup file was not created") + } +} diff --git a/Server/db/auth_queries.go b/Server/db/auth_queries.go index 5e8eb760..ac609414 100644 --- a/Server/db/auth_queries.go +++ b/Server/db/auth_queries.go @@ -96,6 +96,18 @@ func (d *DB) BanUser(id int64, reason string, expires *time.Time) error { return nil } +// UnbanUser removes the ban from a user. +func (d *DB) UnbanUser(id int64) error { + _, err := d.sqlDB.Exec( + `UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ?`, + id, + ) + if err != nil { + return fmt.Errorf("UnbanUser: %w", err) + } + return nil +} + // ─── Session Operations ─────────────────────────────────────────────────────── // CreateSession inserts a new session and returns the session ID. diff --git a/Server/db/models.go b/Server/db/models.go index 214d076d..f73197b3 100644 --- a/Server/db/models.go +++ b/Server/db/models.go @@ -54,15 +54,15 @@ type Role struct { // Channel represents a row in the channels table. type Channel struct { - ID int64 - Name string - Type string - Category string - Topic string - Position int - SlowMode int - Archived bool - CreatedAt string + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category"` + Topic string `json:"topic"` + Position int `json:"position"` + SlowMode int `json:"slow_mode"` + Archived bool `json:"archived"` + CreatedAt string `json:"created_at"` } // Message represents a row in the messages table. @@ -102,5 +102,44 @@ type MessageSearchResult struct { Timestamp string } +// VoiceState represents a row in the voice_states table. +// It tracks which voice channel a user is in and their current audio state. +type VoiceState struct { + UserID int64 + ChannelID int64 + Username string + Muted bool + Deafened bool + Speaking bool +} + +// ServerStats contains aggregate counts for the admin dashboard. +type ServerStats struct { + UserCount int64 `json:"user_count"` + MessageCount int64 `json:"message_count"` + ChannelCount int64 `json:"channel_count"` + InviteCount int64 `json:"invite_count"` + DBSizeBytes int64 `json:"db_size_bytes"` +} + +// UserWithRole extends User with the name of the user's role. +type UserWithRole struct { + User + RoleName string `json:"role_name"` +} + +// AuditEntry represents a single row from the audit_log table joined with the +// actor's username. +type AuditEntry struct { + ID int64 `json:"id"` + ActorID int64 `json:"actor_id"` + ActorName string `json:"actor_name"` + Action string `json:"action"` + TargetType string `json:"target_type"` + TargetID int64 `json:"target_id"` + Detail string `json:"detail"` + CreatedAt string `json:"created_at"` +} + // sessionTTL is the duration a session remains valid after creation. const sessionTTL = 30 * 24 * time.Hour diff --git a/Server/db/voice_queries.go b/Server/db/voice_queries.go new file mode 100644 index 00000000..4cb9ec75 --- /dev/null +++ b/Server/db/voice_queries.go @@ -0,0 +1,172 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" +) + +// JoinVoiceChannel inserts or replaces the user's voice state for the given +// channel. If the user is already in a different channel, the old row is +// replaced. Muted, deafened, and speaking are reset to false on join. +func (d *DB) JoinVoiceChannel(userID, channelID int64) error { + _, err := d.sqlDB.Exec( + `INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking) + VALUES (?, ?, 0, 0, 0) + ON CONFLICT(user_id) DO UPDATE SET + channel_id = excluded.channel_id, + muted = 0, + deafened = 0, + speaking = 0, + joined_at = datetime('now')`, + userID, channelID, + ) + if err != nil { + return fmt.Errorf("JoinVoiceChannel: %w", err) + } + return nil +} + +// LeaveVoiceChannel removes the user's voice state entirely. +// It is safe to call when the user is not in any voice channel. +func (d *DB) LeaveVoiceChannel(userID int64) error { + _, err := d.sqlDB.Exec(`DELETE FROM voice_states WHERE user_id = ?`, userID) + if err != nil { + return fmt.Errorf("LeaveVoiceChannel: %w", err) + } + return nil +} + +// GetVoiceState returns the current voice state for the given user, +// or nil if the user is not in any voice channel. +func (d *DB) GetVoiceState(userID int64) (*VoiceState, error) { + row := d.sqlDB.QueryRow( + `SELECT vs.user_id, vs.channel_id, u.username, + vs.muted, vs.deafened, vs.speaking + FROM voice_states vs + JOIN users u ON u.id = vs.user_id + WHERE vs.user_id = ?`, + userID, + ) + return scanVoiceState(row) +} + +// GetChannelVoiceStates returns all voice states for users currently in the +// given voice channel. +func (d *DB) GetChannelVoiceStates(channelID int64) ([]VoiceState, error) { + rows, err := d.sqlDB.Query( + `SELECT vs.user_id, vs.channel_id, u.username, + vs.muted, vs.deafened, vs.speaking + FROM voice_states vs + JOIN users u ON u.id = vs.user_id + WHERE vs.channel_id = ? + ORDER BY vs.joined_at ASC`, + channelID, + ) + if err != nil { + return nil, fmt.Errorf("GetChannelVoiceStates: %w", err) + } + defer rows.Close() + + var states []VoiceState + for rows.Next() { + vs, scanErr := scanVoiceStateRow(rows) + if scanErr != nil { + return nil, fmt.Errorf("GetChannelVoiceStates scan: %w", scanErr) + } + states = append(states, vs) + } + if rows.Err() != nil { + return nil, fmt.Errorf("GetChannelVoiceStates rows: %w", rows.Err()) + } + if states == nil { + states = []VoiceState{} + } + return states, nil +} + +// UpdateVoiceMute sets the muted field for the given user's voice state. +// It is safe to call when the user is not in any channel (no-op). +func (d *DB) UpdateVoiceMute(userID int64, muted bool) error { + muteInt := boolToInt(muted) + _, err := d.sqlDB.Exec( + `UPDATE voice_states SET muted = ? WHERE user_id = ?`, + muteInt, userID, + ) + if err != nil { + return fmt.Errorf("UpdateVoiceMute: %w", err) + } + return nil +} + +// UpdateVoiceDeafen sets the deafened field for the given user's voice state. +// It is safe to call when the user is not in any channel (no-op). +func (d *DB) UpdateVoiceDeafen(userID int64, deafened bool) error { + deafenInt := boolToInt(deafened) + _, err := d.sqlDB.Exec( + `UPDATE voice_states SET deafened = ? WHERE user_id = ?`, + deafenInt, userID, + ) + if err != nil { + return fmt.Errorf("UpdateVoiceDeafen: %w", err) + } + return nil +} + +// ClearVoiceState removes a user's voice state on disconnect. +// Equivalent to LeaveVoiceChannel but named to clarify the disconnect use case. +func (d *DB) ClearVoiceState(userID int64) error { + _, err := d.sqlDB.Exec(`DELETE FROM voice_states WHERE user_id = ?`, userID) + if err != nil { + return fmt.Errorf("ClearVoiceState: %w", err) + } + return nil +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// scanVoiceState scans a single *sql.Row into a VoiceState. +// Returns nil (not an error) when the row is not found. +func scanVoiceState(row *sql.Row) (*VoiceState, error) { + vs := &VoiceState{} + var muted, deafened, speaking int + err := row.Scan( + &vs.UserID, &vs.ChannelID, &vs.Username, + &muted, &deafened, &speaking, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("scanVoiceState: %w", err) + } + vs.Muted = muted != 0 + vs.Deafened = deafened != 0 + vs.Speaking = speaking != 0 + return vs, nil +} + +// scanVoiceStateRow scans a single row from *sql.Rows into a VoiceState. +func scanVoiceStateRow(rows *sql.Rows) (VoiceState, error) { + vs := VoiceState{} + var muted, deafened, speaking int + err := rows.Scan( + &vs.UserID, &vs.ChannelID, &vs.Username, + &muted, &deafened, &speaking, + ) + if err != nil { + return vs, fmt.Errorf("scanVoiceStateRow: %w", err) + } + vs.Muted = muted != 0 + vs.Deafened = deafened != 0 + vs.Speaking = speaking != 0 + return vs, nil +} + +// boolToInt converts a bool to 0/1 for SQLite storage. +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/Server/db/voice_queries_test.go b/Server/db/voice_queries_test.go new file mode 100644 index 00000000..5822ebfe --- /dev/null +++ b/Server/db/voice_queries_test.go @@ -0,0 +1,423 @@ +package db_test + +import ( + "testing" + "testing/fstest" + + "github.com/owncord/server/db" +) + +// voiceTestSchema adds the voice_states table on top of the base schema. +var voiceTestSchema = append(testSchema, []byte(` +CREATE TABLE IF NOT EXISTS voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); +`)...) + +var channelSchema = []byte(` +CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text', + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +`) + +// newVoiceTestDB opens an in-memory DB with users, channels, and voice_states. +func newVoiceTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: testSchema}, + "002_channels.sql": {Data: channelSchema}, + "003_voice.sql": {Data: []byte(` +CREATE TABLE IF NOT EXISTS voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); +`)}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// seedVoiceUser creates a user and returns its ID. +func seedVoiceUser(t *testing.T, database *db.DB, username string) int64 { + t.Helper() + id, err := database.CreateUser(username, "hash", 4) + if err != nil { + t.Fatalf("seedVoiceUser: %v", err) + } + return id +} + +// seedVoiceChannel creates a voice-type channel and returns its ID. +func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 { + t.Helper() + id, err := database.CreateChannel(name, "voice", "", "", 0) + if err != nil { + t.Fatalf("seedVoiceChannel: %v", err) + } + return id +} + +// ─── JoinVoiceChannel ───────────────────────────────────────────────────────── + +func TestVoice_JoinVoiceChannel_Success(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "alice") + chanID := seedVoiceChannel(t, database, "general-voice") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil { + t.Fatal("GetVoiceState returned nil after join") + } + if state.UserID != userID { + t.Errorf("UserID = %d, want %d", state.UserID, userID) + } + if state.ChannelID != chanID { + t.Errorf("ChannelID = %d, want %d", state.ChannelID, chanID) + } + if state.Muted { + t.Error("Muted = true after join, want false") + } + if state.Deafened { + t.Error("Deafened = true after join, want false") + } +} + +func TestVoice_JoinVoiceChannel_ReplacesExistingState(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "bob") + chan1 := seedVoiceChannel(t, database, "voice-1") + chan2 := seedVoiceChannel(t, database, "voice-2") + + if err := database.JoinVoiceChannel(userID, chan1); err != nil { + t.Fatalf("first JoinVoiceChannel: %v", err) + } + // Join a different channel — should replace the old state. + if err := database.JoinVoiceChannel(userID, chan2); err != nil { + t.Fatalf("second JoinVoiceChannel: %v", err) + } + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil { + t.Fatal("GetVoiceState returned nil after re-join") + } + if state.ChannelID != chan2 { + t.Errorf("ChannelID = %d, want %d (new channel)", state.ChannelID, chan2) + } +} + +func TestVoice_JoinVoiceChannel_SameChannel_Idempotent(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "carol") + chanID := seedVoiceChannel(t, database, "voice-same") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("first join: %v", err) + } + // Joining same channel again should not error. + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("second join same channel: %v", err) + } +} + +// ─── LeaveVoiceChannel ──────────────────────────────────────────────────────── + +func TestVoice_LeaveVoiceChannel_ClearsState(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "dave") + chanID := seedVoiceChannel(t, database, "voice-leave") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.LeaveVoiceChannel(userID); err != nil { + t.Fatalf("LeaveVoiceChannel: %v", err) + } + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState after leave: %v", err) + } + if state != nil { + t.Error("GetVoiceState returned non-nil after leave, want nil") + } +} + +func TestVoice_LeaveVoiceChannel_NoState_NoError(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "eve") + + // Leaving when not in any channel should not error. + if err := database.LeaveVoiceChannel(userID); err != nil { + t.Fatalf("LeaveVoiceChannel (not in channel): %v", err) + } +} + +// ─── GetVoiceState ──────────────────────────────────────────────────────────── + +func TestVoice_GetVoiceState_NotFound(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "frank") + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState(not found): %v", err) + } + if state != nil { + t.Error("GetVoiceState returned non-nil for user not in voice") + } +} + +func TestVoice_GetVoiceState_IncludesUsername(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "grace") + chanID := seedVoiceChannel(t, database, "voice-username") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil { + t.Fatal("GetVoiceState returned nil") + } + if state.Username != "grace" { + t.Errorf("Username = %q, want %q", state.Username, "grace") + } +} + +// ─── GetChannelVoiceStates ──────────────────────────────────────────────────── + +func TestVoice_GetChannelVoiceStates_Empty(t *testing.T) { + database := newVoiceTestDB(t) + chanID := seedVoiceChannel(t, database, "empty-voice") + + states, err := database.GetChannelVoiceStates(chanID) + if err != nil { + t.Fatalf("GetChannelVoiceStates: %v", err) + } + if len(states) != 0 { + t.Errorf("got %d states, want 0", len(states)) + } +} + +func TestVoice_GetChannelVoiceStates_MultipleUsers(t *testing.T) { + database := newVoiceTestDB(t) + u1 := seedVoiceUser(t, database, "henry") + u2 := seedVoiceUser(t, database, "iris") + u3 := seedVoiceUser(t, database, "jack") + chanID := seedVoiceChannel(t, database, "multi-voice") + otherChan := seedVoiceChannel(t, database, "other-voice") + + if err := database.JoinVoiceChannel(u1, chanID); err != nil { + t.Fatalf("join u1: %v", err) + } + if err := database.JoinVoiceChannel(u2, chanID); err != nil { + t.Fatalf("join u2: %v", err) + } + // u3 joins a different channel — should not appear. + if err := database.JoinVoiceChannel(u3, otherChan); err != nil { + t.Fatalf("join u3: %v", err) + } + + states, err := database.GetChannelVoiceStates(chanID) + if err != nil { + t.Fatalf("GetChannelVoiceStates: %v", err) + } + if len(states) != 2 { + t.Errorf("got %d states, want 2", len(states)) + } + + ids := map[int64]bool{u1: true, u2: true} + for _, s := range states { + if !ids[s.UserID] { + t.Errorf("unexpected user_id %d in channel states", s.UserID) + } + } +} + +// ─── UpdateVoiceMute ────────────────────────────────────────────────────────── + +func TestVoice_UpdateVoiceMute_True(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "kate") + chanID := seedVoiceChannel(t, database, "voice-mute") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceMute(userID, true); err != nil { + t.Fatalf("UpdateVoiceMute(true): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || !state.Muted { + t.Error("Muted = false after UpdateVoiceMute(true)") + } +} + +func TestVoice_UpdateVoiceMute_False(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "leo") + chanID := seedVoiceChannel(t, database, "voice-unmute") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceMute(userID, true); err != nil { + t.Fatalf("UpdateVoiceMute(true): %v", err) + } + if err := database.UpdateVoiceMute(userID, false); err != nil { + t.Fatalf("UpdateVoiceMute(false): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || state.Muted { + t.Error("Muted = true after UpdateVoiceMute(false), want false") + } +} + +func TestVoice_UpdateVoiceMute_NotInChannel_NoError(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "mia") + + // Muting when not in a channel should not error. + if err := database.UpdateVoiceMute(userID, true); err != nil { + t.Fatalf("UpdateVoiceMute for non-member: %v", err) + } +} + +// ─── UpdateVoiceDeafen ──────────────────────────────────────────────────────── + +func TestVoice_UpdateVoiceDeafen_True(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "noah") + chanID := seedVoiceChannel(t, database, "voice-deafen") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceDeafen(userID, true); err != nil { + t.Fatalf("UpdateVoiceDeafen(true): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || !state.Deafened { + t.Error("Deafened = false after UpdateVoiceDeafen(true)") + } +} + +func TestVoice_UpdateVoiceDeafen_False(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "olivia") + chanID := seedVoiceChannel(t, database, "voice-undeafen") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.UpdateVoiceDeafen(userID, true); err != nil { + t.Fatalf("UpdateVoiceDeafen(true): %v", err) + } + if err := database.UpdateVoiceDeafen(userID, false); err != nil { + t.Fatalf("UpdateVoiceDeafen(false): %v", err) + } + + state, _ := database.GetVoiceState(userID) + if state == nil || state.Deafened { + t.Error("Deafened = true after UpdateVoiceDeafen(false), want false") + } +} + +// ─── ClearVoiceState ────────────────────────────────────────────────────────── + +func TestVoice_ClearVoiceState_RemovesState(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "pedro") + chanID := seedVoiceChannel(t, database, "voice-clear") + + if err := database.JoinVoiceChannel(userID, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + if err := database.ClearVoiceState(userID); err != nil { + t.Fatalf("ClearVoiceState: %v", err) + } + + state, err := database.GetVoiceState(userID) + if err != nil { + t.Fatalf("GetVoiceState after clear: %v", err) + } + if state != nil { + t.Error("GetVoiceState returned non-nil after ClearVoiceState") + } +} + +func TestVoice_ClearVoiceState_NotInChannel_NoError(t *testing.T) { + database := newVoiceTestDB(t) + userID := seedVoiceUser(t, database, "quinn") + + if err := database.ClearVoiceState(userID); err != nil { + t.Fatalf("ClearVoiceState for non-member: %v", err) + } +} + +// ─── Cascade delete ─────────────────────────────────────────────────────────── + +func TestVoice_GetChannelVoiceStates_IncludesUsername(t *testing.T) { + database := newVoiceTestDB(t) + u1 := seedVoiceUser(t, database, "rachel") + chanID := seedVoiceChannel(t, database, "voice-name-check") + + if err := database.JoinVoiceChannel(u1, chanID); err != nil { + t.Fatalf("JoinVoiceChannel: %v", err) + } + + states, err := database.GetChannelVoiceStates(chanID) + if err != nil { + t.Fatalf("GetChannelVoiceStates: %v", err) + } + if len(states) != 1 { + t.Fatalf("got %d states, want 1", len(states)) + } + if states[0].Username != "rachel" { + t.Errorf("Username = %q, want %q", states[0].Username, "rachel") + } +} diff --git a/Server/migrations/002_voice_states.sql b/Server/migrations/002_voice_states.sql new file mode 100644 index 00000000..939093a2 --- /dev/null +++ b/Server/migrations/002_voice_states.sql @@ -0,0 +1,12 @@ +-- Phase 5: Voice state tracking table. +-- Stores which voice channel each user is currently connected to, +-- along with their mute/deafen/speaking state. +CREATE TABLE IF NOT EXISTS voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); diff --git a/Server/migrations/003_audit_log.sql b/Server/migrations/003_audit_log.sql new file mode 100644 index 00000000..e08c4064 --- /dev/null +++ b/Server/migrations/003_audit_log.sql @@ -0,0 +1,45 @@ +-- Migration 003: Re-create audit_log with Phase-6 canonical column names. +-- +-- Phase-1 audit_log used: user_id (nullable), action, target_type, target_id, +-- details, timestamp +-- Phase-6 audit_log uses: actor_id (NOT NULL DEFAULT 0), action, target_type, +-- target_id, detail, created_at +-- +-- IDEMPOTENCY STRATEGY +-- -------------------- +-- A sentinel table "audit_log_migrated_003" acts as a run-once guard. +-- • First run: sentinel does not exist → migration executes normally. +-- • Second run: "CREATE TABLE IF NOT EXISTS audit_log_migrated_003" is a +-- no-op, but the body still runs. However, because audit_log already has +-- the new schema, the INSERT … SELECT below safely copies actor_id/detail/ +-- created_at which now exist. +-- +-- Rather than fighting SQLite's lack of conditional DDL, we use a helper +-- table whose existence signals completion, and write the INSERT to work +-- with BOTH the old and new column names by coalescing them. +-- SQLite will return an error on unknown column names, so we cannot SELECT +-- user_id when actor_id exists. Instead, we guard with the run-once table. +-- +-- On re-run: CREATE TABLE IF NOT EXISTS audit_log_v6 creates a fresh helper, +-- INSERT OR IGNORE … SELECT from audit_log (new schema) copies actor_id etc., +-- DROP TABLE IF EXISTS audit_log removes current data, +-- ALTER TABLE audit_log_v6 RENAME TO audit_log recreates it. +-- This is safe because the second-run SELECT reads actor_id (not user_id). + +CREATE TABLE IF NOT EXISTS audit_log_v6 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER NOT NULL DEFAULT 0, + action TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT '', + target_id INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +DROP TABLE IF EXISTS audit_log; + +ALTER TABLE audit_log_v6 RENAME TO audit_log; + +-- Keep the legacy index name so existing tests remain green. +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id); diff --git a/Server/ws/handlers.go b/Server/ws/handlers.go index 41219c59..960b4646 100644 --- a/Server/ws/handlers.go +++ b/Server/ws/handlers.go @@ -62,6 +62,18 @@ func (h *Hub) handleMessage(c *Client, raw []byte) { h.handleTyping(c, env.Payload) case "presence_update": h.handlePresence(c, env.Payload) + case "voice_join": + h.handleVoiceJoin(c, env.Payload) + case "voice_leave": + h.handleVoiceLeave(c) + case "voice_mute": + h.handleVoiceMute(c, env.Payload) + case "voice_deafen": + h.handleVoiceDeafen(c, env.Payload) + case "voice_offer", "voice_answer", "voice_ice": + h.handleVoiceSignal(c, env.Type, env.Payload) + case "soundboard_play": + h.handleSoundboard(c, env.Payload) default: slog.Warn("ws handleMessage unknown type", "type", env.Type, "user_id", c.userID) c.sendMsg(buildErrorMsg("UNKNOWN_TYPE", fmt.Sprintf("unknown message type: %s", env.Type))) diff --git a/Server/ws/messages.go b/Server/ws/messages.go index 8aeeb09e..965a99bc 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -3,6 +3,8 @@ package ws import ( "encoding/json" "fmt" + + "github.com/owncord/server/db" ) // envelope is the common wrapper for all WebSocket messages. @@ -129,6 +131,53 @@ func buildTypingMsg(channelID, userID int64, username string) []byte { }) } +// buildVoiceState constructs a voice_state server→client broadcast. +func buildVoiceState(state db.VoiceState) []byte { + return buildJSON(map[string]interface{}{ + "type": "voice_state", + "payload": map[string]interface{}{ + "channel_id": state.ChannelID, + "user_id": state.UserID, + "username": state.Username, + "muted": state.Muted, + "deafened": state.Deafened, + "speaking": state.Speaking, + }, + }) +} + +// buildVoiceLeave constructs a voice_leave server→client broadcast. +func buildVoiceLeave(channelID, userID int64) []byte { + return buildJSON(map[string]interface{}{ + "type": "voice_leave", + "payload": map[string]interface{}{ + "channel_id": channelID, + "user_id": userID, + }, + }) +} + +// buildVoiceSignalRelay relays a signaling message (offer/answer/ice) as-is to +// channel members. The original payload is embedded unchanged. +// channelID is provided for future filtering logic. +func buildVoiceSignalRelay(msgType string, _ int64, data json.RawMessage) []byte { + return buildJSON(map[string]interface{}{ + "type": msgType, + "payload": data, + }) +} + +// buildSoundboardPlay constructs a soundboard_play broadcast. +func buildSoundboardPlay(soundID string, userID int64) []byte { + return buildJSON(map[string]interface{}{ + "type": "soundboard_play", + "payload": map[string]interface{}{ + "sound_id": soundID, + "user_id": userID, + }, + }) +} + // parseChannelID safely extracts channel_id from a raw payload map. func parseChannelID(payload json.RawMessage) (int64, error) { var p struct { diff --git a/Server/ws/serve.go b/Server/ws/serve.go index f6de9d61..8075a69c 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -87,6 +87,7 @@ func writePump(ctx context.Context, conn *websocket.Conn, c *Client) { func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) { defer func() { hub.Unregister(c) + hub.handleVoiceLeave(c) if c.user != nil { _ = hub.db.UpdateUserStatus(c.userID, "offline") hub.BroadcastToAll(buildPresenceMsg(c.userID, "offline")) @@ -188,13 +189,41 @@ func buildReady(database *db.DB) ([]byte, error) { if err != nil { return nil, fmt.Errorf("buildReady ListRoles: %w", err) } + + // Collect all active voice states across every voice channel. + voiceStates, err := collectAllVoiceStates(database, channels) + if err != nil { + // Non-fatal: send empty list rather than failing the whole ready payload. + slog.Warn("buildReady collectAllVoiceStates", "err", err) + voiceStates = []db.VoiceState{} + } + return buildJSON(map[string]interface{}{ "type": "ready", "payload": map[string]interface{}{ "channels": channels, "members": []interface{}{}, - "voice_states": []interface{}{}, + "voice_states": voiceStates, "roles": roles, }, }), nil } + +// collectAllVoiceStates gathers voice states for all voice-type channels. +func collectAllVoiceStates(database *db.DB, channels []db.Channel) ([]db.VoiceState, error) { + var all []db.VoiceState + for _, ch := range channels { + if ch.Type != "voice" { + continue + } + states, err := database.GetChannelVoiceStates(ch.ID) + if err != nil { + return nil, err + } + all = append(all, states...) + } + if all == nil { + all = []db.VoiceState{} + } + return all, nil +} diff --git a/Server/ws/voice_handlers.go b/Server/ws/voice_handlers.go new file mode 100644 index 00000000..f96c7641 --- /dev/null +++ b/Server/ws/voice_handlers.go @@ -0,0 +1,193 @@ +package ws + +import ( + "encoding/json" + "fmt" + "log/slog" + "time" +) + +// Voice permission bits (from SCHEMA.md). +const ( + permConnectVoice = int64(0x200) // bit 9 + permUseSoundboard = int64(0x100) // bit 8 +) + +// Voice rate limit settings. +const ( + voiceSignalRateLimit = 20 + voiceSignalWindow = time.Second + soundboardRateLimit = 1 + soundboardWindow = 3 * time.Second +) + +// handleVoiceJoin processes a voice_join message. +// 1. Checks CONNECT_VOICE permission. +// 2. Persists join in DB. +// 3. Broadcasts voice_state to channel. +// 4. Sends all current voice states in the channel back to the joiner. +func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { + if !h.hasChannelPerm(c, 0, permConnectVoice) { + c.sendMsg(buildErrorMsg("FORBIDDEN", "missing CONNECT_VOICE permission")) + return + } + + channelID, err := parseChannelID(payload) + if err != nil || channelID <= 0 { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be a positive integer")) + return + } + + if err := h.db.JoinVoiceChannel(c.userID, channelID); err != nil { + slog.Error("ws handleVoiceJoin JoinVoiceChannel", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to join voice channel")) + return + } + + state, err := h.db.GetVoiceState(c.userID) + if err != nil || state == nil { + slog.Error("ws handleVoiceJoin GetVoiceState", "err", err, "user_id", c.userID) + return + } + + // Broadcast the joiner's state to all clients currently in this voice channel. + h.BroadcastToChannel(channelID, buildVoiceState(*state)) + + // Send existing channel voice states to the joiner. + existing, err := h.db.GetChannelVoiceStates(channelID) + if err != nil { + slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err) + return + } + for _, vs := range existing { + if vs.UserID == c.userID { + continue // skip the joiner themselves + } + c.sendMsg(buildVoiceState(vs)) + } +} + +// handleVoiceLeave processes an explicit voice_leave message or a disconnect. +// 1. Removes voice state from DB. +// 2. Broadcasts voice_leave to the channel the user was in. +func (h *Hub) handleVoiceLeave(c *Client) { + state, err := h.db.GetVoiceState(c.userID) + if err != nil { + slog.Error("ws handleVoiceLeave GetVoiceState", "err", err, "user_id", c.userID) + } + + if leaveErr := h.db.LeaveVoiceChannel(c.userID); leaveErr != nil { + slog.Error("ws handleVoiceLeave LeaveVoiceChannel", "err", leaveErr, "user_id", c.userID) + } + + if state != nil { + h.BroadcastToChannel(state.ChannelID, buildVoiceLeave(state.ChannelID, c.userID)) + } +} + +// handleVoiceMute processes a voice_mute message. +// 1. Parses muted bool. +// 2. Updates DB. +// 3. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceMute(c *Client, payload json.RawMessage) { + var p struct { + Muted bool `json:"muted"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_mute payload")) + return + } + + if err := h.db.UpdateVoiceMute(c.userID, p.Muted); err != nil { + slog.Error("ws handleVoiceMute UpdateVoiceMute", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to update mute state")) + return + } + + h.broadcastVoiceStateUpdate(c) +} + +// handleVoiceDeafen processes a voice_deafen message. +// 1. Parses deafened bool. +// 2. Updates DB. +// 3. Broadcasts voice_state update to channel. +func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) { + var p struct { + Deafened bool `json:"deafened"` + } + if err := json.Unmarshal(payload, &p); err != nil { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_deafen payload")) + return + } + + if err := h.db.UpdateVoiceDeafen(c.userID, p.Deafened); err != nil { + slog.Error("ws handleVoiceDeafen UpdateVoiceDeafen", "err", err, "user_id", c.userID) + c.sendMsg(buildErrorMsg("INTERNAL", "failed to update deafen state")) + return + } + + h.broadcastVoiceStateUpdate(c) +} + +// handleVoiceSignal relays voice_offer, voice_answer, and voice_ice messages. +// 1. Rate limits at 20/sec per user. +// 2. Parses channel_id from payload. +// 3. Relays the message (with original type) to all other channel members. +// SDP/ICE content is not inspected or logged. +func (h *Hub) handleVoiceSignal(c *Client, msgType string, payload json.RawMessage) { + ratKey := fmt.Sprintf("voice_signal:%d", c.userID) + if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) { + c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many signaling messages")) + return + } + + channelID, err := parseChannelID(payload) + if err != nil || channelID <= 0 { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be a positive integer")) + return + } + + relayed := buildVoiceSignalRelay(msgType, channelID, payload) + h.broadcastExclude(channelID, c.userID, relayed) +} + +// handleSoundboard processes a soundboard_play message. +// 1. Rate limits at 1 per 3 seconds. +// 2. Checks USE_SOUNDBOARD permission. +// 3. Broadcasts soundboard_play (with user_id) to all connected clients. +func (h *Hub) handleSoundboard(c *Client, payload json.RawMessage) { + ratKey := fmt.Sprintf("soundboard:%d", c.userID) + if !h.limiter.Allow(ratKey, soundboardRateLimit, soundboardWindow) { + c.sendMsg(buildErrorMsg("RATE_LIMITED", "soundboard is on cooldown")) + return + } + + if !h.hasChannelPerm(c, 0, permUseSoundboard) { + c.sendMsg(buildErrorMsg("FORBIDDEN", "missing USE_SOUNDBOARD permission")) + return + } + + var p struct { + SoundID string `json:"sound_id"` + } + if err := json.Unmarshal(payload, &p); err != nil || p.SoundID == "" { + c.sendMsg(buildErrorMsg("BAD_REQUEST", "sound_id is required")) + return + } + + h.BroadcastToAll(buildSoundboardPlay(p.SoundID, c.userID)) +} + +// broadcastVoiceStateUpdate fetches the current voice state for the client +// and broadcasts it to all members of the voice channel they are in. +func (h *Hub) broadcastVoiceStateUpdate(c *Client) { + state, err := h.db.GetVoiceState(c.userID) + if err != nil { + slog.Error("ws broadcastVoiceStateUpdate GetVoiceState", "err", err, "user_id", c.userID) + return + } + if state == nil { + return // user not in a voice channel — nothing to broadcast + } + h.BroadcastToChannel(state.ChannelID, buildVoiceState(*state)) +} diff --git a/Server/ws/voice_handlers_test.go b/Server/ws/voice_handlers_test.go new file mode 100644 index 00000000..ea1d0e1e --- /dev/null +++ b/Server/ws/voice_handlers_test.go @@ -0,0 +1,767 @@ +package ws_test + +import ( + "encoding/json" + "testing" + "testing/fstest" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/ws" +) + +// voiceSchema extends hubTestSchema with the voice_states table. +var voiceSchema = append(hubTestSchema, []byte(` +CREATE TABLE IF NOT EXISTS voice_states ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted INTEGER NOT NULL DEFAULT 0, + deafened INTEGER NOT NULL DEFAULT 0, + speaking INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); +`)...) + +// openVoiceTestDB opens an in-memory DB with the full voice schema. +func openVoiceTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + + migrFS := fstest.MapFS{ + "001_schema.sql": {Data: voiceSchema}, + } + if err := db.MigrateFS(database, migrFS); err != nil { + t.Fatalf("MigrateFS: %v", err) + } + return database +} + +// newVoiceHub creates a hub+db suitable for voice handler tests. +func newVoiceHub(t *testing.T) (*ws.Hub, *db.DB) { + t.Helper() + database := openVoiceTestDB(t) + limiter := auth.NewRateLimiter() + hub := ws.NewHub(database, limiter) + go hub.Run() + t.Cleanup(func() { hub.Stop() }) + return hub, database +} + +// seedVoiceOwner inserts an Owner-role user for permission-passing tests. +func seedVoiceOwner(t *testing.T, database *db.DB, username string) *db.User { + t.Helper() + _, err := database.CreateUser(username, "hash", 1) // roleID=1 → Owner + if err != nil { + t.Fatalf("seedVoiceOwner CreateUser: %v", err) + } + user, err := database.GetUserByUsername(username) + if err != nil || user == nil { + t.Fatalf("seedVoiceOwner GetUserByUsername: %v", err) + } + return user +} + +// seedVoiceChan creates a voice-type channel. +func seedVoiceChan(t *testing.T, database *db.DB, name string) int64 { + t.Helper() + id, err := database.CreateChannel(name, "voice", "", "", 0) + if err != nil { + t.Fatalf("seedVoiceChan: %v", err) + } + return id +} + +// voiceJoinMsg builds a raw voice_join WebSocket message. +func voiceJoinMsg(channelID int64) []byte { + raw, _ := json.Marshal(map[string]interface{}{ + "type": "voice_join", + "payload": map[string]interface{}{"channel_id": channelID}, + }) + return raw +} + +// voiceLeaveMsg builds a raw voice_leave WebSocket message. +func voiceLeaveMsg() []byte { + raw, _ := json.Marshal(map[string]interface{}{ + "type": "voice_leave", + "payload": map[string]interface{}{}, + }) + return raw +} + +// voiceMuteMsg builds a voice_mute message. +func voiceMuteMsg(muted bool) []byte { + raw, _ := json.Marshal(map[string]interface{}{ + "type": "voice_mute", + "payload": map[string]interface{}{"muted": muted}, + }) + return raw +} + +// voiceDeafenMsg builds a voice_deafen message. +func voiceDeafenMsg(deafened bool) []byte { + raw, _ := json.Marshal(map[string]interface{}{ + "type": "voice_deafen", + "payload": map[string]interface{}{"deafened": deafened}, + }) + return raw +} + +// voiceSignalMsg builds a voice_offer/answer/ice message. +func voiceSignalMsg(msgType string, channelID int64, sdp string) []byte { + raw, _ := json.Marshal(map[string]interface{}{ + "type": msgType, + "payload": map[string]interface{}{ + "channel_id": channelID, + "sdp": sdp, + }, + }) + return raw +} + +// voiceICEMsg builds a voice_ice message. +func voiceICEMsg(channelID int64, candidate string) []byte { + raw, _ := json.Marshal(map[string]interface{}{ + "type": "voice_ice", + "payload": map[string]interface{}{ + "channel_id": channelID, + "candidate": candidate, + }, + }) + return raw +} + +// extractType parses a JSON message and returns the "type" field. +func extractType(t *testing.T, msg []byte) string { + t.Helper() + var env map[string]interface{} + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("extractType unmarshal: %v", err) + } + typ, _ := env["type"].(string) + return typ +} + +// drainChan reads all pending messages from ch into a slice. +func drainChan(ch <-chan []byte) [][]byte { + var msgs [][]byte + for { + select { + case m := <-ch: + msgs = append(msgs, m) + default: + return msgs + } + } +} + +// ─── voice_join ─────────────────────────────────────────────────────────────── + +func TestVoice_Join_SetsStateInDB(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "alice") + chanID := seedVoiceChan(t, database, "vc-alice") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil { + t.Fatal("voice state is nil after voice_join") + } + if state.ChannelID != chanID { + t.Errorf("ChannelID = %d, want %d", state.ChannelID, chanID) + } +} + +func TestVoice_Join_BroadcastsVoiceState(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "bob") + chanID := seedVoiceChan(t, database, "vc-bob") + + // A second client in the same voice channel to receive the broadcast. + send2 := make(chan []byte, 16) + user2 := seedVoiceOwner(t, database, "bob2") + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + // Look for a voice_state message in either send or send2. + foundVoiceState := false + allMsgs := append(drainChan(send), drainChan(send2)...) + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_state" { + foundVoiceState = true + break + } + } + if !foundVoiceState { + t.Error("voice_state broadcast not received after voice_join") + } +} + +func TestVoice_Join_SendsCurrentStatesToJoiner(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-existing") + + // user1 joins first. + user1 := seedVoiceOwner(t, database, "carol1") + send1 := make(chan []byte, 16) + c1 := ws.NewTestClientWithUser(hub, user1, chanID, send1) + hub.Register(c1) + time.Sleep(20 * time.Millisecond) + hub.HandleMessageForTest(c1, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + // Drain send1 to clear join broadcast. + drainChan(send1) + + // user2 joins — should receive voice_state for user1. + user2 := seedVoiceOwner(t, database, "carol2") + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + time.Sleep(20 * time.Millisecond) + hub.HandleMessageForTest(c2, voiceJoinMsg(chanID)) + time.Sleep(50 * time.Millisecond) + + // user2 should have received a voice_state for user1. + msgs2 := drainChan(send2) + voiceStateCount := 0 + for _, msg := range msgs2 { + if extractType(t, msg) == "voice_state" { + voiceStateCount++ + } + } + if voiceStateCount == 0 { + t.Error("joining client did not receive existing voice states") + } +} + +func TestVoice_Join_MissingChannelID_SendsError(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "dave") + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + badMsg, _ := json.Marshal(map[string]interface{}{ + "type": "voice_join", + "payload": map[string]interface{}{"channel_id": 0}, + }) + hub.HandleMessageForTest(c, badMsg) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractType(t, m) == "error" { + found = true + } + } + if !found { + t.Error("expected error response for invalid channel_id") + } +} + +func TestVoice_Join_NoPermission_SendsError(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-noperm") + + // Member role (id=4) has permissions 1049089. Bit 9 (0x200 = 512) for CONNECT_VOICE. + // Check if member has it: 1049089 & 512 = 512, so member DOES have it. + // We need a role without it. We'll set a custom role using direct DB exec. + // For simplicity, use a user with nil user (no role) to fail perm check. + send := make(chan []byte, 16) + c := ws.NewTestClient(hub, 9999, send) // no user set → hasChannelPerm returns false + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractType(t, m) == "error" { + found = true + } + } + if !found { + t.Error("expected FORBIDDEN error for client without CONNECT_VOICE permission") + } +} + +// ─── voice_leave ────────────────────────────────────────────────────────────── + +func TestVoice_Leave_ClearsStateInDB(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "eve") + chanID := seedVoiceChan(t, database, "vc-eve") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceLeaveMsg()) + time.Sleep(30 * time.Millisecond) + + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState after leave: %v", err) + } + if state != nil { + t.Error("voice state still set after voice_leave") + } +} + +func TestVoice_Leave_BroadcastsVoiceLeave(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-leave-bcast") + + user := seedVoiceOwner(t, database, "frank") + user2 := seedVoiceOwner(t, database, "frank2") + + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + drainChan(send2) + + hub.HandleMessageForTest(c, voiceLeaveMsg()) + time.Sleep(50 * time.Millisecond) + + allMsgs := append(drainChan(send), drainChan(send2)...) + found := false + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_leave" { + found = true + break + } + } + if !found { + t.Error("voice_leave broadcast not received after voice_leave message") + } +} + +// ─── voice_mute ─────────────────────────────────────────────────────────────── + +func TestVoice_Mute_UpdatesStateInDB(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "grace") + chanID := seedVoiceChan(t, database, "vc-grace") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceMuteMsg(true)) + time.Sleep(30 * time.Millisecond) + + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil || !state.Muted { + t.Error("Muted = false after voice_mute(true)") + } +} + +func TestVoice_Mute_BroadcastsVoiceState(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-mute-bcast") + + user := seedVoiceOwner(t, database, "henry") + user2 := seedVoiceOwner(t, database, "henry2") + + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + drainChan(send2) + + hub.HandleMessageForTest(c, voiceMuteMsg(true)) + time.Sleep(50 * time.Millisecond) + + allMsgs := append(drainChan(send), drainChan(send2)...) + found := false + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_state" { + found = true + break + } + } + if !found { + t.Error("voice_state broadcast not received after voice_mute") + } +} + +// ─── voice_deafen ───────────────────────────────────────────────────────────── + +func TestVoice_Deafen_UpdatesStateInDB(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "iris") + chanID := seedVoiceChan(t, database, "vc-iris") + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceDeafenMsg(true)) + time.Sleep(30 * time.Millisecond) + + state, err := database.GetVoiceState(user.ID) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if state == nil || !state.Deafened { + t.Error("Deafened = false after voice_deafen(true)") + } +} + +func TestVoice_Deafen_BroadcastsVoiceState(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-deafen-bcast") + + user := seedVoiceOwner(t, database, "jack") + user2 := seedVoiceOwner(t, database, "jack2") + + send2 := make(chan []byte, 16) + c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2) + hub.Register(c2) + + send := make(chan []byte, 16) + c := ws.NewTestClientWithUser(hub, user, chanID, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(c, voiceJoinMsg(chanID)) + time.Sleep(30 * time.Millisecond) + drainChan(send) + drainChan(send2) + + hub.HandleMessageForTest(c, voiceDeafenMsg(true)) + time.Sleep(50 * time.Millisecond) + + allMsgs := append(drainChan(send), drainChan(send2)...) + found := false + for _, msg := range allMsgs { + if extractType(t, msg) == "voice_state" { + found = true + break + } + } + if !found { + t.Error("voice_state broadcast not received after voice_deafen") + } +} + +// ─── voice signaling relay ──────────────────────────────────────────────────── + +func TestVoice_Signal_RelaysToOtherChannelMembers(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-signal") + + sender := seedVoiceOwner(t, database, "kate") + receiver := seedVoiceOwner(t, database, "kate2") + outsider := seedVoiceOwner(t, database, "kate3") + + sendR := make(chan []byte, 16) + cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR) + hub.Register(cR) + + sendO := make(chan []byte, 16) + cO := ws.NewTestClientWithUser(hub, outsider, 999, sendO) // different channel + hub.Register(cO) + + sendS := make(chan []byte, 16) + cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS) + hub.Register(cS) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(cS, voiceSignalMsg("voice_offer", chanID, "v=0...")) + time.Sleep(50 * time.Millisecond) + + // Receiver in same channel should get the signal. + receiverMsgs := drainChan(sendR) + found := false + for _, msg := range receiverMsgs { + if extractType(t, msg) == "voice_offer" { + found = true + break + } + } + if !found { + t.Error("receiver in channel did not receive voice_offer relay") + } + + // Outsider in different channel should NOT get it. + outsiderMsgs := drainChan(sendO) + for _, msg := range outsiderMsgs { + if extractType(t, msg) == "voice_offer" { + t.Error("outsider received voice_offer, should not have") + } + } + + // Sender should NOT receive their own signal. + senderMsgs := drainChan(sendS) + for _, msg := range senderMsgs { + if extractType(t, msg) == "voice_offer" { + t.Error("sender received their own voice_offer, should not have") + } + } +} + +func TestVoice_Signal_ICERelayed(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-ice") + + sender := seedVoiceOwner(t, database, "leo") + receiver := seedVoiceOwner(t, database, "leo2") + + sendR := make(chan []byte, 16) + cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR) + hub.Register(cR) + + sendS := make(chan []byte, 16) + cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS) + hub.Register(cS) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(cS, voiceICEMsg(chanID, "candidate:...")) + time.Sleep(50 * time.Millisecond) + + receiverMsgs := drainChan(sendR) + found := false + for _, msg := range receiverMsgs { + if extractType(t, msg) == "voice_ice" { + found = true + break + } + } + if !found { + t.Error("receiver did not receive relayed voice_ice") + } +} + +func TestVoice_Signal_RateLimit_BlocksExcess(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-ratelimit") + + sender := seedVoiceOwner(t, database, "mia") + receiver := seedVoiceOwner(t, database, "mia2") + + sendR := make(chan []byte, 256) + cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR) + hub.Register(cR) + + sendS := make(chan []byte, 256) + cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS) + hub.Register(cS) + time.Sleep(20 * time.Millisecond) + + // Send 30 signals rapidly — limit is 20/sec, so some should be dropped. + for i := 0; i < 30; i++ { + hub.HandleMessageForTest(cS, voiceSignalMsg("voice_offer", chanID, "v=0...")) + } + time.Sleep(50 * time.Millisecond) + + receivedCount := len(drainChan(sendR)) + if receivedCount >= 30 { + t.Errorf("received %d signals, expected fewer due to rate limit", receivedCount) + } + + // Sender should receive at least one RATE_LIMITED error. + senderMsgs := drainChan(sendS) + foundError := false + for _, msg := range senderMsgs { + if extractType(t, msg) == "error" { + foundError = true + break + } + } + if !foundError { + t.Error("expected RATE_LIMITED error to sender after exceeding signal rate limit") + } +} + +// ─── soundboard ─────────────────────────────────────────────────────────────── + +func TestVoice_Soundboard_BroadcastsToAll(t *testing.T) { + hub, database := newVoiceHub(t) + + user := seedVoiceOwner(t, database, "noah") + listener := seedVoiceOwner(t, database, "noah2") + + sendL := make(chan []byte, 16) + cL := ws.NewTestClientWithUser(hub, listener, 0, sendL) + hub.Register(cL) + + sendS := make(chan []byte, 16) + cS := ws.NewTestClientWithUser(hub, user, 0, sendS) + hub.Register(cS) + time.Sleep(20 * time.Millisecond) + + soundMsg, _ := json.Marshal(map[string]interface{}{ + "type": "soundboard_play", + "payload": map[string]interface{}{"sound_id": "abc-uuid-123"}, + }) + hub.HandleMessageForTest(cS, soundMsg) + time.Sleep(50 * time.Millisecond) + + listenerMsgs := drainChan(sendL) + found := false + for _, msg := range listenerMsgs { + if extractType(t, msg) == "soundboard_play" { + found = true + break + } + } + if !found { + t.Error("listener did not receive soundboard_play broadcast") + } +} + +func TestVoice_Soundboard_NoPermission_SendsError(t *testing.T) { + hub, _ := newVoiceHub(t) + + // Client with no user set → permission check fails. + send := make(chan []byte, 16) + c := ws.NewTestClient(hub, 8888, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + soundMsg, _ := json.Marshal(map[string]interface{}{ + "type": "soundboard_play", + "payload": map[string]interface{}{"sound_id": "abc"}, + }) + hub.HandleMessageForTest(c, soundMsg) + time.Sleep(30 * time.Millisecond) + + msgs := drainChan(send) + found := false + for _, m := range msgs { + if extractType(t, m) == "error" { + found = true + } + } + if !found { + t.Error("expected FORBIDDEN error for soundboard without permission") + } +} + +func TestVoice_Soundboard_RateLimit(t *testing.T) { + hub, database := newVoiceHub(t) + user := seedVoiceOwner(t, database, "olivia") + + send := make(chan []byte, 64) + c := ws.NewTestClientWithUser(hub, user, 0, send) + hub.Register(c) + time.Sleep(20 * time.Millisecond) + + soundMsg, _ := json.Marshal(map[string]interface{}{ + "type": "soundboard_play", + "payload": map[string]interface{}{"sound_id": "x"}, + }) + + // Send 5 soundboard plays rapidly — limit is 1 per 3 sec. + for i := 0; i < 5; i++ { + hub.HandleMessageForTest(c, soundMsg) + } + time.Sleep(50 * time.Millisecond) + + msgs := drainChan(send) + errCount := 0 + for _, m := range msgs { + if extractType(t, m) == "error" { + errCount++ + } + } + if errCount == 0 { + t.Error("expected rate limit errors for rapid soundboard plays") + } +} + +// ─── handleMessage dispatch ─────────────────────────────────────────────────── + +func TestVoice_HandleMessage_VoiceAnswer_Relayed(t *testing.T) { + hub, database := newVoiceHub(t) + chanID := seedVoiceChan(t, database, "vc-answer") + + sender := seedVoiceOwner(t, database, "pedro") + receiver := seedVoiceOwner(t, database, "pedro2") + + sendR := make(chan []byte, 16) + cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR) + hub.Register(cR) + + sendS := make(chan []byte, 16) + cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS) + hub.Register(cS) + time.Sleep(20 * time.Millisecond) + + hub.HandleMessageForTest(cS, voiceSignalMsg("voice_answer", chanID, "v=0 answer...")) + time.Sleep(50 * time.Millisecond) + + receiverMsgs := drainChan(sendR) + found := false + for _, msg := range receiverMsgs { + if extractType(t, msg) == "voice_answer" { + found = true + break + } + } + if !found { + t.Error("receiver did not receive relayed voice_answer") + } +} From 5aa216d99138510b1827634e89bc84ba20903deb Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 21:37:47 +0100 Subject: [PATCH 007/100] fix: correct embed path (static not admin/static) and simplify audit_log migration --- Server/admin/admin.go | 6 +++-- Server/migrations/003_audit_log.sql | 34 ++++++++++------------------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/Server/admin/admin.go b/Server/admin/admin.go index bb703272..386292c1 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -27,8 +27,10 @@ func NewHandler(database *db.DB) http.Handler { // Admin REST API mounted at /api r.Mount("/api", NewAdminAPI(database)) - // Static files — serve from the embedded FS sub-tree. - staticFS, err := fs.Sub(staticFiles, "admin/static") + // Static files — serve from the "static" sub-tree of the embedded FS. + // The //go:embed static directive in this package embeds as "static/…", + // not "admin/static/…", so we strip just "static". + staticFS, err := fs.Sub(staticFiles, "static") if err != nil { // This is a programming error (wrong embed path) and should never // happen in production. Panic so it surfaces immediately in tests. diff --git a/Server/migrations/003_audit_log.sql b/Server/migrations/003_audit_log.sql index e08c4064..6155ddbe 100644 --- a/Server/migrations/003_audit_log.sql +++ b/Server/migrations/003_audit_log.sql @@ -5,26 +5,16 @@ -- Phase-6 audit_log uses: actor_id (NOT NULL DEFAULT 0), action, target_type, -- target_id, detail, created_at -- --- IDEMPOTENCY STRATEGY --- -------------------- --- A sentinel table "audit_log_migrated_003" acts as a run-once guard. --- • First run: sentinel does not exist → migration executes normally. --- • Second run: "CREATE TABLE IF NOT EXISTS audit_log_migrated_003" is a --- no-op, but the body still runs. However, because audit_log already has --- the new schema, the INSERT … SELECT below safely copies actor_id/detail/ --- created_at which now exist. +-- IDEMPOTENCY +-- ----------- +-- This migration is safe to re-run: +-- 1. CREATE TABLE IF NOT EXISTS audit_log_v6 → no-op if already exists +-- 2. DROP TABLE IF EXISTS audit_log → no-op if already gone +-- 3. ALTER TABLE audit_log_v6 RENAME TO audit_log → recreates the table -- --- Rather than fighting SQLite's lack of conditional DDL, we use a helper --- table whose existence signals completion, and write the INSERT to work --- with BOTH the old and new column names by coalescing them. --- SQLite will return an error on unknown column names, so we cannot SELECT --- user_id when actor_id exists. Instead, we guard with the run-once table. --- --- On re-run: CREATE TABLE IF NOT EXISTS audit_log_v6 creates a fresh helper, --- INSERT OR IGNORE … SELECT from audit_log (new schema) copies actor_id etc., --- DROP TABLE IF EXISTS audit_log removes current data, --- ALTER TABLE audit_log_v6 RENAME TO audit_log recreates it. --- This is safe because the second-run SELECT reads actor_id (not user_id). +-- On second run audit_log_v6 is created fresh (empty), the current audit_log +-- is dropped, and audit_log_v6 is renamed. Audit log data is not preserved +-- across re-runs, which is acceptable for a development-phase migration. CREATE TABLE IF NOT EXISTS audit_log_v6 ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -40,6 +30,6 @@ DROP TABLE IF EXISTS audit_log; ALTER TABLE audit_log_v6 RENAME TO audit_log; --- Keep the legacy index name so existing tests remain green. -CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id); +-- Keep the legacy index name so db_test.go TestMigrateCreatesIndexes passes. +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id); From aa2a1cf0254cb368c2dcf09314f6889db1d43a16 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 21:58:06 +0100 Subject: [PATCH 008/100] ci: add GitHub Actions CI and release workflows CI runs build+test+lint for both server and client on push/PR. Release workflow builds binaries, generates SHA256 checksums, and creates a GitHub Release with auto-generated notes on tag push. --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++ .github/workflows/release.yml | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..fcb29ab5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: + - main + - "feature/*" + pull_request: + branches: + - main + - "feature/*" + +jobs: + server-build-test: + name: Server Build & Test + runs-on: windows-latest + defaults: + run: + working-directory: Server/ + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Build server + run: go build -o chatserver.exe -ldflags "-s -w" . + + - name: Run tests with coverage + run: go test ./... -cover + + - name: Lint + uses: golangci/golangci-lint-action@v6 + with: + working-directory: Server/ + + client-build-test: + name: Client Build & Test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Build client + run: dotnet build Client/OwnCord.Client.sln + + - name: Run client tests + run: dotnet test Client/OwnCord.Client.Tests/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..0a379f0a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + name: Build & Release + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Extract version from tag + shell: bash + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Build server + shell: bash + run: cd Server && go build -o chatserver.exe -ldflags "-s -w -X main.version=$VERSION" . + + - name: Build client single-file + run: >- + dotnet publish Client/OwnCord.Client/OwnCord.Client.csproj + -c Release -r win-x64 --self-contained + -p:PublishSingleFile=true + -p:IncludeNativeLibrariesForSelfExtract=true + -o dist/client + + - name: Generate SHA256 checksums + shell: pwsh + run: | + $serverHash = (Get-FileHash -Path Server/chatserver.exe -Algorithm SHA256).Hash.ToLower() + $clientHash = (Get-FileHash -Path dist/client/OwnCord.Client.exe -Algorithm SHA256).Hash.ToLower() + "$serverHash chatserver.exe" | Out-File -FilePath checksums.sha256 -Encoding utf8 -NoNewline + "`n$clientHash OwnCord.Client.exe" | Out-File -FilePath checksums.sha256 -Encoding utf8 -Append -NoNewline + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: >- + gh release create ${{ github.ref_name }} + --generate-notes + Server/chatserver.exe + dist/client/OwnCord.Client.exe + checksums.sha256 From 8ab2c93f1e5ea38738e855cd854c8df48924e7d4 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 21:58:18 +0100 Subject: [PATCH 009/100] feat: add server_restart WebSocket message type for update notifications --- Server/ws/hub.go | 7 +++++++ Server/ws/messages.go | 11 +++++++++++ Server/ws/messages_test.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 Server/ws/messages_test.go diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 5df8185c..52a40e46 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -98,6 +98,13 @@ func (h *Hub) BroadcastToAll(msg []byte) { h.broadcast <- broadcastMsg{channelID: 0, msg: msg} } +// BroadcastServerRestart sends a server_restart message to all connected clients. +// reason describes why the server is restarting (e.g., "update"). +// delaySeconds tells clients how long until the server actually shuts down. +func (h *Hub) BroadcastServerRestart(reason string, delaySeconds int) { + h.BroadcastToAll(buildServerRestartMsg(reason, delaySeconds)) +} + // SendToUser delivers msg directly to the client identified by userID. // Returns true if the client was found and the message was queued. func (h *Hub) SendToUser(userID int64, msg []byte) bool { diff --git a/Server/ws/messages.go b/Server/ws/messages.go index 965a99bc..ca739395 100644 --- a/Server/ws/messages.go +++ b/Server/ws/messages.go @@ -178,6 +178,17 @@ func buildSoundboardPlay(soundID string, userID int64) []byte { }) } +// buildServerRestartMsg constructs a server_restart broadcast. +func buildServerRestartMsg(reason string, delaySeconds int) []byte { + return buildJSON(map[string]interface{}{ + "type": "server_restart", + "payload": map[string]interface{}{ + "reason": reason, + "delay_seconds": delaySeconds, + }, + }) +} + // parseChannelID safely extracts channel_id from a raw payload map. func parseChannelID(payload json.RawMessage) (int64, error) { var p struct { diff --git a/Server/ws/messages_test.go b/Server/ws/messages_test.go new file mode 100644 index 00000000..ff2231cc --- /dev/null +++ b/Server/ws/messages_test.go @@ -0,0 +1,29 @@ +package ws + +import ( + "encoding/json" + "testing" +) + +func TestBuildServerRestartMsg(t *testing.T) { + msg := buildServerRestartMsg("update", 5) + var env struct { + Type string `json:"type"` + Payload struct { + Reason string `json:"reason"` + DelaySeconds int `json:"delay_seconds"` + } `json:"payload"` + } + if err := json.Unmarshal(msg, &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if env.Type != "server_restart" { + t.Errorf("type = %q, want server_restart", env.Type) + } + if env.Payload.Reason != "update" { + t.Errorf("reason = %q, want update", env.Payload.Reason) + } + if env.Payload.DelaySeconds != 5 { + t.Errorf("delay_seconds = %d, want 5", env.Payload.DelaySeconds) + } +} From 7398756515b997864d1b0e640cc7cb9a33cadb9c Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 21:58:53 +0100 Subject: [PATCH 010/100] docs: add README, SECURITY, CONTRIBUTING, and setup guides --- CONTRIBUTING.md | 58 +++++++++++++++++++++++++++++++++ README.md | 72 +++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 27 ++++++++++++++++ docs/port-forwarding.md | 30 +++++++++++++++++ docs/quick-start.md | 25 ++++++++++++++ docs/tailscale.md | 21 ++++++++++++ 6 files changed, 233 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/port-forwarding.md create mode 100644 docs/quick-start.md create mode 100644 docs/tailscale.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..6767acdb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,58 @@ +# Contributing + +## Development Setup + +- **Go 1.25+** for the server +- **.NET 8 SDK** for the client + +Install dev tools: + +```bash +go install github.com/air-verse/air@latest # hot reload +go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest # linter +``` + +## Branch Naming + +- `feature/` — new features +- `fix/` — bug fixes +- `docs/` — documentation changes + +## Commit Format + +Use conventional commits: + +``` +feat: add thread support to channels +fix: prevent duplicate WebSocket connections +refactor: extract permission checks into middleware +docs: update quick-start guide +test: add integration tests for invite flow +chore: bump Go dependencies +perf: cache role permissions in memory +ci: add lint step to GitHub Actions +``` + +## Pull Request Process + +1. Branch from `main` +2. CI must pass (build + test + lint) +3. Request code review +4. Squash merge preferred + +## Test Requirements + +Target **80%+ coverage**. Follow TDD workflow: write tests first, then implement. + +### Server Tests + +```bash +cd Server +go test ./... -cover +``` + +### Client Tests + +```bash +dotnet test Client/OwnCord.Client.Tests/ +``` diff --git a/README.md b/README.md index b7e97bf7..57c911d9 100644 --- a/README.md +++ b/README.md @@ -1 +1,73 @@ # OwnCord + +Self-hosted Windows chat platform with voice, video, and an admin panel. + +## Features + +- Real-time text chat with threads and reactions +- Voice and video channels (WebRTC) +- Role-based permissions with custom roles +- File sharing with inline previews +- Full-text message search +- Web-based admin panel +- Invite-only registration +- TLS encryption (self-signed or custom cert) + +## Quick Start + +1. Download the latest release from GitHub Releases +2. Run `chatserver.exe` — generates `config.yaml` on first run +3. Open `https://localhost:8443/admin` to access the admin panel +4. Generate an invite code, share it with friends +5. Friends download `OwnCord.Client.exe` and connect using your server address + +## Building from Source + +### Server + +```bash +cd Server +go build -o chatserver.exe -ldflags "-s -w -X main.version=1.0.0" . +``` + +### Client + +```bash +dotnet publish Client/OwnCord.Client/OwnCord.Client.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true +``` + +## Architecture + +OwnCord consists of a Go server and a WPF/.NET 8 desktop client. The server handles all business logic, storage, and real-time communication. Clients connect over WebSocket for chat events, REST for history and uploads, and WebRTC for voice/video. + +``` +┌─────────────────────┐ ┌─────────────────────┐ +│ OwnCord Client │ │ OwnCord Server │ +│ (WPF / .NET 8) │ │ (Go) │ +│ │ │ │ +│ ┌───────────────┐ │ WSS │ ┌───────────────┐ │ +│ │ Chat UI │──┼────────►│ │ WebSocket Hub│ │ +│ └───────────────┘ │ │ └───────────────┘ │ +│ ┌───────────────┐ │ HTTPS │ ┌───────────────┐ │ +│ │ REST Client │──┼────────►│ │ REST API │ │ +│ └───────────────┘ │ │ └───────────────┘ │ +│ ┌───────────────┐ │ WebRTC │ ┌───────────────┐ │ +│ │ Voice/Video │──┼────────►│ │ TURN/STUN │ │ +│ └───────────────┘ │ │ └───────────────┘ │ +└─────────────────────┘ │ ┌───────────────┐ │ + │ │ SQLite DB │ │ + │ └───────────────┘ │ + └─────────────────────┘ +``` + +## Documentation + +- [Quick Start Guide](docs/quick-start.md) +- [Port Forwarding Guide](docs/port-forwarding.md) +- [Tailscale Guide](docs/tailscale.md) +- [Contributing](CONTRIBUTING.md) +- [Security](SECURITY.md) + +## License + +MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..8fe56f1d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Reporting Vulnerabilities + +Use GitHub Security Advisories to report vulnerabilities: go to Settings > Security > Advisories and create a new advisory. + +**Do NOT open public issues for security bugs.** + +## Response Timeline + +- **Acknowledgment:** Within 48 hours +- **Critical fixes:** Within 7 days +- **Non-critical fixes:** Included in the next release + +## Known Limitations + +- No code signing yet — binaries are verified via SHA256 checksums only + +## Security Hardening Checklist for Operators + +- [ ] Enable TLS (self-signed is the default; custom certs recommended for production) +- [ ] Keep invite-only registration enabled (default) +- [ ] Set a strong admin password +- [ ] Configure rate limits (defaults are sensible but review for your use case) +- [ ] Run regular backups via the admin panel +- [ ] Keep the server updated (admin panel shows available updates) +- [ ] Firewall: only expose port 8443 (HTTPS) and 3478 (TURN/STUN for voice) diff --git a/docs/port-forwarding.md b/docs/port-forwarding.md new file mode 100644 index 00000000..c4c1f818 --- /dev/null +++ b/docs/port-forwarding.md @@ -0,0 +1,30 @@ +# Port Forwarding Guide + +## Why + +Friends outside your LAN need a way to reach your server. Port forwarding tells your router to send incoming traffic on a specific port to your server machine. + +## Steps + +1. **Find your router's admin page** — usually `192.168.1.1` or `192.168.0.1`. Check your gateway IP with `ipconfig` (Windows) or `ip route` (Linux). +2. **Find the port forwarding section** — may be listed under "NAT", "Virtual Servers", or "Firewall" depending on your router. +3. **Add a rule for the server:** + - External port: `8443` + - Internal IP: your server machine's local IP + - Internal port: `8443` + - Protocol: TCP +4. **Add a rule for voice chat** (if using voice/video): + - External port: `3478` + - Internal IP: your server machine's local IP + - Internal port: `3478` + - Protocol: UDP +5. **Find your public IP** at a site like `whatismyip.com`. +6. **Share your public IP and port** with friends: `your.public.ip:8443` + +## Troubleshooting + +Windows Firewall may block incoming connections. `chatserver.exe` should prompt on first run to allow access. If not, manually add a firewall rule for port 8443 (TCP) and 3478 (UDP). + +## Dynamic IP + +If your public IP changes frequently, consider a Dynamic DNS service (e.g., No-IP, DuckDNS) so friends can use a stable hostname instead of a raw IP address. diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 00000000..2e5a2c8d --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,25 @@ +# Quick Start Guide + +## Step 1: Download + +Get the latest release from the GitHub Releases page. Download `chatserver.exe` and `OwnCord.Client.exe`. + +## Step 2: Run the Server + +Run `chatserver.exe`. On first run it generates `config.yaml` with sensible defaults and a self-signed TLS certificate. The server starts on `https://0.0.0.0:8443`. + +## Step 3: Admin Setup + +Open `https://localhost:8443/admin` in a browser. The first registered user with the Owner role can manage the server. + +## Step 4: Create Invites + +In the admin panel, go to invite management and generate invite codes for your friends. + +## Step 5: Connect Clients + +Friends run `OwnCord.Client.exe`, enter your server address (IP or domain + port 8443), and redeem their invite code to register. + +## Networking + +If friends are outside your local network, see the [Port Forwarding Guide](port-forwarding.md) or use [Tailscale](tailscale.md) for zero-config networking. diff --git a/docs/tailscale.md b/docs/tailscale.md new file mode 100644 index 00000000..4d45ffd4 --- /dev/null +++ b/docs/tailscale.md @@ -0,0 +1,21 @@ +# Tailscale Guide (Zero-Config Alternative) + +## What is Tailscale + +Tailscale is a mesh VPN that creates encrypted tunnels between your devices using WireGuard. No port forwarding, no dynamic DNS, and it works behind CGNAT. Free for personal use. + +## Setup + +1. **Install Tailscale** on the server machine and each client machine: https://tailscale.com/download +2. **Sign in** with the same Tailscale account (or share the machine using Tailscale's sharing feature) +3. **Find the server's Tailscale IP** — shown in the Tailscale app, typically `100.x.y.z` +4. **Disable TLS in config** — set `tls.mode` to `"off"` in `config.yaml` since Tailscale already encrypts all traffic with WireGuard +5. **Connect clients** using the Tailscale IP: `100.x.y.z:8443` + +## Benefits + +- No port forwarding needed +- Works behind CGNAT and strict firewalls +- Encrypted by default (WireGuard) +- Stable IPs that don't change +- Easy to add/remove friends via the Tailscale admin console From 27b7c000da42fa2fba2971c0882eea79e0bed0a5 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 21:59:58 +0100 Subject: [PATCH 011/100] feat: add updater package with GitHub Release checking and checksum verification --- Server/config/config.go | 10 ++ Server/go.mod | 1 + Server/go.sum | 8 +- Server/updater/updater.go | 306 +++++++++++++++++++++++++++++++++ Server/updater/updater_test.go | 265 ++++++++++++++++++++++++++++ 5 files changed, 586 insertions(+), 4 deletions(-) create mode 100644 Server/updater/updater.go create mode 100644 Server/updater/updater_test.go diff --git a/Server/config/config.go b/Server/config/config.go index 53cfe542..50393817 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -21,6 +21,12 @@ type Config struct { TLS TLSConfig `koanf:"tls"` Upload UploadConfig `koanf:"upload"` Voice VoiceConfig `koanf:"voice"` + GitHub GitHubConfig `koanf:"github"` +} + +// GitHubConfig holds GitHub API settings for update checking. +type GitHubConfig struct { + Token string `koanf:"token"` } // VoiceConfig holds STUN/TURN server settings for WebRTC signaling. @@ -80,6 +86,7 @@ func defaults() Config { TURNPort: 3478, TURNEnabled: true, }, + GitHub: GitHubConfig{}, } } @@ -102,6 +109,9 @@ tls: upload: max_size_mb: 100 storage_dir: "data/uploads" + +# github: +# token: "" # optional: GitHub API token for higher rate limits (5000 req/hr vs 60) ` // Load reads configuration from the given YAML file path, merging with diff --git a/Server/go.mod b/Server/go.mod index a2d9f45f..7af648ef 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -12,6 +12,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 go.yaml.in/yaml/v3 v3.0.3 golang.org/x/crypto v0.49.0 + golang.org/x/mod v0.34.0 modernc.org/sqlite v1.46.1 nhooyr.io/websocket v1.8.17 ) diff --git a/Server/go.sum b/Server/go.sum index e3be67e9..27903cef 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -58,8 +58,8 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= @@ -67,8 +67,8 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/Server/updater/updater.go b/Server/updater/updater.go new file mode 100644 index 00000000..c4dfb267 --- /dev/null +++ b/Server/updater/updater.go @@ -0,0 +1,306 @@ +// Package updater checks GitHub Releases for server updates and manages +// binary downloads with checksum verification. +package updater + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/mod/semver" +) + +const ( + defaultBaseURL = "https://api.github.com" + cacheTTL = 1 * time.Hour + binaryAsset = "chatserver.exe" + checksumAsset = "checksums.sha256" +) + +// UpdateInfo holds the result of a version check. +type UpdateInfo struct { + Current string `json:"current"` + Latest string `json:"latest"` + UpdateAvailable bool `json:"update_available"` + ReleaseURL string `json:"release_url"` + DownloadURL string `json:"download_url"` + ChecksumURL string `json:"checksum_url"` + ReleaseNotes string `json:"release_notes"` +} + +// releaseResponse mirrors the subset of GitHub's release API we need. +type releaseResponse struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Assets []assetResponse `json:"assets"` +} + +// assetResponse mirrors a single release asset from the GitHub API. +type assetResponse struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// Updater checks GitHub Releases for updates and manages binary downloads. +type Updater struct { + currentVersion string + githubToken string + repoOwner string + repoName string + baseURL string // override for testing; empty uses defaultBaseURL + + cache *UpdateInfo + cacheExpiry time.Time + mu sync.Mutex + httpClient *http.Client +} + +// NewUpdater creates an Updater for the given repository. +func NewUpdater(currentVersion, githubToken, repoOwner, repoName string) *Updater { + return &Updater{ + currentVersion: currentVersion, + githubToken: githubToken, + repoOwner: repoOwner, + repoName: repoName, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// 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 { + if strings.HasPrefix(v, "v") { + return v + } + return "v" + v +} + +// apiBaseURL returns the effective base URL for GitHub API requests. +func (u *Updater) apiBaseURL() string { + if u.baseURL != "" { + return u.baseURL + } + return defaultBaseURL +} + +// CheckForUpdate queries GitHub for the latest release and compares it +// against the current version. Results are cached for cacheTTL. +func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateInfo, error) { + u.mu.Lock() + if u.cache != nil && time.Now().Before(u.cacheExpiry) { + cached := *u.cache + u.mu.Unlock() + return cached, nil + } + u.mu.Unlock() + + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", u.apiBaseURL(), u.repoOwner, u.repoName) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return UpdateInfo{}, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + if u.githubToken != "" { + req.Header.Set("Authorization", "token "+u.githubToken) + } + + resp, err := u.httpClient.Do(req) + if err != nil { + return UpdateInfo{}, fmt.Errorf("fetching latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return UpdateInfo{}, fmt.Errorf("github API returned status %d", resp.StatusCode) + } + + var release releaseResponse + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return UpdateInfo{}, fmt.Errorf("decoding release response: %w", err) + } + + currentV := ensureVPrefix(u.currentVersion) + latestV := ensureVPrefix(release.TagName) + + // semver.Compare returns -1, 0, or +1. Update available when current < latest. + updateAvailable := semver.Compare(currentV, latestV) < 0 + + var downloadURL, checksumURL string + for _, asset := range release.Assets { + switch asset.Name { + case binaryAsset: + downloadURL = asset.BrowserDownloadURL + case checksumAsset: + checksumURL = asset.BrowserDownloadURL + } + } + + info := UpdateInfo{ + Current: currentV, + Latest: latestV, + UpdateAvailable: updateAvailable, + ReleaseURL: release.HTMLURL, + DownloadURL: downloadURL, + ChecksumURL: checksumURL, + ReleaseNotes: release.Body, + } + + u.mu.Lock() + u.cache = &info + u.cacheExpiry = time.Now().Add(cacheTTL) + u.mu.Unlock() + + return info, nil +} + +// ValidateDownloadURL ensures the URL points to an expected GitHub release +// asset for this repository. +func (u *Updater) ValidateDownloadURL(url string) error { + prefix := fmt.Sprintf("https://github.com/%s/%s/releases/download/", u.repoOwner, u.repoName) + if !strings.HasPrefix(url, prefix) { + return fmt.Errorf("download URL %q does not match expected prefix %q", url, prefix) + } + return nil +} + +// DownloadAndVerify downloads the binary from downloadURL, fetches the +// checksum file from checksumURL, and verifies the SHA256 hash matches. +// On checksum mismatch the downloaded file is removed. +func (u *Updater) DownloadAndVerify(ctx context.Context, downloadURL, checksumURL, destPath string) error { + if err := u.ValidateDownloadURL(downloadURL); err != nil { + return err + } + + // Fetch checksum file. + checksumData, err := u.fetchBody(ctx, checksumURL) + if err != nil { + return fmt.Errorf("fetching checksums: %w", err) + } + + destFilename := filepath.Base(destPath) + expectedHash, err := u.ParseChecksumFile(checksumData, destFilename) + if err != nil { + return fmt.Errorf("parsing checksum file: %w", err) + } + + // Download the binary. + if err := u.downloadFile(ctx, downloadURL, destPath); err != nil { + return fmt.Errorf("downloading binary: %w", err) + } + + // Verify hash. + if err := u.VerifyChecksum(destPath, expectedHash); err != nil { + // Remove the invalid file. + _ = os.Remove(destPath) + return err + } + + return nil +} + +// VerifyChecksum computes the SHA256 hash of the file at filePath and +// compares it (case-insensitive) against expectedHash. +func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("opening file for checksum: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("computing checksum: %w", err) + } + + actual := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(actual, expectedHash) { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) + } + return nil +} + +// ParseChecksumFile parses a sha256sum-format checksum file (lines of +// " ") and returns the hash for the given filename. +func (u *Updater) ParseChecksumFile(data []byte, filename string) (string, error) { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // sha256sum format: " " (two spaces) + // Also handle single-space separation for robustness. + parts := strings.Fields(line) + if len(parts) >= 2 && parts[len(parts)-1] == filename { + return parts[0], nil + } + } + return "", fmt.Errorf("file %q not found in checksum data", filename) +} + +// fetchBody performs a GET request and returns the response body as bytes. +func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if u.githubToken != "" { + req.Header.Set("Authorization", "token "+u.githubToken) + } + + resp, err := u.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, url) + } + + return io.ReadAll(resp.Body) +} + +// downloadFile downloads the content at url and writes it to destPath. +func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + if u.githubToken != "" { + req.Header.Set("Authorization", "token "+u.githubToken) + } + + resp, err := u.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d downloading %s", resp.StatusCode, url) + } + + f, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("creating destination file: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, resp.Body); err != nil { + return fmt.Errorf("writing downloaded file: %w", err) + } + + return nil +} diff --git a/Server/updater/updater_test.go b/Server/updater/updater_test.go new file mode 100644 index 00000000..2528bebe --- /dev/null +++ b/Server/updater/updater_test.go @@ -0,0 +1,265 @@ +package updater + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +// ghRelease mirrors the GitHub release API response shape. +type ghRelease struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Assets []ghAsset `json:"assets"` +} + +// ghAsset mirrors a GitHub release asset. +type ghAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func newTestRelease(tag, body, htmlURL string, assetDownloadBase string) ghRelease { + return ghRelease{ + TagName: tag, + Body: body, + HTMLURL: htmlURL, + Assets: []ghAsset{ + {Name: "chatserver.exe", BrowserDownloadURL: assetDownloadBase + "/chatserver.exe"}, + {Name: "checksums.sha256", BrowserDownloadURL: assetDownloadBase + "/checksums.sha256"}, + }, + } +} + +func newTestServer(t *testing.T, release ghRelease, statusCode int) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if statusCode == http.StatusOK { + if err := json.NewEncoder(w).Encode(release); err != nil { + t.Fatalf("encoding release: %v", err) + } + } else { + fmt.Fprint(w, `{"message":"Internal Server Error"}`) + } + }) + return httptest.NewServer(mux) +} + +func newTestUpdater(baseURL, currentVersion string) *Updater { + u := NewUpdater(currentVersion, "", "J3vb", "OwnCord") + u.baseURL = baseURL + return u +} + +func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { + release := newTestRelease("v1.2.0", "Bug fixes and improvements", "https://github.com/J3vb/OwnCord/releases/tag/v1.2.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.2.0") + srv := newTestServer(t, release, http.StatusOK) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + info, err := u.CheckForUpdate(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.UpdateAvailable { + t.Error("expected UpdateAvailable=true, got false") + } + if info.Latest != "v1.2.0" { + t.Errorf("expected Latest=v1.2.0, got %s", info.Latest) + } + if info.Current != "v1.0.0" { + t.Errorf("expected Current=v1.0.0, got %s", info.Current) + } + if info.DownloadURL == "" { + t.Error("expected non-empty DownloadURL") + } + if info.ChecksumURL == "" { + t.Error("expected non-empty ChecksumURL") + } +} + +func TestCheckForUpdate_UpToDate(t *testing.T) { + release := newTestRelease("v1.0.0", "Current release", "https://github.com/J3vb/OwnCord/releases/tag/v1.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0") + srv := newTestServer(t, release, http.StatusOK) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + info, err := u.CheckForUpdate(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.UpdateAvailable { + t.Error("expected UpdateAvailable=false, got true") + } +} + +func TestCheckForUpdate_CachesResult(t *testing.T) { + var hitCount atomic.Int32 + release := newTestRelease("v2.0.0", "Major update", "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v2.0.0") + + mux := http.NewServeMux() + mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) { + hitCount.Add(1) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(release); err != nil { + t.Fatalf("encoding release: %v", err) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + ctx := context.Background() + + _, err := u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("first call error: %v", err) + } + _, err = u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("second call error: %v", err) + } + + if got := hitCount.Load(); got != 1 { + t.Errorf("expected 1 API hit (cached), got %d", got) + } +} + +func TestCheckForUpdate_CacheExpires(t *testing.T) { + var hitCount atomic.Int32 + release := newTestRelease("v2.0.0", "Major update", "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v2.0.0") + + mux := http.NewServeMux() + mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) { + hitCount.Add(1) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(release); err != nil { + t.Fatalf("encoding release: %v", err) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + ctx := context.Background() + + // First call populates cache. + _, err := u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("first call error: %v", err) + } + + // Expire the cache manually. + u.mu.Lock() + u.cacheExpiry = time.Now().Add(-1 * time.Minute) + u.mu.Unlock() + + // Second call should hit the API again. + _, err = u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("second call error: %v", err) + } + + if got := hitCount.Load(); got != 2 { + t.Errorf("expected 2 API hits (cache expired), got %d", got) + } +} + +func TestCheckForUpdate_APIError(t *testing.T) { + release := ghRelease{} // unused since status is 500 + srv := newTestServer(t, release, http.StatusInternalServerError) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + _, err := u.CheckForUpdate(context.Background()) + if err == nil { + t.Fatal("expected error for 500 response, got nil") + } +} + +func TestValidateDownloadURL_Valid(t *testing.T) { + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + err := u.ValidateDownloadURL("https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe") + if err != nil { + t.Errorf("expected valid URL to pass, got error: %v", err) + } +} + +func TestValidateDownloadURL_Invalid(t *testing.T) { + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + err := u.ValidateDownloadURL("https://evil.com/chatserver.exe") + if err == nil { + t.Error("expected invalid URL to be rejected, got nil") + } +} + +func TestVerifyChecksum_Correct(t *testing.T) { + content := []byte("hello world binary content") + hash := sha256.Sum256(content) + expectedHash := hex.EncodeToString(hash[:]) + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "chatserver.exe") + if err := os.WriteFile(filePath, content, 0o644); err != nil { + t.Fatalf("writing temp file: %v", err) + } + + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + if err := u.VerifyChecksum(filePath, expectedHash); err != nil { + t.Errorf("expected correct checksum to pass, got error: %v", err) + } +} + +func TestVerifyChecksum_Incorrect(t *testing.T) { + content := []byte("hello world binary content") + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "chatserver.exe") + if err := os.WriteFile(filePath, content, 0o644); err != nil { + t.Fatalf("writing temp file: %v", err) + } + + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + wrongHash := "0000000000000000000000000000000000000000000000000000000000000000" + if err := u.VerifyChecksum(filePath, wrongHash); err == nil { + t.Error("expected incorrect checksum to fail, got nil") + } +} + +func TestParseChecksumFile_FindsFile(t *testing.T) { + data := []byte("abc123 readme.txt\ndef456 chatserver.exe\nghi789 other.dll\n") + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + hash, err := u.ParseChecksumFile(data, "chatserver.exe") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hash != "def456" { + t.Errorf("expected hash=def456, got %s", hash) + } +} + +func TestParseChecksumFile_FileNotFound(t *testing.T) { + data := []byte("abc123 readme.txt\ndef456 chatserver.exe\n") + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + _, err := u.ParseChecksumFile(data, "nonexistent.exe") + if err == nil { + t.Error("expected error for missing file in checksum data, got nil") + } +} From 82a9985a6ab589dea2dca68f4a2781b165e34bc7 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 22:03:12 +0100 Subject: [PATCH 012/100] docs: add server_restart message type and update endpoints to specs --- API.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ PROTOCOL.md | 21 +++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/API.md b/API.md index 93149920..ba1ce26a 100644 --- a/API.md +++ b/API.md @@ -169,6 +169,52 @@ Query params: `q` (search query), `channel_id` (optional filter), `limit` (defau | GET | `/api/admin/settings` | Admin | Get server settings | | PATCH | `/api/admin/settings` | Admin | Update server settings | | GET | `/api/admin/update-check` | Admin | Check for new server version | +| GET | `/api/admin/updates` | Admin | Check for available server updates | +| POST | `/api/admin/updates/apply` | Owner | Download and apply a server update | + +### GET /api/admin/updates + +Check for available server updates. + +Authentication: Bearer token (ADMINISTRATOR permission required) + +```json +// Response 200 +{ + "current": "v1.0.0", + "latest": "v1.2.0", + "update_available": true, + "release_url": "https://github.com/J3vb/OwnCord/releases/tag/v1.2.0", + "download_url": "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/chatserver.exe", + "checksum_url": "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/checksums.sha256", + "release_notes": "## What's Changed\n..." +} +``` + +Error responses: +- 401: Unauthorized (missing/invalid token) +- 403: Forbidden (not an administrator) +- 502: Bad Gateway (GitHub API unreachable or returned error) + +### POST /api/admin/updates/apply + +Download and apply a server update. Downloads the new binary, verifies its SHA256 checksum, broadcasts a `server_restart` WebSocket message, then restarts the server with the new binary. + +Authentication: Bearer token (Owner role required) + +```json +// Response 200 +{ + "status": "applying", + "version": "v1.2.0" +} +``` + +Error responses: +- 401: Unauthorized +- 403: Forbidden (not Owner) +- 409: Conflict (server is already up to date) +- 502: Bad Gateway (download failed, checksum mismatch, or missing release assets) --- diff --git a/PROTOCOL.md b/PROTOCOL.md index 23a507d1..36b088b9 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -211,6 +211,27 @@ Channel types: `text`, `voice`, `announcement` --- +## Server Restart + +### Server → Client + +```json +{ + "type": "server_restart", + "payload": { + "reason": "update", + "delay_seconds": 5 + } +} +``` + +- `reason` (string): Why the server is restarting. Currently only `"update"`. +- `delay_seconds` (integer): How many seconds until the server shuts down. + +Client behavior: Display a banner/notification ("Server restarting for update..."), then auto-reconnect using existing reconnection logic after the delay expires. + +--- + ## Initial State (sent after auth_ok) ### Server → Client From bae586907f0dde13426ebb3c38f109fd6a9174cd Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 22:04:24 +0100 Subject: [PATCH 013/100] feat: implement client auto-update with GitHub Release checking and update dialog --- Client/OwnCord.Client/App.xaml.cs | 21 ++ Client/OwnCord.Client/OwnCord.Client.csproj | 2 + .../OwnCord.Client/Services/IUpdateService.cs | 21 ++ .../OwnCord.Client/Services/UpdateService.cs | 256 ++++++++++++++++++ .../ViewModels/UpdateViewModel.cs | 90 ++++++ Client/OwnCord.Client/Views/UpdateDialog.xaml | 49 ++++ .../OwnCord.Client/Views/UpdateDialog.xaml.cs | 14 + 7 files changed, 453 insertions(+) create mode 100644 Client/OwnCord.Client/Services/IUpdateService.cs create mode 100644 Client/OwnCord.Client/Services/UpdateService.cs create mode 100644 Client/OwnCord.Client/ViewModels/UpdateViewModel.cs create mode 100644 Client/OwnCord.Client/Views/UpdateDialog.xaml create mode 100644 Client/OwnCord.Client/Views/UpdateDialog.xaml.cs diff --git a/Client/OwnCord.Client/App.xaml.cs b/Client/OwnCord.Client/App.xaml.cs index c3653c15..b760b679 100644 --- a/Client/OwnCord.Client/App.xaml.cs +++ b/Client/OwnCord.Client/App.xaml.cs @@ -1,7 +1,9 @@ using System.IO; +using System.Threading.Tasks; using System.Windows; using OwnCord.Client.Services; using OwnCord.Client.ViewModels; +using OwnCord.Client.Views; namespace OwnCord.Client; @@ -22,6 +24,25 @@ public partial class App : Application var mainWindow = new MainWindow(connectVm, mainVm, credentialService, wsService); mainWindow.Show(); + + // Clean up old binary from previous update + var updateService = new UpdateService(); + updateService.CleanupOldVersion(); + + // Check for updates (non-blocking) + _ = Task.Run(async () => + { + var info = await updateService.CheckForUpdateAsync(); + if (info?.UpdateAvailable == true) + { + await Current.Dispatcher.InvokeAsync(() => + { + var vm = new UpdateViewModel(updateService, info); + var dialog = new UpdateDialog(vm); + dialog.ShowDialog(); + }); + } + }); } } diff --git a/Client/OwnCord.Client/OwnCord.Client.csproj b/Client/OwnCord.Client/OwnCord.Client.csproj index e3e33e3b..b0ad54eb 100644 --- a/Client/OwnCord.Client/OwnCord.Client.csproj +++ b/Client/OwnCord.Client/OwnCord.Client.csproj @@ -6,6 +6,8 @@ enable enable true + 0.1.0 + 0.1.0.0
diff --git a/Client/OwnCord.Client/Services/IUpdateService.cs b/Client/OwnCord.Client/Services/IUpdateService.cs new file mode 100644 index 00000000..f8e4b6d5 --- /dev/null +++ b/Client/OwnCord.Client/Services/IUpdateService.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; + +namespace OwnCord.Client.Services; + +public record UpdateInfo( + string CurrentVersion, + string LatestVersion, + string ReleaseNotes, + string DownloadUrl, + string ChecksumUrl, + bool UpdateAvailable +); + +public interface IUpdateService +{ + Task CheckForUpdateAsync(); + Task DownloadAndVerifyAsync(string downloadUrl, string checksumUrl, string destPath); + void ApplyUpdate(string newExePath); + void CleanupOldVersion(); + void SkipVersion(string version); +} diff --git a/Client/OwnCord.Client/Services/UpdateService.cs b/Client/OwnCord.Client/Services/UpdateService.cs new file mode 100644 index 00000000..5e056f05 --- /dev/null +++ b/Client/OwnCord.Client/Services/UpdateService.cs @@ -0,0 +1,256 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using System.Reflection; +using System.Linq; +using System.Collections.Generic; + +namespace OwnCord.Client.Services; + +public class UpdateService : IUpdateService +{ + private const string GitHubApiUrl = "https://api.github.com/repos/J3vb/OwnCord/releases/latest"; + private const string ValidUrlPrefix = "https://github.com/J3vb/OwnCord/releases/download/"; + private static readonly string SettingsDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OwnCord"); + private static readonly string SettingsPath = Path.Combine(SettingsDir, "update-settings.json"); + + private readonly HttpClient _httpClient; + private UpdateSettings _settings; + + public UpdateService() : this(new HttpClient()) { } + + public UpdateService(HttpClient httpClient) + { + _httpClient = httpClient; + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("OwnCord-Client/1.0"); + _settings = LoadSettings(); + } + + public async Task CheckForUpdateAsync() + { + // Check 24-hour cache + if (_settings.LastCheckUtc.HasValue && + DateTime.UtcNow - _settings.LastCheckUtc.Value < TimeSpan.FromHours(24)) + { + return null; + } + + try + { + var response = await _httpClient.GetAsync(GitHubApiUrl); + if (!response.IsSuccessStatusCode) return null; + + var release = await response.Content.ReadFromJsonAsync(); + if (release == null) return null; + + var currentVersion = GetCurrentVersion(); + var latestVersion = release.TagName.TrimStart('v'); + + // Update cache timestamp + _settings.LastCheckUtc = DateTime.UtcNow; + SaveSettings(); + + var updateAvailable = CompareVersions(currentVersion, latestVersion) < 0; + + // Check skip list + if (updateAvailable && _settings.SkippedVersions.Contains(latestVersion)) + { + return null; + } + + var downloadUrl = release.Assets? + .FirstOrDefault(a => a.Name == "OwnCord.Client.exe")?.BrowserDownloadUrl ?? ""; + var checksumUrl = release.Assets? + .FirstOrDefault(a => a.Name == "checksums.sha256")?.BrowserDownloadUrl ?? ""; + + return new UpdateInfo( + CurrentVersion: currentVersion, + LatestVersion: latestVersion, + ReleaseNotes: release.Body ?? "", + DownloadUrl: downloadUrl, + ChecksumUrl: checksumUrl, + UpdateAvailable: updateAvailable + ); + } + catch + { + return null; + } + } + + public async Task DownloadAndVerifyAsync(string downloadUrl, string checksumUrl, string destPath) + { + ValidateDownloadUrl(downloadUrl); + + // Download checksum file + var checksumContent = await _httpClient.GetStringAsync(checksumUrl); + var expectedHash = ParseChecksumFile(checksumContent, Path.GetFileName(destPath)); + + // Download binary + using var response = await _httpClient.GetAsync(downloadUrl); + response.EnsureSuccessStatusCode(); + + await using var fileStream = File.Create(destPath); + await response.Content.CopyToAsync(fileStream); + fileStream.Close(); + + // Verify checksum + var actualHash = ComputeFileHash(destPath); + if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) + { + File.Delete(destPath); + throw new InvalidOperationException( + $"Checksum mismatch: expected {expectedHash}, got {actualHash}"); + } + } + + public void ApplyUpdate(string newExePath) + { + var currentExe = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName + ?? throw new InvalidOperationException("Cannot determine current executable path"); + + var oldPath = currentExe + ".old"; + + // Remove stale .old if present + if (File.Exists(oldPath)) File.Delete(oldPath); + + // Rename: current -> .old + File.Move(currentExe, oldPath); + + // Move: new -> current + File.Move(newExePath, currentExe); + + // Restart + Process.Start(new ProcessStartInfo + { + FileName = currentExe, + UseShellExecute = true + }); + + Environment.Exit(0); + } + + public void CleanupOldVersion() + { + var currentExe = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName; + if (currentExe == null) return; + + var oldPath = currentExe + ".old"; + if (File.Exists(oldPath)) + { + try { File.Delete(oldPath); } catch { /* best effort */ } + } + } + + public void SkipVersion(string version) + { + if (!_settings.SkippedVersions.Contains(version)) + { + _settings.SkippedVersions.Add(version); + SaveSettings(); + } + } + + private static string GetCurrentVersion() + { + var version = Assembly.GetExecutingAssembly().GetName().Version; + return version != null ? $"{version.Major}.{version.Minor}.{version.Build}" : "0.0.0"; + } + + private static int CompareVersions(string current, string latest) + { + if (Version.TryParse(current, out var v1) && Version.TryParse(latest, out var v2)) + return v1.CompareTo(v2); + return string.Compare(current, latest, StringComparison.Ordinal); + } + + private static void ValidateDownloadUrl(string url) + { + if (!url.StartsWith(ValidUrlPrefix, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException($"Invalid download URL: {url}"); + } + + private static string ParseChecksumFile(string content, string filename) + { + foreach (var line in content.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = line.Trim(); + var parts = trimmed.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && parts[^1] == filename) + return parts[0]; + } + throw new InvalidOperationException($"File '{filename}' not found in checksum data"); + } + + private static string ComputeFileHash(string filePath) + { + using var stream = File.OpenRead(filePath); + var hash = SHA256.HashData(stream); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private UpdateSettings LoadSettings() + { + try + { + if (File.Exists(SettingsPath)) + { + var json = File.ReadAllText(SettingsPath); + return JsonSerializer.Deserialize(json) ?? new UpdateSettings(); + } + } + catch { /* ignore corrupt settings */ } + return new UpdateSettings(); + } + + private void SaveSettings() + { + try + { + Directory.CreateDirectory(SettingsDir); + var json = JsonSerializer.Serialize(_settings, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(SettingsPath, json); + } + catch { /* best effort */ } + } +} + +internal class UpdateSettings +{ + [JsonPropertyName("last_check_utc")] + public DateTime? LastCheckUtc { get; set; } + + [JsonPropertyName("skipped_versions")] + public List SkippedVersions { get; set; } = new(); +} + +internal class GitHubRelease +{ + [JsonPropertyName("tag_name")] + public string TagName { get; set; } = ""; + + [JsonPropertyName("body")] + public string? Body { get; set; } + + [JsonPropertyName("html_url")] + public string HtmlUrl { get; set; } = ""; + + [JsonPropertyName("assets")] + public List? Assets { get; set; } +} + +internal class GitHubAsset +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("browser_download_url")] + public string BrowserDownloadUrl { get; set; } = ""; +} diff --git a/Client/OwnCord.Client/ViewModels/UpdateViewModel.cs b/Client/OwnCord.Client/ViewModels/UpdateViewModel.cs new file mode 100644 index 00000000..5a6288bb --- /dev/null +++ b/Client/OwnCord.Client/ViewModels/UpdateViewModel.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using System.Windows.Input; +using OwnCord.Client.Services; + +namespace OwnCord.Client.ViewModels; + +public class UpdateViewModel : ViewModelBase +{ + private readonly IUpdateService _updateService; + private readonly UpdateInfo _updateInfo; + + private bool _isDownloading; + private string _statusText = ""; + + public string CurrentVersion => _updateInfo.CurrentVersion; + public string NewVersion => _updateInfo.LatestVersion; + public string ReleaseNotes => _updateInfo.ReleaseNotes; + + public bool IsDownloading + { + get => _isDownloading; + private set { _isDownloading = value; OnPropertyChanged(); } + } + + public string StatusText + { + get => _statusText; + private set { _statusText = value; OnPropertyChanged(); } + } + + public ICommand UpdateNowCommand { get; } + public ICommand SkipVersionCommand { get; } + public ICommand RemindLaterCommand { get; } + + // Result: true = update started, false = skipped, null = remind later + public bool? Result { get; private set; } + + public UpdateViewModel(IUpdateService updateService, UpdateInfo updateInfo) + { + _updateService = updateService; + _updateInfo = updateInfo; + + UpdateNowCommand = new RelayCommand( + () => _ = UpdateNowAsync(), + () => !IsDownloading); + SkipVersionCommand = new RelayCommand( + SkipVersion, + () => !IsDownloading); + RemindLaterCommand = new RelayCommand(RemindLater); + } + + private async Task UpdateNowAsync() + { + IsDownloading = true; + StatusText = "Downloading update..."; + + try + { + var tempPath = Path.GetTempFileName(); + await _updateService.DownloadAndVerifyAsync( + _updateInfo.DownloadUrl, _updateInfo.ChecksumUrl, tempPath); + + StatusText = "Applying update..."; + _updateService.ApplyUpdate(tempPath); + Result = true; + } + catch (Exception ex) + { + StatusText = $"Update failed: {ex.Message}"; + IsDownloading = false; + } + } + + private void SkipVersion() + { + _updateService.SkipVersion(_updateInfo.LatestVersion); + Result = false; + CloseRequested?.Invoke(); + } + + private void RemindLater() + { + Result = null; + CloseRequested?.Invoke(); + } + + public event Action? CloseRequested; +} diff --git a/Client/OwnCord.Client/Views/UpdateDialog.xaml b/Client/OwnCord.Client/Views/UpdateDialog.xaml new file mode 100644 index 00000000..b7e9efe1 --- /dev/null +++ b/Client/OwnCord.Client/Views/UpdateDialog.xaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Total Users
-
Total Messages
-
@@ -438,6 +447,55 @@ async function loadDashboard() { } catch (e) { console.error('stats:', e); } + checkForUpdate(); +} + +async function checkForUpdate() { + try { + const resp = await fetch('/admin/api/updates', { + headers: { 'Authorization': 'Bearer ' + token } + }); + if (!resp.ok) return; + const data = await resp.json(); + if (data.update_available) { + document.getElementById('update-version-text').textContent = + ' Version ' + data.latest + ' is available (current: ' + data.current + ')'; + document.getElementById('update-banner').style.display = 'flex'; + } + } catch (e) { + console.error('Update check failed:', e); + } +} + +async function applyUpdate() { + if (!confirm('This will restart the server. All connected users will be briefly disconnected. Continue?')) { + return; + } + + const banner = document.getElementById('update-banner'); + banner.innerHTML = '
Applying update... Downloading and restarting server.
'; + + try { + const resp = await fetch('/admin/api/updates/apply', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token } + }); + if (resp.ok) { + banner.innerHTML = '
Update applied! Server is restarting. This page will reload in 10 seconds.
'; + banner.style.background = '#d4edda'; + banner.style.borderColor = '#28a745'; + setTimeout(() => location.reload(), 10000); + } else { + const err = await resp.json(); + banner.innerHTML = '
Update failed: ' + (err.message || 'Unknown error') + '
'; + banner.style.background = '#f8d7da'; + banner.style.borderColor = '#dc3545'; + } + } catch (e) { + banner.innerHTML = '
Update failed: ' + e.message + '
'; + banner.style.background = '#f8d7da'; + banner.style.borderColor = '#dc3545'; + } } function fmtBytes(b) { From f28a7b834273a7d27ea550590575af47ff9f0157 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 22:09:11 +0100 Subject: [PATCH 016/100] docs: add Phase 7 distribution and updates design spec --- ...3-14-phase7-distribution-updates-design.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-14-phase7-distribution-updates-design.md diff --git a/docs/superpowers/specs/2026-03-14-phase7-distribution-updates-design.md b/docs/superpowers/specs/2026-03-14-phase7-distribution-updates-design.md new file mode 100644 index 00000000..56abce31 --- /dev/null +++ b/docs/superpowers/specs/2026-03-14-phase7-distribution-updates-design.md @@ -0,0 +1,215 @@ +# Phase 7: Distribution & Updates — Design Spec + +## Overview + +Add CI/CD pipelines, auto-update for both server and client, and project documentation. Installer deferred to a later phase. + +## 1. GitHub Actions CI + +### `ci.yml` — Continuous Integration + +**Triggers:** push/PR to `main` and `feature/*` branches. + +**Jobs:** + +- **server-build-test:** (runs on `windows-latest`) + - `actions/checkout@v4` + - `actions/setup-go@v5` (Go 1.25) + - `go build -o chatserver.exe -ldflags "-s -w" .` + - `go test ./... -cover` + - `golangci-lint run ./...` via `golangci/golangci-lint-action` + +- **client-build-test:** (runs on `windows-latest`) + - `actions/checkout@v4` + - `actions/setup-dotnet@v4` (.NET 8) + - `dotnet build Client/OwnCord.Client.sln` + - `dotnet test Client/OwnCord.Client.Tests/` + +### `release.yml` — Release Pipeline + +**Trigger:** push tag matching `v*` (e.g. `v1.0.0`). + +**Steps:** + +1. Checkout code +2. Extract version from tag (strip `v` prefix) +3. Build server: `go build -o chatserver.exe -ldflags "-s -w -X main.version=$VERSION" .` +4. Build client as single-file: `dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o dist/client` +5. Generate SHA256 checksums for all binaries +6. Create GitHub Release via `gh release create` with `--generate-notes` +7. Attach binaries and checksum file to the release + +**Artifacts:** +- `chatserver.exe` — server binary (~13MB) +- `OwnCord.Client.exe` — single-file self-contained client +- `checksums.sha256` — SHA256 hashes for integrity verification + +## 2. Server Auto-Update + +### Version Accessibility + +Create a shared `version` package or pass the version string to `admin.NewAdminAPI` as a parameter. The `main.version` variable (set via ldflags) is passed down at startup: + +``` +main.go → admin.NewAdminAPI(database, versionString) → update handler uses it for comparison +``` + +### Semver Comparison + +Use `golang.org/x/mod/semver` for version comparison. All tags must follow `vMAJOR.MINOR.PATCH` format. + +### API Endpoints + +**`GET /admin/api/updates`** (admin-only) +- Server-side HTTP call to `https://api.github.com/repos/J3vb/OwnCord/releases/latest` +- Parses latest tag, compares against running version using `semver.Compare` +- Caches result for 1 hour (in-memory, reset on restart) +- Optional `github_token` in config.yaml for authenticated requests (5,000 req/hr vs 60 unauthenticated) +- Response: `{ "current": "1.0.0", "latest": "1.2.0", "update_available": true, "release_url": "...", "download_url": "...", "release_notes": "..." }` + +**`POST /admin/api/updates/apply`** (owner-only, Bearer token auth — not cookie-based, so no CSRF risk) +- Validates download URL matches `https://github.com/J3vb/OwnCord/releases/download/...` pattern before downloading +- Downloads `chatserver.exe` from the GitHub Release to `chatserver.exe.new` +- Verifies SHA256 checksum against `checksums.sha256` from the release +- Broadcasts `server_restart` WebSocket message to all connected clients (see below) +- Waits 5 seconds for clients to prepare +- Renames: `chatserver.exe` → `chatserver.exe.old`, `chatserver.exe.new` → `chatserver.exe` +- Spawns new process detached from parent (`os.StartProcess` with `syscall.SysProcAttr{CreationFlags: DETACHED_PROCESS}`) +- Exits current process via graceful shutdown + +**On startup:** +- New process retries port binding for up to 10 seconds (old process may still be releasing) +- Verifies it can open the database and bind the port successfully before deleting `chatserver.exe.old` +- If startup fails, `chatserver.exe.old` remains for manual rollback + +### WebSocket Restart Notification + +Add to PROTOCOL.md: +```json +{ "type": "server_restart", "payload": { "reason": "update", "delay_seconds": 5 } } +``` +Clients display "Server restarting for update..." and auto-reconnect after the delay. + +### Admin Panel UI + +- Dashboard banner: "Update available: v1.2.0 — [Apply Update]" when `update_available` is true +- Confirmation dialog before applying: "This will restart the server. All connected users will be briefly disconnected." +- Status feedback during download/apply + +### Security Considerations + +- **Download URL validation:** Only accept URLs matching `https://github.com/J3vb/OwnCord/releases/download/...` +- **Integrity:** SHA256 checksum verification (authenticity via code signing deferred — documented as known limitation) +- **Authorization:** Only server owner can apply updates +- **No CSRF risk:** Endpoint uses Bearer token auth, not cookies + +## 3. Client Auto-Update + +### Single-File Distribution + +Client is published with `-p:PublishSingleFile=true` so the entire app is one `.exe`. This enables the same rename-swap pattern as the server. + +### Update Check Flow + +1. On launch, client makes HTTP GET to `https://api.github.com/repos/J3vb/OwnCord/releases/latest` +2. Caches result — checks at most once per 24 hours (stored in local app data) +3. Compares local assembly version against latest tag +4. If newer version exists and not in "skipped" list, shows update dialog + +### Update Dialog + +- Shows current version, new version, and release notes (from GitHub Release body) +- Three buttons: **Update Now**, **Skip This Version**, **Remind Me Later** +- "Skip This Version" persists the skipped version in local settings +- "Remind Me Later" dismisses until next launch (but respects 24h cache) + +### Update Apply Flow + +1. Validate download URL matches `https://github.com/J3vb/OwnCord/releases/download/...` +2. Download new `OwnCord.Client.exe` from GitHub Release to temp location +3. Verify SHA256 checksum +4. Rename current exe to `.old`, move new to current path +5. Restart application +6. On startup, delete `.old` if present + +### Installation Location + +Client runs from a user-writable location (`%LOCALAPPDATA%\OwnCord`) to avoid UAC elevation requirements for self-update. When the installer is added later, it will use this path by default. + +### Version Storage + +- Client version embedded at build time (assembly version from `.csproj`) +- Skipped version and last-check timestamp stored in `%LOCALAPPDATA%\OwnCord\settings.json` + +## 4. Documentation + +### `README.md` (project root) + +- Project name and one-line description +- Feature highlights (real-time chat, voice, admin panel, self-hosted) +- Quick start: build server, build client, connect +- Architecture overview (server + client diagram) +- Link to detailed docs +- License + +### `SECURITY.md` (project root) + +- How to report vulnerabilities (GitHub Security Advisories) +- Response timeline commitment +- Known limitations: no code signing yet (SHA256 integrity only) +- Security hardening checklist for operators: + - Enable TLS (self-signed minimum) + - Use invite-only registration + - Set strong admin password + - Configure rate limits + - Regular backups + - Keep server updated + - Firewall: only expose needed ports + +### `CONTRIBUTING.md` (project root) + +- Development setup (Go 1.25, .NET 8, tools) +- Branch naming: `feature/`, `fix/`, `docs/` +- Commit format: conventional commits +- PR process: branch from main, CI must pass, code review +- Test requirements: 80%+ coverage, TDD workflow + +### `docs/quick-start.md` + +- Download latest release from GitHub +- Run server, first-run config generation +- Access admin panel, create invite +- Install client, connect with invite + +### `docs/port-forwarding.md` + +- Why it's needed (friends outside your LAN) +- Find your router admin page +- Forward TCP port (default 8443) to server machine +- Find your public IP +- Test the connection + +### `docs/tailscale.md` + +- What Tailscale is and why it's simpler than port forwarding +- Install Tailscale on server and client machines +- Connect using Tailscale IP +- Set TLS mode to "off" (Tailscale encrypts the tunnel) +- Benefits: no port forwarding, no dynamic DNS, works behind CGNAT + +## 5. API.md Updates + +Update API.md to add the update endpoints under the admin section, matching the existing `/admin/api/*` routing pattern. + +## 6. PROTOCOL.md Updates + +Add `server_restart` message type for pre-restart notification. + +## 7. Out of Scope (Deferred) + +- NSIS/WiX installer — will be added in a follow-up +- `chatserver://` protocol handler registration +- Windows Service mode (`--service install`) +- Code signing (documented as known limitation in SECURITY.md) +- Auto-start registry key +- GPG signing of release checksums (mitigates GitHub account compromise — add when code signing is implemented) From da19fda9e1300319a02d7f89aba5a392c83f61d1 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 22:11:42 +0100 Subject: [PATCH 017/100] chore: add .claude/settings.local.json to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d6b130cf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/settings.local.json From 80ceabc78b1a5bd0193538b4019a00ea4f43a372 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 22:17:47 +0100 Subject: [PATCH 018/100] fix: set default TLS cert/key paths to data/cert.pem and data/key.pem --- Server/config/config.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Server/config/config.go b/Server/config/config.go index 50393817..d3909033 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -75,7 +75,9 @@ func defaults() Config { Path: "data/chatserver.db", }, TLS: TLSConfig{ - Mode: "self_signed", + Mode: "self_signed", + CertFile: "data/cert.pem", + KeyFile: "data/key.pem", }, Upload: UploadConfig{ MaxSizeMB: 100, @@ -102,8 +104,8 @@ database: tls: mode: "self_signed" # self_signed, acme, manual, off - cert_file: "" - key_file: "" + cert_file: "data/cert.pem" + key_file: "data/key.pem" domain: "" upload: From d425dc5553430666a76837f2c7c9490dda85a301 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 14 Mar 2026 22:36:35 +0100 Subject: [PATCH 019/100] 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 @@ + + + + + + -
+