[feat](web): serve the SPA and web actions on the single listener
Add the browser edge (contract rev 8.2/9): a Preact/Vite SPA under web/, an embedded placeholder bundle, the /web/* auth and action handlers, and the explicit browser-action allowlist. The SPA and the API share the one plaintext :8080 listener; there is no second port and no peer edge, so main.go also drops the mTLS listener and the identity mounts in the debug compose fall away. /_manifest announces the web block only while the SPA is mounted (honesty rule), always with the single service port. The DSNs move to the shared database's scram-sha-256 credentials, matching rev 9's removal of the PKI layer. Committed web/bun.lock because web/package.json and vite.config.ts are new in this commit. src/web/dist/index.html is a hand-written placeholder, not a generated bundle: it keeps bare `go build`/`go vet` working from a clean checkout, while /web/dist (the real build output) stays gitignored.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
EnvEnabled = "DCNTRLIZED_WEB_ENABLED"
|
||||
EnvOrigins = "DCNTRLIZED_WEB_ORIGINS"
|
||||
EnvDir = "DCNTRLIZED_WEB_DIR"
|
||||
EnvCookieSecure = "COOKIE_SECURE"
|
||||
EnvAccessMaxAge = "DCNTRLIZED_WEB_ACCESS_MAX_AGE"
|
||||
EnvRefreshMaxAge = "DCNTRLIZED_WEB_REFRESH_MAX_AGE"
|
||||
|
||||
DefaultAccessMaxAge = 900
|
||||
DefaultRefreshMaxAge = 604800
|
||||
)
|
||||
|
||||
// Enabled reports whether the browser surface must be mounted on the service's
|
||||
// single :8080 listener. Unset/unknown values default to on in dev/debug and
|
||||
// off in prod (opt-in).
|
||||
//
|
||||
// Contract rev 9: there is no separate browser port any more
|
||||
// (DCNTRLIZED_WEB_PORT is gone) — the SPA and /web/* ride the same listener as
|
||||
// the API, so this is purely a mount switch.
|
||||
func Enabled() bool {
|
||||
switch os.Getenv(EnvEnabled) {
|
||||
case "true":
|
||||
return true
|
||||
case "false":
|
||||
return false
|
||||
default:
|
||||
return devMode()
|
||||
}
|
||||
}
|
||||
|
||||
// devMode reports the dev/debug selector directly from the environment. It no
|
||||
// longer depends on the shared tlsutil package: rev 9 removed the transport
|
||||
// security layer (and with it the reason to consult it here).
|
||||
func devMode() bool {
|
||||
return os.Getenv("DCNTRLIZED_DEBUG") == "true" || os.Getenv("DCNTRLIZED_DEV") == "true"
|
||||
}
|
||||
|
||||
// ErrDisabled is returned by Mount when the browser surface is disabled. The
|
||||
// disabled state is enforced in code, not by caller convention, so a disabled
|
||||
// edge can never mount a route even if a caller forgets the `if web.Enabled()`
|
||||
// guard (S31 rewrite: there is no second socket to bind any more).
|
||||
var ErrDisabled = errors.New("web: browser edge disabled")
|
||||
|
||||
// Validate fails closed when the browser edge is enabled outside dev/debug: an
|
||||
// Origin allowlist is mandatory (DNS-rebinding hardening) and session cookies
|
||||
// must be Secure (F3 — never rely on the compose default alone). The old "https
|
||||
// hub base" requirement is gone with the de-mTLS migration.
|
||||
func Validate() error {
|
||||
if !Enabled() || devMode() {
|
||||
return nil
|
||||
}
|
||||
if len(origins()) == 0 {
|
||||
return errors.New("web: DCNTRLIZED_WEB_ORIGINS is required when the browser edge is enabled outside dev/debug")
|
||||
}
|
||||
if !cookieSecure() {
|
||||
return errors.New("web: COOKIE_SECURE=true is required when the browser edge is enabled outside dev/debug")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func origins() []string {
|
||||
raw := os.Getenv(EnvOrigins)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
if o := normalizeOrigin(part); o != "" {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeOrigin returns the canonical scheme://host form (lowercase, default
|
||||
// port stripped) or "" when the entry is unusable.
|
||||
func normalizeOrigin(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" || u.User != nil {
|
||||
return ""
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
host := strings.ToLower(u.Host)
|
||||
if (scheme == "https" && strings.HasSuffix(host, ":443")) ||
|
||||
(scheme == "http" && strings.HasSuffix(host, ":80")) {
|
||||
host = host[:strings.LastIndex(host, ":")]
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
Reference in New Issue
Block a user