feat: add env-based configuration and token-in-cookie auth

Implement 12-factor configuration via environment variables and
token-in-cookie authentication for Gitea API access.

- internal/config/config.go: reads GITEA_URL, GITEA_TOKEN, LISTEN_ADDR,
  SESSION_SECRET from environment with validation
- internal/auth/cookie.go: HMAC-signed HTTP-only cookie for storing
  Gitea API tokens (Secure, SameSite=Strict)
- internal/middleware/auth.go: extracts token from cookie, injects into
  request context, redirects unauthenticated users to /settings
- internal/middleware/logging.go: structured JSON request logging
- internal/handlers/settings.go: settings page for entering/removing
  Gitea API token with mobile-first dark UI
- cmd/server/main.go: integrated config, auth middleware, and settings

Includes unit tests for config loading, cookie signing/verification,
and auth middleware bypass/redirect logic.

Closes leeworks-agents/gitea-mobile#2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent-company
2026-03-26 04:05:31 +00:00
parent 69a1ab86c2
commit 703b2fafb0
12 changed files with 724 additions and 6 deletions
View File
+50
View File
@@ -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
}
+89
View File
@@ -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")
}
}