Skip to content

Commit 0d5abe5

Browse files
committed
feat: Bitwarden-style server selector with connection test (#14)
Builds on the connectivity fix to make the in-app "change server" flow production-grade for a distributed client (web today, iOS later, #22), rather than a bare text field that fails silently. Backend: - Public GET /api/v1/info returns { name, version } — the "test connection" probe. It runs under the same CORS policy as real requests, so a passing test honestly predicts that login will be allowed. - CORS overhaul: AllowCredentials is now false (auth is Bearer-token, not cookies — and credentials mode is incompatible with a wildcard origin), and CORS_ORIGIN is a comma-separated allow-list. Unset/"*"/dev allow any origin; an explicit list locks it down. Unit-tested. Frontend: - testServerConnection() probes /info with a timeout and classifies the result; network/CORS failures get an actionable message. - normalizeServerUrl() defaults a bare host to the page's protocol, so an HTTPS-served app doesn't coerce to http:// and get blocked as mixed content. - Extracted the duplicated panel from Login/Register into one <ServerSettings/> component: validate -> Test & Save -> live status ("Connected · lyftr v0.1.0" / "Saved, but <reason>"). Warn-but-save: the choice persists immediately; the probe is advisory. Docs: document CORS_ORIGIN (list/"*") and BACKEND_ORIGIN in the README and backend/.env.example. Tests: e2e covers register, invalid-URL rejection, default reverse-proxy connect, and page-protocol normalization; Go tests cover /info and CORS. https://claude.ai/code/session_01EeGweBeouxSsT6nSaXy8Pk
1 parent ebdf680 commit 0d5abe5

13 files changed

Lines changed: 304 additions & 138 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,9 @@ All variables live in `.env` at the project root.
118118
| Variable | Default | Description |
119119
|----------|---------|-------------|
120120
| `JWT_SECRET` | *required* | Min 32-char secret for signing tokens |
121-
| `CORS_ORIGIN` | `http://localhost` | Set to your domain in production |
121+
| `CORS_ORIGIN` | `http://localhost` | Comma-separated allow-list of client origins. Use `*` to allow any (the API is Bearer-token based, no cookies) |
122122
| `PORT` | `80` | Host port for the web interface |
123+
| `BACKEND_ORIGIN` | `backend:3000` | `host:port` the frontend proxies `/api` to. Must match the backend's `PORT` if you change it |
123124

124125
---
125126

backend/.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ DB_PATH=./data/lyftr.db
1717
JWT_SECRET=change-me-in-production-min-32-chars!!
1818
JWT_EXPIRY=3600
1919

20-
# CORS — set to your frontend origin
20+
# CORS — comma-separated allow-list of client origins permitted to call the API.
21+
# Use * to allow any origin (the API is Bearer-token based, so no cookies are at risk).
2122
CORS_ORIGIN=http://localhost:5173
2223

2324
# ExerciseDB (RapidAPI)

backend/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type Config struct {
2020
JWTExpiry string
2121
CORSOrigin string
2222
Env string
23+
Version string
2324
}
2425

2526
var C *Config
@@ -40,6 +41,7 @@ func Load() {
4041
JWTExpiry: getEnv("JWT_EXPIRY", "3600"),
4142
CORSOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
4243
Env: getEnv("ENV", "development"),
44+
Version: getEnv("APP_VERSION", "0.1.0"),
4345
}
4446

4547
if C.Env == "production" && C.JWTSecret == "change-me-in-production-min-32-chars!!" {

backend/controllers/server.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package controllers
2+
3+
import (
4+
"github.com/Cawlumm/lyftr-backend/config"
5+
"github.com/Cawlumm/lyftr-backend/utils"
6+
"github.com/gin-gonic/gin"
7+
)
8+
9+
// ServerInfo is a public, unauthenticated endpoint clients use to verify that a
10+
// configured server URL is reachable and is actually a Lyftr backend — the
11+
// "test connection" behind the in-app server selector. It runs under the same
12+
// CORS policy as the rest of the API, so a successful probe honestly predicts
13+
// that authenticated requests from the same origin will be allowed too.
14+
func ServerInfo(c *gin.Context) {
15+
utils.OK(c, gin.H{
16+
"name": "lyftr",
17+
"version": config.C.Version,
18+
})
19+
}

backend/controllers/server_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package controllers
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/Cawlumm/lyftr-backend/config"
8+
)
9+
10+
func TestServerInfo(t *testing.T) {
11+
config.C = &config.Config{Version: "9.9.9"}
12+
13+
c, w := newContext(0, "GET", "/api/v1/info", nil)
14+
ServerInfo(c)
15+
16+
if w.Code != http.StatusOK {
17+
t.Fatalf("status = %d, want 200", w.Code)
18+
}
19+
body := decodeResponse(t, w)
20+
data, ok := body["data"].(map[string]any)
21+
if !ok {
22+
t.Fatalf("missing data envelope: %v", body)
23+
}
24+
if data["name"] != "lyftr" {
25+
t.Errorf("name = %v, want lyftr", data["name"])
26+
}
27+
if data["version"] != "9.9.9" {
28+
t.Errorf("version = %v, want 9.9.9", data["version"])
29+
}
30+
}

backend/routes/cors_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package routes
2+
3+
import (
4+
"testing"
5+
6+
"github.com/Cawlumm/lyftr-backend/config"
7+
)
8+
9+
func TestParseOrigins(t *testing.T) {
10+
got := parseOrigins(" http://a.com , http://b.com ,")
11+
if len(got) != 2 || got[0] != "http://a.com" || got[1] != "http://b.com" {
12+
t.Fatalf("parseOrigins = %#v", got)
13+
}
14+
if len(parseOrigins("")) != 0 {
15+
t.Fatalf("empty input should yield no origins")
16+
}
17+
}
18+
19+
func TestCORSConfig(t *testing.T) {
20+
// Bearer-token auth means credentials mode must stay off.
21+
config.C = &config.Config{Env: "production", CORSOrigin: "http://a.com,http://b.com"}
22+
cfg := corsConfig()
23+
if cfg.AllowCredentials {
24+
t.Error("AllowCredentials should be false")
25+
}
26+
if cfg.AllowAllOrigins {
27+
t.Error("an explicit allow-list should not allow all origins")
28+
}
29+
if len(cfg.AllowOrigins) != 2 {
30+
t.Errorf("AllowOrigins = %v, want 2 entries", cfg.AllowOrigins)
31+
}
32+
33+
config.C = &config.Config{Env: "production", CORSOrigin: "*"}
34+
if !corsConfig().AllowAllOrigins {
35+
t.Error(`CORS_ORIGIN="*" should allow all origins`)
36+
}
37+
38+
config.C = &config.Config{Env: "development", CORSOrigin: "http://a.com"}
39+
if !corsConfig().AllowAllOrigins {
40+
t.Error("development should allow all origins")
41+
}
42+
}

backend/routes/routes.go

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package routes
22

33
import (
4+
"strings"
5+
46
"github.com/Cawlumm/lyftr-backend/config"
57
"github.com/Cawlumm/lyftr-backend/controllers"
68
"github.com/Cawlumm/lyftr-backend/middleware"
@@ -9,23 +11,17 @@ import (
911
)
1012

1113
func Setup(r *gin.Engine) {
12-
corsOrigins := []string{config.C.CORSOrigin}
13-
if config.C.Env == "development" {
14-
corsOrigins = []string{"*"}
15-
}
16-
r.Use(cors.New(cors.Config{
17-
AllowOrigins: corsOrigins,
18-
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
19-
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
20-
AllowCredentials: config.C.Env != "development",
21-
}))
14+
r.Use(cors.New(corsConfig()))
2215

2316
r.GET("/health", func(c *gin.Context) {
2417
c.JSON(200, gin.H{"status": "ok"})
2518
})
2619

2720
api := r.Group("/api/v1")
2821

22+
// Public: "test connection" probe for the in-app server selector.
23+
api.GET("/info", controllers.ServerInfo)
24+
2925
// Auth (public)
3026
auth := api.Group("/auth")
3127
{
@@ -97,3 +93,42 @@ func Setup(r *gin.Engine) {
9793
protected.POST("admin/reset-exercises", controllers.ResetExercises)
9894
}
9995
}
96+
97+
// corsConfig builds the CORS policy. Auth is Bearer-token based (no cookies), so
98+
// credentials mode is off — which also lets the wildcard origin be valid. In
99+
// development, or when CORS_ORIGIN is unset or "*", any origin is allowed; in
100+
// production CORS_ORIGIN is a comma-separated allow-list of client origins.
101+
func corsConfig() cors.Config {
102+
cfg := cors.Config{
103+
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
104+
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
105+
AllowCredentials: false,
106+
}
107+
108+
origins := parseOrigins(config.C.CORSOrigin)
109+
if config.C.Env == "development" || len(origins) == 0 || contains(origins, "*") {
110+
cfg.AllowAllOrigins = true
111+
} else {
112+
cfg.AllowOrigins = origins
113+
}
114+
return cfg
115+
}
116+
117+
func parseOrigins(raw string) []string {
118+
out := make([]string, 0)
119+
for _, p := range strings.Split(raw, ",") {
120+
if t := strings.TrimSpace(p); t != "" {
121+
out = append(out, t)
122+
}
123+
}
124+
return out
125+
}
126+
127+
func contains(list []string, v string) bool {
128+
for _, item := range list {
129+
if item == v {
130+
return true
131+
}
132+
}
133+
return false
134+
}

web/e2e/auth.spec.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,25 @@ test('server settings rejects an invalid URL without persisting it', async ({ pa
1717
await page.goto('/login')
1818
await page.getByRole('button', { name: /server settings/i }).click()
1919
await page.getByPlaceholder('Leave blank to use this site').fill('not a valid url')
20-
await page.getByRole('button', { name: 'Save' }).click()
20+
await page.getByRole('button', { name: /test & save/i }).click()
2121
await expect(page.getByText(/enter a full url/i)).toBeVisible()
2222
expect(await page.evaluate(() => localStorage.getItem('server_url'))).toBeNull()
2323
})
2424

25-
test('server settings normalizes a scheme-less host to an absolute origin', async ({ page }) => {
25+
test('server settings tests and connects to the default (reverse proxy)', async ({ page }) => {
2626
await page.goto('/login')
2727
await page.getByRole('button', { name: /server settings/i }).click()
28-
await page.getByPlaceholder('Leave blank to use this site').fill('192.168.1.50:3000')
29-
await page.getByRole('button', { name: 'Save' }).click()
30-
await expect(page.getByText('Current: http://192.168.1.50:3000')).toBeVisible()
31-
expect(await page.evaluate(() => localStorage.getItem('server_url'))).toBe('http://192.168.1.50:3000')
28+
await page.getByRole('button', { name: /test & save/i }).click()
29+
await expect(page.getByText(/connected · lyftr/i)).toBeVisible()
30+
})
31+
32+
test('server settings normalizes a scheme-less host using the page protocol', async ({ page }) => {
33+
// The dev server is served over HTTPS, so a bare host must become https://
34+
// (coercing to http:// would be blocked as mixed content on an HTTPS page).
35+
await page.goto('/login')
36+
await page.getByRole('button', { name: /server settings/i }).click()
37+
await page.getByPlaceholder('Leave blank to use this site').fill('127.0.0.1:9')
38+
await page.getByRole('button', { name: /test & save/i }).click()
39+
await expect(page.getByText('Current: https://127.0.0.1:9')).toBeVisible()
40+
expect(await page.evaluate(() => localStorage.getItem('server_url'))).toBe('https://127.0.0.1:9')
3241
})
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useState } from 'react'
2+
import { ChevronDown, Server, CheckCircle2, AlertTriangle, Loader } from 'lucide-react'
3+
import { useServerStore, normalizeServerUrl } from '../stores/server'
4+
import { testServerConnection } from '../services/api'
5+
6+
type Status =
7+
| { kind: 'idle' }
8+
| { kind: 'testing' }
9+
| { kind: 'ok'; message: string }
10+
| { kind: 'warn'; message: string }
11+
| { kind: 'error'; message: string }
12+
13+
// Bitwarden-style server selector shared by Login and Register: validates and
14+
// normalizes the URL, tests connectivity against the backend's public /info, and
15+
// saves regardless of the result (a server may be temporarily down) while showing
16+
// clear status. Empty means "use this site's own origin via the reverse proxy".
17+
export default function ServerSettings() {
18+
const { serverUrl, setServerUrl } = useServerStore()
19+
const [open, setOpen] = useState(false)
20+
const [input, setInput] = useState(serverUrl)
21+
const [status, setStatus] = useState<Status>({ kind: 'idle' })
22+
23+
const handleSave = async () => {
24+
const raw = input.trim()
25+
if (raw && !normalizeServerUrl(raw)) {
26+
setStatus({ kind: 'error', message: 'Enter a full URL, e.g. http://192.168.1.10:3000' })
27+
return
28+
}
29+
const normalized = normalizeServerUrl(raw) // '' = same origin (reverse proxy)
30+
// Save immediately (warn-but-save): the choice is authoritative right away;
31+
// the connection probe below is advisory and may lag on a slow/down server.
32+
setServerUrl(raw)
33+
setInput(normalized)
34+
setStatus({ kind: 'testing' })
35+
const result = await testServerConnection(normalized)
36+
setStatus(
37+
result.ok
38+
? { kind: 'ok', message: `Connected · ${result.info.name} v${result.info.version}` }
39+
: { kind: 'warn', message: `Saved, but ${result.message}` },
40+
)
41+
}
42+
43+
return (
44+
<div className="mb-4">
45+
<button
46+
type="button"
47+
onClick={() => setOpen(o => !o)}
48+
className="flex items-center gap-2 px-3 py-2 w-full text-xs text-tx-muted hover:text-tx-secondary rounded-lg hover:bg-surface-muted/40 transition-colors"
49+
>
50+
<Server className="w-3.5 h-3.5" />
51+
<span>Server settings</span>
52+
<ChevronDown className={`w-3 h-3 ml-auto transition-transform ${open ? 'rotate-180' : ''}`} />
53+
</button>
54+
55+
{open && (
56+
<div className="mt-2 p-3 bg-surface-muted/30 border border-surface-border rounded-lg space-y-2">
57+
<label className="block text-xs font-medium text-tx-secondary uppercase tracking-wider">Server URL</label>
58+
<input
59+
type="text"
60+
value={input}
61+
onChange={e => { setInput(e.target.value); setStatus({ kind: 'idle' }) }}
62+
placeholder="Leave blank to use this site"
63+
className="input text-sm"
64+
autoComplete="off"
65+
autoCapitalize="off"
66+
spellCheck={false}
67+
/>
68+
69+
{status.kind === 'error' && <p className="text-xs text-error-400">{status.message}</p>}
70+
{status.kind === 'warn' && (
71+
<p className="flex items-start gap-1.5 text-xs text-warning-400">
72+
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
73+
<span>{status.message}</span>
74+
</p>
75+
)}
76+
{status.kind === 'ok' && (
77+
<p className="flex items-center gap-1.5 text-xs text-success-500">
78+
<CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" />
79+
<span>{status.message}</span>
80+
</p>
81+
)}
82+
83+
<div className="flex gap-2 pt-1">
84+
<button
85+
type="button"
86+
onClick={handleSave}
87+
disabled={status.kind === 'testing'}
88+
className="flex-1 px-2 py-1.5 text-xs bg-brand-500 hover:bg-brand-600 disabled:opacity-40 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-1.5"
89+
>
90+
{status.kind === 'testing'
91+
? (<><Loader className="w-3.5 h-3.5 animate-spin" /> Testing…</>)
92+
: 'Test & Save'}
93+
</button>
94+
<button
95+
type="button"
96+
onClick={() => setOpen(false)}
97+
className="flex-1 px-2 py-1.5 text-xs bg-surface-border text-tx-secondary hover:bg-surface-border/80 rounded-lg transition-colors"
98+
>
99+
Close
100+
</button>
101+
</div>
102+
103+
<p className="text-xs text-tx-muted pt-1">
104+
{serverUrl ? `Current: ${serverUrl}` : 'Using this site’s address (reverse proxy)'}
105+
</p>
106+
</div>
107+
)}
108+
</div>
109+
)
110+
}

0 commit comments

Comments
 (0)