e66d4669d6
gates / gates (push) Failing after 47s
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.
settings Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
household + the install-time and optional settings. The default for an installed app,
RENDERED at the bare URL and never redirected to.
data the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
guide tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
link — and the WHOLE page for an app that is not installed
logs the log, and the only tab whose poller runs
The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.
The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.
A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.
New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.
The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).
template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
448 lines
17 KiB
Go
448 lines
17 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// ── The setup gate's answerer (v0.280.0, `09` §3 decision 46) ─────────────────────────────────────────
|
|
//
|
|
// traefik asks GET /__felhom_gate/auth (forwardAuth) for every request to an app whose gate is closed
|
|
// (internal/stacks/setup_gate.go writes that route). The answer:
|
|
//
|
|
// - a valid GATE COOKIE for that app host → 200, the request goes to the app;
|
|
// - /__felhom_gate/cb?t=<token> → the token (minted below, 60 s, one use, bound to the host) is swapped for a
|
|
// host-only gate cookie, and the browser goes back where it was going;
|
|
// - a browser GET without one → 302 to https://felhom.<domain>/__gate/start?rd=<where it was going>;
|
|
// - anything else → 401 {"error": …}: a script or a phone app gets a plain refusal.
|
|
//
|
|
// GET /__gate/start is on the DASHBOARD host, where the household's own session cookie already is. With a
|
|
// valid session: a token and a 302 to the app's callback. Without: a page that says the app is waiting for its
|
|
// first setup, with a sign-in link that comes straight back here after the sign-in.
|
|
//
|
|
// The dashboard cookie is NEVER widened to the app hosts (it is host-only, auth.go) — the spike measured that
|
|
// widening it would send the household's session to every app's backend. The gate cookie reaches only its
|
|
// own app host and opens only that app's gate (HMAC over the host). The key is persisted, so a controller
|
|
// restart does not re-gate a browser that already passed.
|
|
// Pinned by internal/web/setup_gate_test.go.
|
|
|
|
const (
|
|
gateCookieName = "felhom_gate"
|
|
gateCookieLife = 7 * 24 * time.Hour
|
|
gateTokenLife = 60 * time.Second
|
|
gateAuthPath = "/__felhom_gate/auth"
|
|
gateCallbackURI = "/__felhom_gate/cb"
|
|
gateStartPath = "/__gate/start"
|
|
// signupClosedPath: the sign-up block's replacePath target (internal/stacks/signup_block.go, decision 47).
|
|
signupClosedPath = "/__felhom_gate/signup-closed"
|
|
)
|
|
|
|
type gateState struct {
|
|
once sync.Once
|
|
key []byte
|
|
mu sync.Mutex
|
|
used map[string]time.Time // token nonces already swapped, until they expire
|
|
}
|
|
|
|
// gateKey loads the gate's HMAC key from the data dir, or makes one. No data dir → a key for this run only.
|
|
func (s *Server) gateKey() []byte {
|
|
s.gate.once.Do(func() {
|
|
s.gate.used = map[string]time.Time{}
|
|
p := ""
|
|
if s.cfg != nil && s.cfg.Paths.DataDir != "" {
|
|
p = filepath.Join(s.cfg.Paths.DataDir, "setup-gate.key")
|
|
if b, err := os.ReadFile(p); err == nil {
|
|
if k, err := hex.DecodeString(strings.TrimSpace(string(b))); err == nil && len(k) == 32 {
|
|
s.gate.key = k
|
|
return
|
|
}
|
|
}
|
|
}
|
|
k := make([]byte, 32)
|
|
_, _ = rand.Read(k)
|
|
s.gate.key = k
|
|
if p != "" {
|
|
if err := os.WriteFile(p, []byte(hex.EncodeToString(k)), 0o600); err != nil {
|
|
s.logger.Printf("[WARN] [web] setup gate: the key could not be saved (%v) — a restart will ask browsers to sign in again", err)
|
|
}
|
|
}
|
|
})
|
|
return s.gate.key
|
|
}
|
|
|
|
func (s *Server) gateMAC(parts ...string) string {
|
|
m := hmac.New(sha256.New, s.gateKey())
|
|
m.Write([]byte(strings.Join(parts, "\x00")))
|
|
return hex.EncodeToString(m.Sum(nil))
|
|
}
|
|
|
|
func (s *Server) gateNow() time.Time {
|
|
if s.gateClock != nil {
|
|
return s.gateClock()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
// gateCookieValid: "<unix expiry>.<mac over host+expiry>".
|
|
func (s *Server) gateCookieValid(r *http.Request, host string) bool {
|
|
c, err := r.Cookie(gateCookieName)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
exp, mac, ok := strings.Cut(c.Value, ".")
|
|
n, err := strconv.ParseInt(exp, 10, 64)
|
|
if !ok || err != nil || s.gateNow().Unix() > n {
|
|
return false
|
|
}
|
|
return hmac.Equal([]byte(mac), []byte(s.gateMAC("cookie", host, exp)))
|
|
}
|
|
|
|
type gateToken struct {
|
|
Host string `json:"h"`
|
|
Exp int64 `json:"e"`
|
|
Nonce string `json:"n"`
|
|
RD string `json:"r"`
|
|
MAC string `json:"m"`
|
|
}
|
|
|
|
func (s *Server) mintGateToken(host, rd string) string {
|
|
nb := make([]byte, 12)
|
|
_, _ = rand.Read(nb)
|
|
t := gateToken{Host: host, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
|
|
t.MAC = s.gateMAC("token", t.Host, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
|
|
b, _ := json.Marshal(t)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
// takeGateToken checks a token for host and uses it up. Returns where the browser was going.
|
|
func (s *Server) takeGateToken(raw, host string) (string, error) {
|
|
b, err := base64.RawURLEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return "", errors.New("malformed")
|
|
}
|
|
var t gateToken
|
|
if json.Unmarshal(b, &t) != nil {
|
|
return "", errors.New("malformed")
|
|
}
|
|
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("token", t.Host, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
|
|
return "", errors.New("bad signature")
|
|
}
|
|
if t.Host != host {
|
|
return "", errors.New("for another app")
|
|
}
|
|
now := s.gateNow()
|
|
if now.Unix() > t.Exp {
|
|
return "", errors.New("expired")
|
|
}
|
|
s.gateKey()
|
|
s.gate.mu.Lock()
|
|
defer s.gate.mu.Unlock()
|
|
for n, until := range s.gate.used {
|
|
if now.After(until) {
|
|
delete(s.gate.used, n)
|
|
}
|
|
}
|
|
if _, seen := s.gate.used[t.Nonce]; seen {
|
|
return "", errors.New("already used")
|
|
}
|
|
s.gate.used[t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second)
|
|
return t.RD, nil
|
|
}
|
|
|
|
// gateRDHost: the host of a return address that may be used — https, on this household's domain, and an
|
|
// app whose gate is closed. Anything else is refused (no open redirect through the dashboard).
|
|
func (s *Server) gateRDHost(rd string) (string, bool) {
|
|
u, err := url.Parse(rd)
|
|
if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || s.stackMgr == nil {
|
|
return "", false
|
|
}
|
|
host := strings.ToLower(u.Hostname())
|
|
if u.Port() != "" || !strings.HasSuffix(host, "."+strings.ToLower(s.cfg.Customer.Domain)) {
|
|
return "", false
|
|
}
|
|
if _, closed, found := s.stackMgr.SetupGateHost(host); !found || !closed {
|
|
return "", false
|
|
}
|
|
return host, true
|
|
}
|
|
|
|
func gateRefuse(w http.ResponseWriter, code int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(code)
|
|
_, _ = w.Write([]byte(`{"error":"this app is waiting for its first setup"}`))
|
|
}
|
|
|
|
// ServeGateAuth is traefik's forwardAuth answer. It trusts X-Forwarded-Host/-Uri/-Method, which traefik sets
|
|
// from the request it is forwarding (the entrypoints trust no client's own X-Forwarded-* headers). Anyone who
|
|
// calls it directly on the docker network learns only yes or no about a cookie they already hold.
|
|
func (s *Server) ServeGateAuth(w http.ResponseWriter, r *http.Request) {
|
|
host := strings.ToLower(r.Header.Get("X-Forwarded-Host"))
|
|
if i := strings.LastIndex(host, ":"); i != -1 {
|
|
host = host[:i]
|
|
}
|
|
uri := r.Header.Get("X-Forwarded-Uri")
|
|
if uri == "" {
|
|
uri = "/"
|
|
}
|
|
method := r.Header.Get("X-Forwarded-Method")
|
|
if s.stackMgr == nil {
|
|
gateRefuse(w, http.StatusForbidden)
|
|
return
|
|
}
|
|
app, closed, found := s.stackMgr.SetupGateHost(host)
|
|
if !found {
|
|
// A host no app claims: fail closed. traefik only asks for hosts a gate file names.
|
|
s.logger.Printf("[WARN] [web] setup gate: asked about %q, which no gated app owns — refused", host)
|
|
gateRefuse(w, http.StatusForbidden)
|
|
return
|
|
}
|
|
if !closed {
|
|
// Opened; traefik has not dropped the file yet (it is removed right after the record is written).
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if u, err := url.Parse(uri); err == nil && u.Path == gateCallbackURI {
|
|
rd, err := s.takeGateToken(u.Query().Get("t"), host)
|
|
if err != nil {
|
|
s.logger.Printf("[WARN] [web] setup gate %s: a sign-in token was refused (%v) — visitor %s", app, err, clientIP(r))
|
|
gateRefuse(w, http.StatusForbidden)
|
|
return
|
|
}
|
|
exp := strconv.FormatInt(s.gateNow().Add(gateCookieLife).Unix(), 10)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: gateCookieName, Value: exp + "." + s.gateMAC("cookie", host, exp), Path: "/",
|
|
MaxAge: int(gateCookieLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
s.logger.Printf("[INFO] [web] setup gate %s: the household passed (a dashboard session vouched for this browser) — visitor %s", app, clientIP(r))
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
http.Redirect(w, r, rd, http.StatusFound)
|
|
return
|
|
}
|
|
if s.gateCookieValid(r, host) {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if (method == "" || method == http.MethodGet) && strings.Contains(r.Header.Get("Accept"), "text/html") {
|
|
rd := "https://" + host + uri
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
http.Redirect(w, r, "https://felhom."+s.cfg.Customer.Domain+gateStartPath+"?"+url.Values{"rd": {rd}}.Encode(), http.StatusFound)
|
|
return
|
|
}
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] setup gate %s: %s %s without a pass — 401 (visitor %s)", app, method, uri, clientIP(r))
|
|
}
|
|
gateRefuse(w, http.StatusUnauthorized)
|
|
}
|
|
|
|
// ServeGateStart is /__gate/start on the dashboard host.
|
|
func (s *Server) ServeGateStart(w http.ResponseWriter, r *http.Request) {
|
|
rd := r.URL.Query().Get("rd")
|
|
host, ok := s.gateRDHost(rd)
|
|
if !ok {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if s.hasSession(r) {
|
|
http.Redirect(w, r, "https://"+host+gateCallbackURI+"?"+url.Values{"t": {s.mintGateToken(host, rd)}}.Encode(), http.StatusFound)
|
|
return
|
|
}
|
|
next := gateStartPath + "?" + url.Values{"rd": {rd}}.Encode()
|
|
data := map[string]interface{}{
|
|
"LoginURL": "/login?" + url.Values{"next": {next}}.Encode(),
|
|
"Host": host,
|
|
}
|
|
if app, _, found := s.stackMgr.SetupGateHost(host); found {
|
|
if st, ok := s.stackMgr.GetStack(app); ok {
|
|
data["AppName"] = st.Meta.DisplayName
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.executeTemplateLang(w, r, "setupgate", data); err != nil {
|
|
s.logger.Printf("[ERROR] [web] setup gate page: %v", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// appSetupGateOpenHandler is the household's "Done, I set it up" (POST /apps/<slug>/setup-gate/open).
|
|
func (s *Server) appSetupGateOpenHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
|
found := s.stackBySlug(slug)
|
|
if found == nil {
|
|
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
|
|
return
|
|
}
|
|
// v0.281.0: an app that can say whether its setup is done is ASKED first — the press never opens an app that
|
|
// still says "not done" (measured 2026-09-29: uptime-kuma pressed before its setup answered anyone). An
|
|
// unreadable status refuses too (fail closed). Without a probe the page's confirm says what the press does.
|
|
if has, done, got, perr := s.stackMgr.SetupGateProbe(found.Name); has && (perr != nil || !done) {
|
|
s.logger.Printf("[INFO] [web] setup gate %s: the household's press refused — the app's own status says not done (value %q, err %v)", found.Name, got, perr)
|
|
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.probe_not_done"))
|
|
return
|
|
}
|
|
// R-1045 (v0.313.0): an UNCLAIMED Plex answers everything to a private address with no token — „Kész" never opens it
|
|
// before Plex itself says it is claimed (the claim box on the same page does that).
|
|
if found.Meta.Slug == "plex" && !plexClaimed() {
|
|
s.logger.Printf("[INFO] [web] setup gate plex: the household's press refused — Plex is not claimed yet (R-1045)")
|
|
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.plex_claim.not_claimed"))
|
|
return
|
|
}
|
|
if err := s.stackMgr.OpenSetupGate(found.Name, stacks.SetupGateByHousehold); err != nil {
|
|
if errors.Is(err, stacks.ErrSetupGateNotClosed) {
|
|
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.not_closed"))
|
|
return
|
|
}
|
|
s.logger.Printf("[ERROR] [web] setup gate %s: the household's open failed: %v", found.Name, err)
|
|
escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "err.setup_gate.open_failed"))
|
|
return
|
|
}
|
|
escrowJSON(w, http.StatusOK, map[string]any{"opened": true}, "")
|
|
}
|
|
|
|
// appDefaultLoginChangedHandler is the household's "I changed it" under a known default login
|
|
// (POST /apps/<slug>/default-login/changed, R-710).
|
|
func (s *Server) appDefaultLoginChangedHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
|
found := s.stackBySlug(slug)
|
|
if found == nil || !found.Deployed {
|
|
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
|
|
return
|
|
}
|
|
if err := s.stackMgr.MarkDefaultLoginChanged(found.Name, "household"); err != nil {
|
|
s.logger.Printf("[ERROR] [web] default login %s: %v", found.Name, err)
|
|
escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "err.setup_gate.open_failed"))
|
|
return
|
|
}
|
|
escrowJSON(w, http.StatusOK, map[string]any{"recorded": true}, "")
|
|
}
|
|
|
|
// stackBySlug resolves a page slug exactly as appCommonData does.
|
|
func (s *Server) stackBySlug(slug string) *stacks.Stack {
|
|
if s.stackMgr == nil {
|
|
return nil
|
|
}
|
|
for _, st := range s.stackMgr.GetStacks() {
|
|
if st.Meta.Slug == slug {
|
|
st := st
|
|
return &st
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// appSignupWindowHandler is the household's "open sign-up for 15 minutes" (POST /apps/<slug>/signup-window, decision 47).
|
|
func (s *Server) appSignupWindowHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
|
found := s.stackBySlug(slug)
|
|
if found == nil {
|
|
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
|
|
return
|
|
}
|
|
until, err := s.stackMgr.OpenSignupWindow(found.Name)
|
|
if err != nil {
|
|
if errors.Is(err, stacks.ErrNoSignupBlock) {
|
|
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.no_signup_block"))
|
|
return
|
|
}
|
|
s.logger.Printf("[ERROR] [web] signup window %s: %v", found.Name, err)
|
|
escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "err.setup_gate.open_failed"))
|
|
return
|
|
}
|
|
escrowJSON(w, http.StatusOK, map[string]any{"open_until": until}, "")
|
|
}
|
|
|
|
// ServeSignupClosed answers the app's own sign-up address while it is closed (the sign-up block's replacePath
|
|
// sends it here). A browser gets a page, anything else a 403 JSON. It holds no secret and changes nothing.
|
|
func (s *Server) ServeSignupClosed(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if !strings.Contains(r.Header.Get("Accept"), "text/html") || r.Method != http.MethodGet {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"error":"sign-up is closed on this app; its admin adds new accounts"}`))
|
|
return
|
|
}
|
|
host := strings.ToLower(r.Host)
|
|
if i := strings.LastIndex(host, ":"); i != -1 {
|
|
host = host[:i]
|
|
}
|
|
data := map[string]interface{}{"Host": host}
|
|
if s.stackMgr != nil {
|
|
if app, _, found := s.stackMgr.SetupGateHost(host); found {
|
|
if st, ok := s.stackMgr.GetStack(app); ok {
|
|
data["AppName"] = st.Meta.DisplayName
|
|
}
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
if err := s.executeTemplateLang(w, r, "signupclosed", data); err != nil {
|
|
s.logger.Printf("[ERROR] [web] signup-closed page: %v", err)
|
|
}
|
|
}
|
|
|
|
// lostFilesNote is the app page's lost-files note (R-1007): the files the app's own check reported lost after an
|
|
// unclean boot, until the household dismisses it, and the boot time in the box's zone. Nothing for an app with no
|
|
// lost file (repaired previews need no note). Pinned by TestLostFilesNote_*.
|
|
func lostFilesNote(deployed bool, cfg *stacks.AppConfig) ([]stacks.LostFile, string) {
|
|
if !deployed || cfg == nil || cfg.AfterUncleanBoot == nil {
|
|
return nil, ""
|
|
}
|
|
rec := cfg.AfterUncleanBoot
|
|
if len(rec.Lost) == 0 || rec.Dismissed {
|
|
return nil, ""
|
|
}
|
|
at := ""
|
|
if t, err := time.Parse(time.RFC3339, rec.BootAt); err == nil {
|
|
at = t.In(getTimezone()).Format("2006-01-02 15:04")
|
|
}
|
|
return rec.Lost, at
|
|
}
|
|
|
|
// appLostFilesDismissHandler hides the app page's lost-files note (R-1007). The record (and the operator's counts)
|
|
// stays; only the household's note goes.
|
|
func (s *Server) appLostFilesDismissHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
|
found := s.stackBySlug(slug)
|
|
if found == nil {
|
|
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
|
|
return
|
|
}
|
|
if err := s.stackMgr.DismissLostFiles(found.Name); err != nil {
|
|
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.lost_files.nothing_to_dismiss"))
|
|
return
|
|
}
|
|
escrowJSON(w, http.StatusOK, map[string]any{"dismissed": true}, "")
|
|
}
|
|
|
|
// appCloseSignupHandler is decision 49's "close sign-up now" (POST /apps/<slug>/close-signup) for an app installed
|
|
// before decision 47. It applies exactly what a fresh install gets after its setup; it never gates the app.
|
|
func (s *Server) appCloseSignupHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
|
found := s.stackBySlug(slug)
|
|
if found == nil {
|
|
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
|
|
return
|
|
}
|
|
if err := s.stackMgr.CloseSignupNow(found.Name); err != nil {
|
|
if errors.Is(err, stacks.ErrCloseSignupNotOffered) {
|
|
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.setup_gate.close_signup_not_offered"))
|
|
return
|
|
}
|
|
s.logger.Printf("[ERROR] [web] close sign-up %s: %v", found.Name, err)
|
|
escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "err.setup_gate.open_failed"))
|
|
return
|
|
}
|
|
escrowJSON(w, http.StatusOK, map[string]any{"closed": true}, "")
|
|
}
|