Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func main() {
}

repo := domain.NewRedisRepository(rdb)
handler := app.NewHandler(repo)
handler := app.NewHandler(repo, cfg.DefaultTheme)

secCfg := app.SecurityHeadersConfig{
RequireHTTPS: cfg.RequireHTTPS,
Expand Down
8 changes: 5 additions & 3 deletions internal/app/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ import (
)

type Handler struct {
repo domain.SecretRepository
repo domain.SecretRepository
defaultTheme string
}

func NewHandler(repo domain.SecretRepository) *Handler {
return &Handler{repo: repo}
func NewHandler(repo domain.SecretRepository, defaultTheme string) *Handler {
return &Handler{repo: repo, defaultTheme: defaultTheme}
}

func (h *Handler) HandleHealth(w http.ResponseWriter, r *http.Request) {
Expand All @@ -44,6 +45,7 @@ func (h *Handler) HandleConfig(w http.ResponseWriter, r *http.Request) {
utility.WriteJSON(w, http.StatusOK, domain.ConfigRes{
MaxSecretSize: domain.MaxSecretSize,
ExpiryOptions: domain.ExpiryOptions,
DefaultTheme: h.defaultTheme,
})
}

Expand Down
50 changes: 41 additions & 9 deletions internal/app/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func (m *mockSecretRepository) Ping(ctx context.Context) error {

func TestHandler_HandleHealth(t *testing.T) {
t.Run("returns ok without redis check", func(t *testing.T) {
handler := NewHandler(nil)
handler := NewHandler(nil, "")
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()

Expand All @@ -96,7 +96,7 @@ func TestHandler_HandleHealth(t *testing.T) {
return nil
},
}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")
req := httptest.NewRequest(http.MethodGet, "/health?redis=true", nil)
rr := httptest.NewRecorder()

Expand All @@ -116,7 +116,7 @@ func TestHandler_HandleHealth(t *testing.T) {
return errors.New("connection refused")
},
}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")
req := httptest.NewRequest(http.MethodGet, "/health?redis=true", nil)
rr := httptest.NewRecorder()

Expand All @@ -134,7 +134,7 @@ func TestHandler_HandleHealth(t *testing.T) {
}

func TestHandler_HandleConfig(t *testing.T) {
handler := NewHandler(nil)
handler := NewHandler(nil, "")
req := httptest.NewRequest(http.MethodGet, "/config", nil)
rr := httptest.NewRecorder()

Expand All @@ -158,11 +158,43 @@ func TestHandler_HandleConfig(t *testing.T) {
}
}

func TestHandler_HandleConfig_DefaultTheme(t *testing.T) {
testCases := []struct {
defaultTheme string
wantTheme string
}{
{"", ""},
{"light", "light"},
{"dark", "dark"},
}

for _, tc := range testCases {
t.Run("default_theme="+tc.defaultTheme, func(t *testing.T) {
handler := NewHandler(nil, tc.defaultTheme)
req := httptest.NewRequest(http.MethodGet, "/config", nil)
rr := httptest.NewRecorder()

handler.HandleConfig(rr, req)

if status := rr.Code; status != http.StatusOK {
t.Fatalf("wrong status code: got %v want %v", status, http.StatusOK)
}
var res domain.ConfigRes
if err := json.NewDecoder(rr.Body).Decode(&res); err != nil {
t.Fatalf("could not decode response: %v", err)
}
if res.DefaultTheme != tc.wantTheme {
t.Errorf("wrong default_theme: got %q want %q", res.DefaultTheme, tc.wantTheme)
}
})
}
}

func TestHandler_HandleCreate(t *testing.T) {
utility.LowerCryptoParamsForTest(t)

mockRepo := &mockSecretRepository{}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")

t.Run("successful creation", func(t *testing.T) {
mockRepo.StoreSecretFunc = func(
Expand Down Expand Up @@ -282,7 +314,7 @@ func TestHandler_HandleRead(t *testing.T) {
utility.LowerCryptoParamsForTest(t)

mockRepo := &mockSecretRepository{}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")
secretID := "test-id"
passcode, err := utility.GeneratePasscode()
if err != nil {
Expand Down Expand Up @@ -489,7 +521,7 @@ func TestHandler_HandleCreate_ExpiryOptions(t *testing.T) {
return nil
},
}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")

reqBody := `{"secret":"test","expiry":"` + tc.expiry + `"}`
req := httptest.NewRequest(
Expand Down Expand Up @@ -518,7 +550,7 @@ func TestHandler_HandleCreate_HTTPSDetection(t *testing.T) {
return nil
},
}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")

t.Run("detects HTTPS from X-Forwarded-Proto header", func(t *testing.T) {
reqBody := `{"secret":"test"}`
Expand Down Expand Up @@ -560,7 +592,7 @@ func TestHandler_HandleCreate_WhitespaceSecret(t *testing.T) {
utility.LowerCryptoParamsForTest(t)

mockRepo := &mockSecretRepository{}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")

testCases := []struct {
name string
Expand Down
12 changes: 6 additions & 6 deletions internal/app/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestNewRouter_Routes(t *testing.T) {
return nil
},
}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")
router := NewRouter(handler, nil, SecurityHeadersConfig{})

testCases := []struct {
Expand Down Expand Up @@ -61,7 +61,7 @@ func TestNewRouter_CreateEndpoint(t *testing.T) {
return nil
},
}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")
router := NewRouter(handler, nil, SecurityHeadersConfig{})

reqBody := `{"secret":"test-secret"}`
Expand All @@ -84,7 +84,7 @@ func TestNewRouter_ReadEndpoint_ValidUUID(t *testing.T) {
return nil, redis.Nil
},
}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")
router := NewRouter(handler, nil, SecurityHeadersConfig{})

// Valid UUID format
Expand All @@ -105,7 +105,7 @@ func TestNewRouter_ReadEndpoint_InvalidUUID(t *testing.T) {
utility.LowerCryptoParamsForTest(t)

mockRepo := &mockSecretRepository{}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")
router := NewRouter(handler, nil, SecurityHeadersConfig{})

// Invalid UUID format - should not match route
Expand All @@ -126,7 +126,7 @@ func TestNewRouter_SecurityHeaders(t *testing.T) {
utility.LowerCryptoParamsForTest(t)

mockRepo := &mockSecretRepository{}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")
router := NewRouter(handler, nil, SecurityHeadersConfig{})

req := httptest.NewRequest(http.MethodGet, "/health", nil)
Expand All @@ -147,7 +147,7 @@ func TestNewRouter_RedirectSlashes(t *testing.T) {
utility.LowerCryptoParamsForTest(t)

mockRepo := &mockSecretRepository{}
handler := NewHandler(mockRepo)
handler := NewHandler(mockRepo, "")
router := NewRouter(handler, nil, SecurityHeadersConfig{})

// Request with trailing slash should redirect
Expand Down
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ type Config struct {

// Security settings
RequireHTTPS bool // enforce HTTPS with HSTS header (disable with NO_HTTPS=1)

// UI settings
DefaultTheme string // "" | "light" | "dark"
}

// DefaultConfig returns a Config with sensible defaults.
Expand Down Expand Up @@ -106,6 +109,14 @@ func Load() (Config, error) {
cfg.RequireHTTPS = false
}

// UI settings
if theme := os.Getenv("DEFAULT_THEME"); theme != "" {
if theme != "light" && theme != "dark" {
return Config{}, fmt.Errorf("DEFAULT_THEME must be 'light' or 'dark', got %q", theme)
}
cfg.DefaultTheme = theme
}

return cfg, nil
}

Expand Down
39 changes: 39 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,45 @@ func TestLoad_NoHTTPSDisablesRequireHTTPS(t *testing.T) {
}
}

func TestLoad_DefaultTheme(t *testing.T) {
t.Run("unset defaults to empty string", func(t *testing.T) {
os.Unsetenv("DEFAULT_THEME")

cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.DefaultTheme != "" {
t.Errorf("expected empty DefaultTheme, got %q", cfg.DefaultTheme)
}
})

for _, theme := range []string{"light", "dark"} {
t.Run(theme, func(t *testing.T) {
os.Setenv("DEFAULT_THEME", theme)
defer os.Unsetenv("DEFAULT_THEME")

cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.DefaultTheme != theme {
t.Errorf("expected DefaultTheme %q, got %q", theme, cfg.DefaultTheme)
}
})
}

t.Run("invalid value returns error", func(t *testing.T) {
os.Setenv("DEFAULT_THEME", "blue")
defer os.Unsetenv("DEFAULT_THEME")

_, err := Load()
if err == nil {
t.Error("expected error for invalid DEFAULT_THEME")
}
})
}

func TestLoad_NoHTTPSIgnoresOtherValues(t *testing.T) {
testCases := []string{"0", "false", "no", ""}

Expand Down
1 change: 1 addition & 0 deletions internal/domain/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ type ReadRes struct {
type ConfigRes struct {
MaxSecretSize int `json:"max_secret_size"`
ExpiryOptions []string `json:"expiry_options"`
DefaultTheme string `json:"default_theme,omitempty"`
}
21 changes: 21 additions & 0 deletions web/frontend/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@
--error-color: #d93025;
--warning-background-color: #ffc;
--spacing-unit: 8px;
color-scheme: light;
}

[data-theme="dark"] {
--primary-color: #e8e8e8;
--secondary-color: #111;
--background-color: #111;
--text-color: #e8e8e8;
--border-color: #2a2a2a;
--light-gray-color: #1e1e1e;
--medium-gray-color: #444;
--dark-gray-color: #888;
--hover-color: #ccc;
--error-color: #f87171;
--warning-background-color: #3a3200;
color-scheme: dark;
}

html,
Expand Down Expand Up @@ -41,6 +57,11 @@ select {
-webkit-appearance: none;
}

::placeholder {
color: var(--dark-gray-color);
opacity: 1;
}

input:focus,
textarea:focus,
select:focus {
Expand Down
7 changes: 6 additions & 1 deletion web/frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import { Create } from './pages/Create';
import { Read } from './pages/Read';
import { About } from './pages/About';
import { Layout } from './components/Layout';
import { useConfig } from './hooks/useConfig';
import { useTheme } from './hooks/useTheme';

function App() {
const config = useConfig();
const { toggleTheme } = useTheme(config.default_theme);

return (
<Layout>
<Layout onToggleTheme={toggleTheme}>
<Router>
<Create path="/" />
<Read path="/read/:id" />
Expand Down
13 changes: 13 additions & 0 deletions web/frontend/components/Layout/Layout.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,22 @@
right: 10px;
font-family: monospace;
font-size: 12px;
display: flex;
align-items: center;
}

.navLink {
text-decoration: underline;
color: var(--primary-color);
}

.themeToggle {
background: none;
border: none;
padding: 0;
width: auto;
cursor: pointer;
color: var(--primary-color);
line-height: 0;
margin-left: 6px;
}
9 changes: 8 additions & 1 deletion web/frontend/components/Layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import styles from './Layout.module.css';

interface LayoutProps {
children: ComponentChildren;
onToggleTheme: () => void;
}

export function Layout({ children }: LayoutProps) {
export function Layout({ children, onToggleTheme }: LayoutProps) {
return (
<div class={styles.container}>
<div class={styles.nav}>
Expand All @@ -17,6 +18,12 @@ export function Layout({ children }: LayoutProps) {
about
</a>
{' | secretapi \u00A9 2025'}
<button class={styles.themeToggle} onClick={onToggleTheme} aria-label="Toggle theme">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1.5" />
<path d="M7 1 A6 6 0 0 0 7 13 Z" fill="currentColor" />
</svg>
</button>
</div>
{children}
</div>
Expand Down
Loading