diff --git a/client/app.js b/client/app.js index 805b422..f2436a9 100644 --- a/client/app.js +++ b/client/app.js @@ -15,6 +15,41 @@ const els = { discordStart: document.getElementById("discordStart"), }; +function defaultApiBase() { + // 1) URL query override: …/index.html?api=http://host:9080 + try { + const qs = new URLSearchParams(window.location.search); + const qApi = qs.get("api"); + if (qApi) return qApi.replace(/\/+$/, ""); + } catch {} + + // 2) Meta override in index.html: + const m = document.querySelector('meta[name="gc-api-base"]'); + if (m && m.content) return m.content.replace(/\/+$/, ""); + + // 3) Heuristic from frontend origin + try { + const u = new URL(window.location.href); + const proto = u.protocol; + const host = u.hostname; // no port + const portStr = u.port; // "" if default (80/443) + const bracketHost = host.includes(":") ? `[${host}]` : host; // IPv6-safe + + const port = portStr ? parseInt(portStr, 10) : null; + let apiPort = port; + + // Known frontend→API mappings + if (port === 8082) apiPort = 8080; + else if (port === 9082) apiPort = 9080; + else if (port) apiPort = Math.max(1, port - 2); // generic “minus two” fallback + + return apiPort ? `${proto}//${bracketHost}:${apiPort}` : `${proto}//${bracketHost}`; + } catch { + return window.location.origin.replace(/\/+$/, ""); + } +} + + const LS_KEY = "gc_client_config_v1"; const POSTS_KEY = "gc_posts_index_v1"; @@ -33,7 +68,17 @@ function saveConfig(c){ localStorage.setItem(LS_KEY, JSON.stringify(c)); Object. function getPosts(){ try { return JSON.parse(localStorage.getItem(POSTS_KEY)) ?? []; } catch { return []; } } function setPosts(v){ localStorage.setItem(POSTS_KEY, JSON.stringify(v)); renderPosts(); } function norm(u){ return (u||"").replace(/\/+$/,""); } -function applyConfig(){ els.shardUrl.value = cfg.url ?? location.origin; els.bearer.value = cfg.bearer ?? ""; els.passphrase.value = cfg.passphrase ?? ""; } +function applyConfig() { + // If no URL saved yet, detect a sensible default and persist it + if (!cfg.url) { + const detected = defaultApiBase(); // uses ?api=…, , or port heuristic + cfg.url = detected; + try { localStorage.setItem(LS_KEY, JSON.stringify(cfg)); } catch {} + } + els.shardUrl.value = cfg.url; + els.bearer.value = cfg.bearer ?? ""; + els.passphrase.value = cfg.passphrase ?? ""; +} async function checkHealth() { if (!cfg.url) return; els.health.textContent = "Checking…"; @@ -151,8 +196,18 @@ async function delServer(p) { } async function discordStart() { + // Last-resort auto-fill if user didn’t hit Save + if (!cfg.url) { + const derived = defaultApiBase(); + if (derived) { + cfg.url = derived; + try { localStorage.setItem(LS_KEY, JSON.stringify(cfg)); } catch {} + els.shardUrl.value = derived; + } + } if (!cfg.url) { alert("Set shard URL first."); return; } - const r = await fetch(cfg.url + "/v1/auth/discord/start", { headers: { "X-GC-3P-Assent":"1" }}); + + const r = await fetch(cfg.url + "/v1/auth/discord/start", { headers: { "X-GC-3P-Assent": "1" }}); if (!r.ok) { alert("Discord SSO not available"); return; } const j = await r.json(); location.href = j.url; diff --git a/client/index.html b/client/index.html index 35263d3..e35f7d1 100644 --- a/client/index.html +++ b/client/index.html @@ -4,6 +4,9 @@ GreenCoast — Client + + + @@ -12,6 +15,10 @@

Connect

+
+ + +
@@ -65,5 +72,16 @@
+ diff --git a/configs/shard.test.yaml b/configs/shard.test.yaml new file mode 100644 index 0000000..038b7c8 --- /dev/null +++ b/configs/shard.test.yaml @@ -0,0 +1,69 @@ +shard_id: "gc-test-001" + +listen: + http: "0.0.0.0:9080" # API for testers + https: "" # if you terminate TLS at a proxy, leave empty + ws: "0.0.0.0:9081" # reserved + +tls: + enable: false # set true only if serving HTTPS directly here + cert_file: "/etc/greencoast/tls/cert.pem" + key_file: "/etc/greencoast/tls/key.pem" + +federation: + mtls_enable: false + listen: "0.0.0.0:9443" + cert_file: "/etc/greencoast/fed/cert.pem" + key_file: "/etc/greencoast/fed/key.pem" + client_ca_file: "/etc/greencoast/fed/clients_ca.pem" + +ui: + enable: true + path: "./client" + base_url: "/" + frontend_http: "0.0.0.0:9082" # static client for testers + +storage: + backend: "fs" + path: "/var/lib/greencoast/objects" + max_object_kb: 128 # lower if you want to constrain uploads + +security: + zero_trust: true + require_mtls_for_federation: true + accept_client_signed_tokens: true + log_level: "warn" + +privacy: + retain_ip: "no" + retain_user_agent: "no" + retain_timestamps: "coarse" + +auth: + # IMPORTANT: rotate this per environment (use `openssl rand -hex 32`) + signing_secret: "D941C4F91D0046D28CDBC3F425DE0B4EA26BD2A80434E0F160D1B7C813EB43F8" + sso: + discord: + enabled: true + client_id: "REPLACE" + client_secret: "REPLACE" + # must exactly match your Discord app's allowed redirect + redirect_uri: "https://greencoast.fullmooncyberworks.com/auth-callback.html" + google: + enabled: false + client_id: "" + client_secret: "" + redirect_uri: "" + facebook: + enabled: false + client_id: "" + client_secret: "" + redirect_uri: "" + two_factor: + webauthn_enabled: false + totp_enabled: false + +limits: + rate: + burst: 20 + per_minute: 60 # slightly tighter for external testing diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..0c32e20 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,26 @@ +version: "3.9" + +services: + shard-test: + build: . + container_name: greencoast-shard-test + restart: unless-stopped + user: "0:0" + # You can keep these published for local debugging; Tunnel doesn't require them. + ports: + - "9080:9080" # API + - "9082:9082" # Frontend + environment: + - GC_DEV_ALLOW_UNAUTH=false + volumes: + - ./testdata:/var/lib/greencoast + - ./configs/shard.test.yaml:/app/shard.yaml:ro + - ./client:/app/client:ro + + cloudflared: + image: cloudflare/cloudflared:latest + # Use the token you copy from Cloudflare Zero Trust → Tunnels + command: tunnel --no-autoupdate run --token ${CF_TUNNEL_TOKEN} + restart: unless-stopped + depends_on: + - shard-test diff --git a/internal/api/static.go b/internal/api/static.go index b6ffc80..ce926fb 100644 --- a/internal/api/static.go +++ b/internal/api/static.go @@ -2,6 +2,7 @@ package api import ( "log" + "mime" "net/http" "os" "path/filepath" @@ -9,7 +10,14 @@ import ( "time" ) -// Mount static on the API mux (kept for compatibility; still serves under API port if you want) +func init() { + // Ensure common types are known (some distros are sparse by default) + _ = mime.AddExtensionType(".js", "application/javascript; charset=utf-8") + _ = mime.AddExtensionType(".css", "text/css; charset=utf-8") + _ = mime.AddExtensionType(".html", "text/html; charset=utf-8") + _ = mime.AddExtensionType(".map", "application/json; charset=utf-8") +} + func (s *Server) MountStatic(dir string, baseURL string) { if dir == "" { return @@ -23,7 +31,6 @@ func (s *Server) MountStatic(dir string, baseURL string) { } } -// NEW: serve the same static handler on its own port (frontend). func (s *Server) ListenFrontendHTTP(addr, dir, baseURL string) error { if dir == "" || addr == "" { return nil @@ -48,6 +55,7 @@ func (s *Server) staticHandler(dir, baseURL string) http.Handler { } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.secureHeaders(w) + up := strings.TrimPrefix(r.URL.Path, baseURL) if up == "" || strings.HasSuffix(r.URL.Path, "/") { up = "index.html" @@ -57,12 +65,19 @@ func (s *Server) staticHandler(dir, baseURL string) http.Handler { http.NotFound(w, r) return } + + // Serve file if it exists, else SPA-fallback to index.html if st, err := os.Stat(full); err == nil && !st.IsDir() { + // Set Content-Type explicitly based on extension + if ctype := mime.TypeByExtension(filepath.Ext(full)); ctype != "" { + w.Header().Set("Content-Type", ctype) + } http.ServeFile(w, r, full) return } fallback := filepath.Join(dir, "index.html") if _, err := os.Stat(fallback); err == nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") http.ServeFile(w, r, fallback) return }