diff --git a/cmd/server/main.go b/cmd/server/main.go index d863276..82b2554 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,30 +3,62 @@ package main import ( "fmt" "log" + "log/slog" "net/http" "os" + "strings" + + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config" + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/handlers" + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware" ) func main() { - addr := os.Getenv("LISTEN_ADDR") - if addr == "" { - addr = ":8080" + // Set up structured logging. + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + cfg, err := config.Load() + if err != nil { + log.Fatalf("configuration error: %v", err) } + // Determine if cookies should be marked Secure (disable for local dev). + secureCookies := !strings.HasPrefix(cfg.ListenAddr, "localhost") && + !strings.HasPrefix(cfg.ListenAddr, "127.0.0.1") + mux := http.NewServeMux() + // Health endpoint (no auth required). mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintln(w, "ok") }) + // Settings handler. + settingsHandler := &handlers.SettingsHandler{ + SessionSecret: cfg.SessionSecret, + SecureCookies: secureCookies, + } + mux.HandleFunc("/settings", settingsHandler.ServeHTTP) + + // Static file server. + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + + // Placeholder dashboard (will be replaced in issue #4/#5). mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintln(w, "
Coming soon.
") + fmt.Fprintln(w, "Dashboard coming soon.
") }) - log.Printf("listening on %s", addr) - if err := http.ListenAndServe(addr, mux); err != nil { + // Apply middleware chain: logging -> auth. + var handler http.Handler = mux + handler = middleware.Auth(cfg.SessionSecret)(handler) + handler = middleware.Logging()(handler) + + slog.Info("server starting", "addr", cfg.ListenAddr, "gitea_url", cfg.GiteaURL) + if err := http.ListenAndServe(cfg.ListenAddr, handler); err != nil { log.Fatalf("server error: %v", err) } } diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go new file mode 100644 index 0000000..f6c3e22 --- /dev/null +++ b/internal/auth/cookie.go @@ -0,0 +1,104 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/http" + "strings" + "time" +) + +const ( + cookieName = "gitea_token" + cookieMaxAge = 30 * 24 * 60 * 60 // 30 days in seconds +) + +var ( + ErrInvalidSignature = errors.New("invalid cookie signature") + ErrMalformedCookie = errors.New("malformed cookie value") +) + +// SetTokenCookie stores a Gitea API token in a signed HTTP-only cookie. +func SetTokenCookie(w http.ResponseWriter, token string, secret string, secure bool) { + signed := sign(token, secret) + + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: signed, + Path: "/", + MaxAge: cookieMaxAge, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteStrictMode, + Expires: time.Now().Add(30 * 24 * time.Hour), + }) +} + +// ClearTokenCookie removes the token cookie. +func ClearTokenCookie(w http.ResponseWriter, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteStrictMode, + }) +} + +// GetToken extracts and verifies the Gitea API token from the request cookie. +// Returns the token string or an error if the cookie is missing or invalid. +func GetToken(r *http.Request, secret string) (string, error) { + cookie, err := r.Cookie(cookieName) + if err != nil { + return "", err + } + + token, err := verify(cookie.Value, secret) + if err != nil { + return "", err + } + + return token, nil +} + +// sign creates a signed cookie value: base64(token).base64(hmac-sha256(token)) +func sign(token string, secret string) string { + encodedToken := base64.URLEncoding.EncodeToString([]byte(token)) + mac := computeHMAC(encodedToken, secret) + return fmt.Sprintf("%s.%s", encodedToken, mac) +} + +// verify checks the HMAC signature and returns the original token. +func verify(signed string, secret string) (string, error) { + parts := strings.SplitN(signed, ".", 2) + if len(parts) != 2 { + return "", ErrMalformedCookie + } + + encodedToken := parts[0] + providedMAC := parts[1] + expectedMAC := computeHMAC(encodedToken, secret) + + if !hmac.Equal([]byte(providedMAC), []byte(expectedMAC)) { + return "", ErrInvalidSignature + } + + tokenBytes, err := base64.URLEncoding.DecodeString(encodedToken) + if err != nil { + return "", ErrMalformedCookie + } + + return string(tokenBytes), nil +} + +// computeHMAC generates a base64-encoded HMAC-SHA256 of the given data. +func computeHMAC(data string, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(data)) + return base64.URLEncoding.EncodeToString(h.Sum(nil)) +} diff --git a/internal/auth/cookie_test.go b/internal/auth/cookie_test.go new file mode 100644 index 0000000..158d532 --- /dev/null +++ b/internal/auth/cookie_test.go @@ -0,0 +1,89 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +const testSecret = "test-secret-that-is-at-least-32-chars-long" + +func TestSignAndVerify(t *testing.T) { + token := "abc123-gitea-token" + signed := sign(token, testSecret) + + got, err := verify(signed, testSecret) + if err != nil { + t.Fatalf("verify failed: %v", err) + } + if got != token { + t.Errorf("got %q, want %q", got, token) + } +} + +func TestVerify_InvalidSignature(t *testing.T) { + token := "abc123-gitea-token" + signed := sign(token, testSecret) + + _, err := verify(signed, "wrong-secret-that-is-at-least-32-chars") + if err != ErrInvalidSignature { + t.Errorf("expected ErrInvalidSignature, got %v", err) + } +} + +func TestVerify_MalformedCookie(t *testing.T) { + _, err := verify("no-dot-separator", testSecret) + if err != ErrMalformedCookie { + t.Errorf("expected ErrMalformedCookie, got %v", err) + } +} + +func TestSetAndGetToken(t *testing.T) { + token := "my-gitea-api-token" + + // Create a response recorder to capture the Set-Cookie header. + w := httptest.NewRecorder() + SetTokenCookie(w, token, testSecret, false) + + // Extract the cookie from the response. + resp := w.Result() + cookies := resp.Cookies() + if len(cookies) == 0 { + t.Fatal("expected a cookie to be set") + } + + // Create a new request with the cookie. + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookies[0]) + + got, err := GetToken(req, testSecret) + if err != nil { + t.Fatalf("GetToken failed: %v", err) + } + if got != token { + t.Errorf("got %q, want %q", got, token) + } +} + +func TestGetToken_NoCookie(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + + _, err := GetToken(req, testSecret) + if err == nil { + t.Fatal("expected error for missing cookie") + } +} + +func TestClearTokenCookie(t *testing.T) { + w := httptest.NewRecorder() + ClearTokenCookie(w, false) + + resp := w.Result() + cookies := resp.Cookies() + if len(cookies) == 0 { + t.Fatal("expected a cookie to be set") + } + if cookies[0].MaxAge != -1 { + t.Errorf("MaxAge = %d, want -1", cookies[0].MaxAge) + } +} diff --git a/internal/config/.gitkeep b/internal/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8b13e59 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,50 @@ +package config + +import ( + "fmt" + "os" +) + +// Config holds application configuration loaded from environment variables. +type Config struct { + // GiteaURL is the base URL of the Gitea instance. + GiteaURL string + + // GiteaToken is the default API token (optional; users can set their own via cookie). + GiteaToken string + + // ListenAddr is the server listen address. + ListenAddr string + + // SessionSecret is the HMAC key for signing session cookies. + SessionSecret string +} + +// Load reads configuration from environment variables. +// Returns an error if required variables are missing. +func Load() (*Config, error) { + cfg := &Config{ + GiteaURL: os.Getenv("GITEA_URL"), + GiteaToken: os.Getenv("GITEA_TOKEN"), + ListenAddr: os.Getenv("LISTEN_ADDR"), + SessionSecret: os.Getenv("SESSION_SECRET"), + } + + if cfg.ListenAddr == "" { + cfg.ListenAddr = ":8080" + } + + if cfg.GiteaURL == "" { + return nil, fmt.Errorf("GITEA_URL environment variable is required") + } + + if cfg.SessionSecret == "" { + return nil, fmt.Errorf("SESSION_SECRET environment variable is required") + } + + if len(cfg.SessionSecret) < 32 { + return nil, fmt.Errorf("SESSION_SECRET must be at least 32 characters") + } + + return cfg, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..22b9e1f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "os" + "testing" +) + +func TestLoad_Success(t *testing.T) { + os.Setenv("GITEA_URL", "https://gitea.example.com") + os.Setenv("SESSION_SECRET", "test-secret-that-is-at-least-32-chars-long") + os.Setenv("LISTEN_ADDR", ":9090") + os.Setenv("GITEA_TOKEN", "test-token") + defer func() { + os.Unsetenv("GITEA_URL") + os.Unsetenv("SESSION_SECRET") + os.Unsetenv("LISTEN_ADDR") + os.Unsetenv("GITEA_TOKEN") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.GiteaURL != "https://gitea.example.com" { + t.Errorf("GiteaURL = %q, want %q", cfg.GiteaURL, "https://gitea.example.com") + } + if cfg.ListenAddr != ":9090" { + t.Errorf("ListenAddr = %q, want %q", cfg.ListenAddr, ":9090") + } + if cfg.GiteaToken != "test-token" { + t.Errorf("GiteaToken = %q, want %q", cfg.GiteaToken, "test-token") + } +} + +func TestLoad_DefaultListenAddr(t *testing.T) { + os.Setenv("GITEA_URL", "https://gitea.example.com") + os.Setenv("SESSION_SECRET", "test-secret-that-is-at-least-32-chars-long") + os.Unsetenv("LISTEN_ADDR") + defer func() { + os.Unsetenv("GITEA_URL") + os.Unsetenv("SESSION_SECRET") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.ListenAddr != ":8080" { + t.Errorf("ListenAddr = %q, want %q", cfg.ListenAddr, ":8080") + } +} + +func TestLoad_MissingGiteaURL(t *testing.T) { + os.Unsetenv("GITEA_URL") + os.Setenv("SESSION_SECRET", "test-secret-that-is-at-least-32-chars-long") + defer os.Unsetenv("SESSION_SECRET") + + _, err := Load() + if err == nil { + t.Fatal("expected error for missing GITEA_URL") + } +} + +func TestLoad_MissingSessionSecret(t *testing.T) { + os.Setenv("GITEA_URL", "https://gitea.example.com") + os.Unsetenv("SESSION_SECRET") + defer os.Unsetenv("GITEA_URL") + + _, err := Load() + if err == nil { + t.Fatal("expected error for missing SESSION_SECRET") + } +} + +func TestLoad_ShortSessionSecret(t *testing.T) { + os.Setenv("GITEA_URL", "https://gitea.example.com") + os.Setenv("SESSION_SECRET", "tooshort") + defer func() { + os.Unsetenv("GITEA_URL") + os.Unsetenv("SESSION_SECRET") + }() + + _, err := Load() + if err == nil { + t.Fatal("expected error for short SESSION_SECRET") + } +} diff --git a/internal/handlers/.gitkeep b/internal/handlers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go new file mode 100644 index 0000000..0c7f721 --- /dev/null +++ b/internal/handlers/settings.go @@ -0,0 +1,177 @@ +package handlers + +import ( + "html/template" + "net/http" + "strings" + + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/auth" + "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware" +) + +var settingsTemplate = template.Must(template.New("settings").Parse(` + + + + +Status: Connected
+A Gitea API token is configured.
+ +