v0.281.0: 'Done' asks the probe first; sign-up closed after the first admin (decision 47); R-713 code-bound values refused, ${NAME|base64}
gates / gates (push) Successful in 23s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-29 09:57:58 +02:00
parent 3a12c7341a
commit 149467c795
28 changed files with 1928 additions and 15 deletions
+25
View File
@@ -1,3 +1,28 @@
## v0.281.0 — "Done" asks the app first; open sign-up closed once the first admin exists; a password is never read as code (2026-09-29)
**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: `signup_closed.page_title`,
`signup_closed.page_body`, `app_info.setup_gate_confirm`, `app_info.signup_title`, `app_info.signup_closed`,
`app_info.signup_add_how`, `app_info.signup_open_until`, `app_info.signup_window_btn`, `err.setup_gate.probe_not_done`,
`err.setup_gate.no_signup_block` (hu + en). Evidence: `felhom.eu/documentation/audits/gate-rollout-2026-09-29/`.
- **„Kész, beállítottam" asks the app first.** For an app with a `setup_done_probe:` the press reads it and REFUSES
while it says "not done", and when it cannot be read (409, "The app says its first setup is not done yet. Create your
account, then try again."). Measured 2026-09-29: uptime-kuma pressed before its setup answered anyone. A probe app
now also shows the press. An app without a probe asks in the page first (`felhomConfirm`: "Press this only after
you have created your own account. After this, anyone can reach the app's address.").
- **Sign-up closed once the first admin exists (`09` §3 decision 47).** `.felhom.yml` `signup_block:` (a traefik
matcher for the app's own sign-up address). When the setup gate opens, the box writes
`<stacks>/traefik/dynamic/signup-block-<app>.yml` FIRST (a failed write keeps the gate closed), then removes the
gate: that address alone answers "sign-up is closed" (`/__felhom_gate/signup-closed`, a page or 403 JSON). The app
page's new sign-up card says how to add a family member (`app_info.add_people`, per app, hu + en) and has
„Regisztráció megnyitása 15 percre" (`POST /apps/<slug>/signup-window`); the loop closes it again. Only an app
whose gate this box opened gets a block — an installed app is never touched.
- **R-713:** `after_install` refuses a value that would land inside code (text with spaces, quotes or brackets around
the placeholder) when it holds a quote, a backslash, `$`, `{`, `}`, a backtick or a line break. Its own argument and
plain arguments (`--password=${X}`, `admin:${X}`) take any value. New `${NAME|base64}`.
- Tests: `TestSetupGateButton_*`, `TestSignupBlock_*`, `TestSignupClosed_*`, `TestSetupGatePage_ConfirmAndSignupCard`,
`TestR713_*`. Red-proofs RP17–RP24, each seen failing on an assertion.
## v0.280.0 — the setup gate: a new app is closed to strangers until its household set it up; "I changed it"; an installed app's password leaves the page HTML; a generator with a special character (2026-09-29)
**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: `setup_gate.page_title`,
+1
View File
@@ -27,6 +27,7 @@
| `stacks.OldAppDataPaths` / `Manager.ListKept` / `KeepAside` / `DeleteKept` / `FindKept` | controller/internal/stacks/kept.go | `(composePath, hdd)` / `(drives)` / … | Kept data (`09` §3 decision 36): what counts as an app's old data (ONLY `<hdd>/appdata/…` binds), the list, start-fresh, the household's delete | **An action names a kept item by path only through `FindKept`** — `DeleteKept` refuses anything not listed. `KeepAside` is a rename on one drive; never copy, never `RemoveAll` in a rollback (`removeEmptyDirs`) |
| `stacks.RunAfterInstall` / `expandAfterInstall` / `web.defaultLoginInEffect` (v0.279.0, decision 45) | controller/internal/stacks/after_install.go · controller/internal/web/known_login.go | `(name, wait)` / `(cmd, allowed, env)` / `(meta, cfg, installed)` | A fresh install replaces a known default login; the page says when a default is still in effect | **Only from the deploy-done hook** — never after a restore/kept load (R-694). A `success:` marker is required (exit 0 lies). Never log the expanded command |
| `stacks.OpenSetupGate` / `SetupGateTick` / `SetupGateHost` · `web.ServeGateAuth` / `ServeGateStart` (v0.280.0, decision 46) | controller/internal/stacks/setup_gate.go · controller/internal/web/setup_gate.go | `(name, by)` / `()` / `(host)` · handlers | The setup gate: a `setup_gate: true` install is closed to everyone but the household until its probe or the household's press opens it | **Write the gate BEFORE the first start** (spike F2). Open = record first, then remove the file. Never widen the dashboard cookie — the handshake mints a host-bound one-use token |
| `stacks.OpenSignupWindow` / `SignupBlocked` / `SetupGateProbe` · `web.ServeSignupClosed` (v0.281.0, decision 47) | controller/internal/stacks/signup_block.go · controller/internal/web/setup_gate.go | `(name)` | Sign-up closed at the app's own address once the gate opens; the household's 15-minute window; the press asks the probe | **The block goes up BEFORE the gate comes down** (a failed write keeps the gate closed). Never on an app this box did not gate |
| `backup.judgeCopy` / `HollowCopies` / `SetHollowCopyNotify` (Part D, v0.279.0) | controller/internal/backup/hollow_watch.go | `(app, tier, unitDir)` | A RUNNING app whose newest copy holds no data → operator digest once/day + page sentence | Uses `unitCarriesData` (the manifest, never size); a stopped held app is never flagged |
| `web.nightChain` (R-705, v0.279.0) | controller/internal/web/night_chain.go | `POST /api/debug/backup/night-chain` | The night's four legs now, in order | Refuses while any op/update/chain runs; the leg uses `RunUpdateLegNow` |
| `Router.dropLeftoverHold` + `settings.ClearUpdateHold` (R-704, v0.278.0) | controller/internal/api/router.go · controller/internal/settings/settings.go | `(name, why)` / `(stack) (bool, error)` | A new install (plain or "use my kept data") and a removal clear the update / crash-loop hold of the app's install | **A hold belongs to an INSTALL; the name is all the next install shares with it.** Never clears an R-379 restore hold (operator-only) |
+9
View File
@@ -1933,6 +1933,15 @@ that folder is never a dead end, and an install never runs into it silently (R-6
owns, and opens a gate whose probe says done; the household's „Kész, beállítottam" (`POST /apps/<slug>/setup-gate/open`)
opens one without a probe. Opening writes the record, then removes the file. A restore keeps the record; a kept-data
load never gates. Code: `internal/stacks/setup_gate.go`, `internal/web/setup_gate.go`.
**v0.281.0:** the household's press asks the probe first and refuses while it says "not done" or cannot be read; an
app without a probe confirms in the page first.
- **Sign-up closed after the first admin (v0.281.0, decision 47)** — `.felhom.yml` `signup_block:` (traefik matcher).
When the gate opens, `signup-block-<app>.yml` goes up first (replacePath → `/__felhom_gate/signup-closed`, the
controller's refusal page / 403 JSON), then the gate comes down. The app page's sign-up card shows
`app_info.add_people` and „Regisztráció megnyitása 15 percre" (`POST /apps/<slug>/signup-window`, 15 min; the loop
restores the block). Never on an app this box did not gate. Code: `internal/stacks/signup_block.go`.
- **R-713 (v0.281.0).** `after_install` refuses a code-bound value holding a quote, backslash, `$`, `{`, `}`, backtick
or line break; `${NAME|base64}` passes any value safely.
- **R-709 (v0.280.0).** An installed app's `type: password` value is not in its settings page; the eye fetches it.
- **`generate: password:N:special` (v0.280.0)** — a lower, an upper, a digit and one of `-_.!@#%+=` (calibre-web's policy).
- **R-704 (v0.278.0).** A new install (plain or "use my kept data") drops an update or crash-loop hold left by an EARLIER
+11 -1
View File
@@ -2479,5 +2479,15 @@
"app_info.default_login_changed_btn": "I changed it",
"err.stacks.setup_gate_failed": "The app's protection could not be prepared, so it was not installed: %s",
"err.setup_gate.not_closed": "This app is already open.",
"err.setup_gate.open_failed": "It could not be saved. Try again."
"err.setup_gate.open_failed": "It could not be saved. Try again.",
"signup_closed.page_title": "Sign-up closed",
"signup_closed.page_body": "You cannot sign up on this app. The app's admin creates new accounts.",
"app_info.setup_gate_confirm": "Press this only after you have created your own account. After this, anyone can reach the app's address.",
"app_info.signup_title": "Sign-up",
"app_info.signup_closed": "Since the first admin account, sign-up is closed: a stranger cannot make an account.",
"app_info.signup_open_until": "Sign-up is open now, until:",
"app_info.signup_window_btn": "Open sign-up for 15 minutes",
"err.setup_gate.probe_not_done": "The app says its first setup is not done yet. Create your account, then try again.",
"err.setup_gate.no_signup_block": "Sign-up is not closed on this app.",
"app_info.signup_add_how": "To add a family member:"
}
+11 -1
View File
@@ -2467,5 +2467,15 @@
"app_info.default_login_changed_btn": "Megváltoztattam",
"err.stacks.setup_gate_failed": "Az alkalmazás védelmét nem sikerült előkészíteni, ezért nem telepítettük: %s",
"err.setup_gate.not_closed": "Ez az alkalmazás már nyitva van.",
"err.setup_gate.open_failed": "Nem sikerült elmenteni. Próbáld újra."
"err.setup_gate.open_failed": "Nem sikerült elmenteni. Próbáld újra.",
"signup_closed.page_title": "A regisztráció zárva",
"signup_closed.page_body": "Ezen az alkalmazáson nem lehet regisztrálni. Új fiókot az alkalmazás adminja hoz létre.",
"app_info.setup_gate_confirm": "Csak akkor nyomd meg, ha már létrehoztad a saját fiókodat. Utána bárki elérheti az alkalmazás címét.",
"app_info.signup_title": "Regisztráció",
"app_info.signup_closed": "Az első admin fiók után a regisztráció zárva: idegen nem hozhat létre fiókot.",
"app_info.signup_open_until": "A regisztráció most nyitva, eddig:",
"app_info.signup_window_btn": "Regisztráció megnyitása 15 percre",
"err.setup_gate.probe_not_done": "Az alkalmazás szerint még nincs kész az első beállítás. Hozd létre a fiókodat, aztán próbáld újra.",
"err.setup_gate.no_signup_block": "Ennél az alkalmazásnál a regisztráció nincs lezárva.",
"app_info.signup_add_how": "Családtagot így adhatsz hozzá:"
}
+44 -3
View File
@@ -1,6 +1,7 @@
package stacks
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
@@ -60,23 +61,63 @@ func expandAfterInstall(cmd []string, allowed []string, env map[string]string) (
for _, n := range allowed {
ok[n] = true
}
var missing []string
var missing, unsafe []string
out := make([]string, len(cmd))
for i, a := range cmd {
// R-713 (v0.281.0): where does the value land? Its OWN argument ("${X}") or a plain argument
// ("--password=${X}", "admin:${X}") cannot be read as code — any value. Text with spaces, quotes or
// brackets around it ('… "${X}" …' in an Elixir or Python string) can: there the raw value must not hold a
// quote, a backslash, $, {, }, a backtick or a line break. `${X|base64}` is always safe (letters, digits,
// +, /, =): the template decodes it in its own code (claper).
codeShaped := !argumentShaped(a)
out[i] = os.Expand(a, func(k string) string {
if !ok[k] || env[k] == "" {
name, enc, _ := strings.Cut(k, "|")
if !ok[name] || env[name] == "" {
missing = append(missing, name)
return ""
}
v := env[name]
switch enc {
case "":
case "base64":
return base64.StdEncoding.EncodeToString([]byte(v))
default:
missing = append(missing, k)
return ""
}
return env[k]
if codeShaped && strings.ContainsAny(v, codeUnsafeChars) {
unsafe = append(unsafe, name)
}
return v
})
}
if len(missing) > 0 {
return nil, fmt.Errorf("after_install: %v not declared in env or has no value — not run", missing)
}
if len(unsafe) > 0 {
return nil, fmt.Errorf("after_install: the value of %v would be read as code (it holds a quote, a backslash, $, {, }, a backtick or a line break) — not run; the template should pass it as its own argument or as ${NAME|base64}", unsafe)
}
return out, nil
}
// codeUnsafeChars can end a string or start an interpolation in the code a command carries.
const codeUnsafeChars = "'\"\\$`{}\n\r\x00"
// argumentShaped: with every ${…} removed, the element is empty or plain argument text (a flag, a name, a "user:"
// prefix) — nothing that can open or close a string in code.
func argumentShaped(a string) bool {
rest := os.Expand(a, func(string) string { return "" })
for _, r := range rest {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case strings.ContainsRune("-_.:=/@+,", r):
default:
return false
}
}
return true
}
// RunAfterInstall runs the app's after_install once, after a FRESH install (main.go's deploy-done hook).
// Returns (ran, error). Records the outcome in app.yaml either way.
func (m *Manager) RunAfterInstall(name string, wait time.Duration) (bool, error) {
@@ -37,7 +37,10 @@ func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) {
// 1. Success: the value is filled in, the command runs ONCE, the record says ok, the log never carries it.
var calls [][]string
m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "... FELHOM_OK", nil }
m.afterLoadFn = func(_ string, args ...string) (string, error) {
calls = append(calls, args)
return "... FELHOM_OK", nil
}
cmd, err := expandAfterInstall(ai.Command, ai.Env, map[string]string{"ADMIN_PASSWORD": "Gen3r4tedValue"})
must(t, err)
if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err != nil {
@@ -55,7 +58,10 @@ func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) {
// 2. No success marker: retried, then recorded as FAILED — never recorded ok.
calls = nil
m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "boom", errors.New("exit 1") }
m.afterLoadFn = func(_ string, args ...string) (string, error) {
calls = append(calls, args)
return "boom", errors.New("exit 1")
}
if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err == nil {
t.Fatal("a failing command reported success")
}
@@ -89,3 +95,34 @@ func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) {
t.Fatal("an undeclared name was filled in")
}
}
// R-713 (v0.281.0): a value pasted into CODE must not be able to end a string or start an interpolation; the
// same value as its own argument, a plain argument, or ${NAME|base64} is fine.
// COMPANION RED-PROOF: make argumentShaped return true always → the claper-shaped case runs with the quote.
func TestR713_AValueThatWouldBeReadAsCodeIsRefused(t *testing.T) {
typed := map[string]string{"ADMIN_PASSWORD": `My"pass#{System.halt()}`}
allowed := []string{"ADMIN_PASSWORD"}
claperShaped := []string{"/app/bin/claper", "rpc", `Claper.x(u, %{password: "${ADMIN_PASSWORD}"})`}
if _, err := expandAfterInstall(claperShaped, allowed, typed); err == nil || !strings.Contains(err.Error(), "read as code") {
t.Fatalf("a quote and #{ went into Elixir code: %v", err)
}
for _, cmd := range [][]string{
{"python3", "-c", "import sys; f(sys.argv[1])", "${ADMIN_PASSWORD}"}, // its own argument (mealie, wger)
{"php", "artisan", "x", "--password=${ADMIN_PASSWORD}"}, // a plain argument (bookstack)
{"python3", "cps.py", "-s", "admin:${ADMIN_PASSWORD}"}, // calibre-web
{"/app/bin/claper", "rpc", `x(Base.decode64!("${ADMIN_PASSWORD|base64}"))`}, // claper, fixed
} {
out, err := expandAfterInstall(cmd, allowed, typed)
if err != nil {
t.Fatalf("%v: refused a safe placement: %v", cmd, err)
}
if strings.Contains(cmd[len(cmd)-1], "|base64") {
if strings.ContainsAny(out[len(out)-1][len(`x(Base.decode64!("`):], `'\{}$`+"`") || strings.Contains(out[len(out)-1], `My"pass`) {
t.Fatalf("base64 form leaked the raw value: %q", out[len(out)-1])
}
}
}
if _, err := expandAfterInstall([]string{"x", "${ADMIN_PASSWORD|rot13}"}, allowed, typed); err == nil {
t.Fatal("an unknown encoding was accepted")
}
}
+5
View File
@@ -61,6 +61,9 @@ type Metadata struct {
SetupGate bool `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"`
// SetupDoneProbe (v0.280.0) is the app's own read-only "an admin exists" status; absent = the household's button.
SetupDoneProbe *SetupDoneProbe `yaml:"setup_done_probe,omitempty" json:"setup_done_probe,omitempty"`
// SignupBlock (v0.281.0, `09` §3 decision 47): a traefik matcher for the app's own sign-up address, closed once the
// setup gate opens. See signup_block.go.
SignupBlock string `yaml:"signup_block,omitempty" json:"signup_block,omitempty"`
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
// InitialCreds: for apps that auto-generate a first-login credential into a file inside the
// container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and
@@ -146,6 +149,8 @@ type AppInfo struct {
Prerequisites []string `yaml:"prerequisites" json:"prerequisites"`
DefaultCreds string `yaml:"default_creds" json:"default_creds"`
DocsURL string `yaml:"docs_url" json:"docs_url"`
// AddPeople (v0.281.0, decision 47): how the household adds a family member once sign-up is closed.
AddPeople string `yaml:"add_people,omitempty" json:"add_people,omitempty"`
}
// OptionalConfigGroup defines a group of optional config fields (e.g., "Metadata providers").
@@ -69,6 +69,7 @@ type AppInfoOverlay struct {
FirstSteps []string `yaml:"first_steps,omitempty"`
Prerequisites []string `yaml:"prerequisites,omitempty"`
DefaultCreds *string `yaml:"default_creds,omitempty"`
AddPeople *string `yaml:"add_people,omitempty"`
}
// DeployFieldOverlay translates one deploy field, found by EnvVar.
@@ -178,6 +179,7 @@ func (m Metadata) For(lang string) Metadata {
if ov.AppInfo != nil {
out.AppInfo.Tagline = overlayStr(ov.AppInfo.Tagline, out.AppInfo.Tagline)
out.AppInfo.DefaultCreds = overlayStr(ov.AppInfo.DefaultCreds, out.AppInfo.DefaultCreds)
out.AppInfo.AddPeople = overlayStr(ov.AppInfo.AddPeople, out.AppInfo.AddPeople)
out.AppInfo.UseCases = overlayList(ov.AppInfo.UseCases, out.AppInfo.UseCases)
out.AppInfo.FirstSteps = overlayList(ov.AppInfo.FirstSteps, out.AppInfo.FirstSteps)
out.AppInfo.Prerequisites = overlayList(ov.AppInfo.Prerequisites, out.AppInfo.Prerequisites)
+14
View File
@@ -65,6 +65,8 @@ type SetupGateRecord struct {
Hosts []string `yaml:"hosts,omitempty" json:"hosts,omitempty"`
OpenedAt string `yaml:"opened_at,omitempty" json:"opened_at,omitempty"`
OpenedBy string `yaml:"opened_by,omitempty" json:"opened_by,omitempty"`
// SignupOpenUntil (v0.281.0, decision 47): the household opened sign-up until this time (signup_block.go).
SignupOpenUntil string `yaml:"signup_open_until,omitempty" json:"signup_open_until,omitempty"`
}
// Closed reports whether the gate stands.
@@ -296,6 +298,14 @@ func (m *Manager) OpenSetupGate(name, by string) error {
}
dir := filepath.Dir(st.ComposePath)
now := m.now().UTC().Format(time.RFC3339)
// Decision 47: the sign-up block goes up BEFORE the gate comes down, so there is no moment where a stranger
// can sign up. Cannot write it → the gate stays closed (the loop or the next press tries again).
block := strings.TrimSpace(st.Meta.SignupBlock)
if block != "" {
if err := m.writeSignupBlock(name, st.AppConfig.SetupGate.Hosts, block); err != nil {
return fmt.Errorf("setup gate %s: the sign-up block could not be written, so the gate stays closed: %w", name, err)
}
}
opened := false
m.mutateAppConfig(name, dir, "setup_gate", func(cfg *AppConfig) bool {
if !cfg.SetupGate.Closed() {
@@ -306,6 +316,9 @@ func (m *Manager) OpenSetupGate(name, by string) error {
return true
})
if !opened {
if block != "" {
_ = m.removeSignupBlockFile(name) // still gated: the household may still need the sign-up address
}
return fmt.Errorf("setup gate %s: the record could not be written", name)
}
if err := m.removeSetupGateFile(name); err != nil {
@@ -435,6 +448,7 @@ func (m *Manager) SetupGateTick() {
}
}
}
m.reconcileSignupBlocks()
}
// RunSetupGateLoop runs SetupGateTick every interval until ctx ends.
+208
View File
@@ -0,0 +1,208 @@
package stacks
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// ── Sign-up closed once the first admin exists (v0.281.0, `09` §3 decision 47) ─────────────────────────
//
// Operator ruling 2026-09-29 (R-711, option A): after an app's first admin exists, a stranger can no longer make an
// account. Measured on 9202 the same day: opengist and wishlist keep "sign-up on/off" ONLY in their own admin
// settings (no env, no CLI), and vikunja reads it at start but then offers no way to add a user but its CLI. So the
// box does not reach into each app: when an app's setup gate OPENS, the box keeps a small traefik router in front of
// the app's own sign-up address only (`.felhom.yml` `signup_block:`, a traefik matcher), answered by the controller
// with "sign-up is closed on this app" (internal/web/setup_gate.go ServeSignupClosed). Everything else of the app is reached as
// without a gate. The household lets a family member join by opening sign-up for 15 minutes from the app page
// (OpenSignupWindow); the loop closes it again. Decided by CC unattended 2026-09-29 — the operator may reverse.
//
// signup_block: "PathPrefix(`/-/register`)" # opengist
//
// Only an app whose setup gate this box opened carries a block: an app installed before (no gate record) is never
// touched — the same rule as the gate itself (Part 0, 2026-09-29).
// Pinned by internal/stacks/signup_block_test.go.
// signupWindow is how long the household's "open sign-up" press lasts.
var signupWindow = 15 * time.Minute
// signupClosedPath is where the block sends a request (replacePath), answered by the controller.
const signupClosedPath = "/__felhom_gate/signup-closed"
func (m *Manager) signupBlockPath(name string) string {
return filepath.Join(m.setupGateDir(), "signup-block-"+name+".yml")
}
func renderSignupBlock(name string, hosts []string, fragment string) string {
hs := make([]string, 0, len(hosts))
for _, h := range hosts {
hs = append(hs, "Host(`"+h+"`)")
}
rule := "(" + strings.Join(hs, " || ") + ") && (" + fragment + ")"
r := "felhom-signup-block-" + name
var b strings.Builder
fmt.Fprintf(&b, "# Sign-up block for %s — managed by felhom-controller (`09` §3 decision 47).\n", name)
b.WriteString("# The app's own sign-up address answers \"sign-up is closed\"; the household opens it for 15 minutes from the app page.\n")
b.WriteString("http:\n middlewares:\n")
fmt.Fprintf(&b, " %s:\n replacePath:\n path: %q\n", r, signupClosedPath)
b.WriteString(" routers:\n")
fmt.Fprintf(&b, " %s:\n rule: %q\n priority: %d\n", r, rule, 2*setupGatePriority+len(rule))
b.WriteString(" entryPoints:\n - websecure\n tls: {}\n")
fmt.Fprintf(&b, " middlewares:\n - %s@file\n service: %s\n", r, r)
fmt.Fprintf(&b, " services:\n %s:\n loadBalancer:\n servers:\n - url: \"http://felhom-controller:8080\"\n", r)
return b.String()
}
func (m *Manager) writeSignupBlock(name string, hosts []string, fragment string) error {
if len(hosts) == 0 || strings.TrimSpace(fragment) == "" {
return fmt.Errorf("signup block %s: no host or no rule", name)
}
if err := os.MkdirAll(m.setupGateDir(), 0o755); err != nil {
return err
}
want := renderSignupBlock(name, hosts, fragment)
p := m.signupBlockPath(name)
if cur, err := os.ReadFile(p); err == nil && string(cur) == want {
return nil
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, []byte(want), 0o644); err != nil {
return err
}
return os.Rename(tmp, p)
}
func (m *Manager) removeSignupBlockFile(name string) error {
err := os.Remove(m.signupBlockPath(name))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// signupWindowOpen: the household's 15 minutes are running.
func (r *SetupGateRecord) signupWindowOpen(now time.Time) bool {
if r == nil || r.SignupOpenUntil == "" {
return false
}
t, err := time.Parse(time.RFC3339, r.SignupOpenUntil)
return err == nil && now.Before(t)
}
// SignupBlocked reports whether an app's sign-up is closed now, and until when a household's window runs ("" = none).
func (m *Manager) SignupBlocked(name string) (blocked bool, windowUntil string) {
st, ok := m.GetStack(name)
if !ok || !st.Deployed || st.AppConfig == nil || st.AppConfig.SetupGate == nil || strings.TrimSpace(st.Meta.SignupBlock) == "" {
return false, ""
}
g := st.AppConfig.SetupGate
if g.State != SetupGateOpen {
return false, ""
}
if g.signupWindowOpen(m.now()) {
return false, g.SignupOpenUntil
}
return true, ""
}
// ErrNoSignupBlock: the app has no sign-up block to open (never gated here, not open yet, or no signup_block).
var ErrNoSignupBlock = fmt.Errorf("the app has no closed sign-up")
// OpenSignupWindow is the household's "open sign-up for 15 minutes": the record, then the file goes. The loop puts
// the block back once the window has passed.
func (m *Manager) OpenSignupWindow(name string) (string, error) {
st, ok := m.GetStack(name)
if !ok || !st.Deployed || st.AppConfig == nil || st.AppConfig.SetupGate == nil ||
st.AppConfig.SetupGate.State != SetupGateOpen || strings.TrimSpace(st.Meta.SignupBlock) == "" {
return "", ErrNoSignupBlock
}
until := m.now().Add(signupWindow).UTC().Format(time.RFC3339)
done := false
m.mutateAppConfig(name, filepath.Dir(st.ComposePath), "signup_window", func(cfg *AppConfig) bool {
if cfg.SetupGate == nil || cfg.SetupGate.State != SetupGateOpen {
return false
}
cfg.SetupGate.SignupOpenUntil = until
done = true
return true
})
if !done {
return "", fmt.Errorf("signup window %s: the record could not be written", name)
}
if err := m.removeSignupBlockFile(name); err != nil {
return "", err
}
m.logger.Printf("[INFO] [stacks] %s: the household opened sign-up until %s — the loop closes it again", name, until)
return until, nil
}
// reconcileSignupBlocks: every app whose sign-up should be closed has its block file; every other block file goes.
func (m *Manager) reconcileSignupBlocks() {
type want struct {
hosts []string
fragment string
}
wants := map[string]want{}
m.mu.RLock()
now := m.now()
for n, st := range m.stacks {
g := func() *SetupGateRecord {
if st.AppConfig == nil {
return nil
}
return st.AppConfig.SetupGate
}()
if !st.Deployed || g == nil || g.State != SetupGateOpen || strings.TrimSpace(st.Meta.SignupBlock) == "" || g.signupWindowOpen(now) {
continue
}
wants[n] = want{hosts: append([]string(nil), g.Hosts...), fragment: st.Meta.SignupBlock}
}
m.mu.RUnlock()
if ents, err := os.ReadDir(m.setupGateDir()); err == nil {
for _, e := range ents {
n := e.Name()
if !strings.HasPrefix(n, "signup-block-") || !strings.HasSuffix(n, ".yml") {
continue
}
app := strings.TrimSuffix(strings.TrimPrefix(n, "signup-block-"), ".yml")
if _, ok := wants[app]; !ok {
if err := m.removeSignupBlockFile(app); err == nil {
m.logger.Printf("[INFO] [stacks] %s: sign-up block removed (window open, app removed, or no block wanted)", app)
}
}
}
}
for n, w := range wants {
if err := m.writeSignupBlock(n, w.hosts, w.fragment); err != nil {
m.logger.Printf("[ERROR] [stacks] %s: the sign-up block could not be written: %v — sign-up is OPEN until it is", n, err)
}
}
}
// SetupGateProbe asks the app's own "setup done" status once (the household's button asks it first, Part A of the
// 2026-09-29 afternoon brief). has=false: the template declares no probe.
func (m *Manager) SetupGateProbe(name string) (has bool, done bool, got string, err error) {
st, ok := m.GetStack(name)
if !ok {
return false, false, "", fmt.Errorf("stack %q not found", name)
}
p := st.Meta.SetupDoneProbe
if p == nil || p.URL == "" {
return false, false, "", nil
}
body, err := setupGateProbeGet(p.URL)
if err != nil {
return true, false, "", err
}
done, got = probeSaysDone(body, p.Field, p.Done)
return true, done, got, nil
}
// SetSetupGateProbeGetForTest swaps the probe's HTTP read (a test seam for other packages); returns the restore.
func SetSetupGateProbeGetForTest(f func(url string) ([]byte, error)) func() {
old := setupGateProbeGet
setupGateProbeGet = f
return func() { setupGateProbeGet = old }
}
@@ -0,0 +1,118 @@
package stacks
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// v0.281.0 (`09` §3 decision 47) — sign-up closed once the first admin exists, and the household's 15 minutes.
const signupYml = "display_name: Gated App\nsetup_gate: true\nsignup_block: \"PathPrefix(`/signup`)\"\n" +
"deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n"
// Opening the gate puts the sign-up block up FIRST; then the gate comes down.
// COMPANION RED-PROOF: drop the writeSignupBlock call in OpenSetupGate → "no sign-up block after the gate opened"
// fails (between the open and the next tick a stranger could sign up).
func TestSignupBlock_TheGateOpensOnlyWithTheBlockUp(t *testing.T) {
m := gateManager(t, signupYml)
closedGate(t, m)
must(t, m.OpenSetupGate("gapp", SetupGateByHousehold))
b, err := os.ReadFile(m.signupBlockPath("gapp"))
if err != nil {
t.Fatal("no sign-up block after the gate opened")
}
for _, want := range []string{"Host(`gapp.example.hu`)", "PathPrefix(`/signup`)", `path: "/__felhom_gate/signup-closed"`, "http://felhom-controller:8080"} {
if !strings.Contains(string(b), want) {
t.Errorf("block lacks %q:\n%s", want, b)
}
}
if blocked, _ := m.SignupBlocked("gapp"); !blocked {
t.Fatal("SignupBlocked says open")
}
// Still gated → no block (the household may need the sign-up address for the first admin).
m2 := gateManager(t, signupYml)
closedGate(t, m2)
m2.SetupGateTick()
if _, err := os.Stat(m2.signupBlockPath("gapp")); !os.IsNotExist(err) {
t.Fatal("a block stands while the gate is still closed — the household could not sign up as the first admin")
}
}
// A block that cannot be written keeps the gate CLOSED.
// COMPANION RED-PROOF: ignore writeSignupBlock's error in OpenSetupGate → the gate opens with sign-up wide open.
func TestSignupBlock_UnwritableBlockKeepsTheGateClosed(t *testing.T) {
m := gateManager(t, signupYml)
dir := closedGate(t, m)
must(t, os.MkdirAll(m.signupBlockPath("gapp"), 0o755)) // a DIRECTORY where the file must go: rename fails
must(t, os.WriteFile(filepath.Join(m.signupBlockPath("gapp"), "x"), []byte("x"), 0o644))
if err := m.OpenSetupGate("gapp", SetupGateByHousehold); err == nil {
t.Fatal("the gate opened although the sign-up block could not be written")
}
if c := LoadAppConfig(dir); !c.SetupGate.Closed() {
t.Fatal("record says open")
}
if _, err := os.Stat(m.setupGatePath("gapp")); err != nil {
t.Fatal("the gate file was removed")
}
}
// The household's window lifts the block for 15 minutes; the loop puts it back after.
// COMPANION RED-PROOF: make signupWindowOpen always true → "the block did not come back" fails.
func TestSignupBlock_TheWindowOpensAndCloses(t *testing.T) {
m := gateManager(t, signupYml)
closedGate(t, m)
must(t, m.OpenSetupGate("gapp", SetupGateByProbe))
until, err := m.OpenSignupWindow("gapp")
must(t, err)
if until == "" {
t.Fatal("no end time")
}
if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) {
t.Fatal("the window opened but the block is still up")
}
m.SetupGateTick()
if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) {
t.Fatal("a tick inside the window put the block back")
}
if blocked, u := m.SignupBlocked("gapp"); blocked || u != until {
t.Fatalf("inside the window: blocked=%v until=%q", blocked, u)
}
later := time.Now().Add(signupWindow + time.Minute)
m.updateNowFn = func() time.Time { return later }
m.SetupGateTick()
if _, err := os.Stat(m.signupBlockPath("gapp")); err != nil {
t.Fatal("the window passed but the block did not come back")
}
}
// An app with no gate record (installed before, or on a box that never gated it) never gets a block, even when its
// template has signup_block — Part 0's rule. A removed app's block goes.
// COMPANION RED-PROOF: drop the `g == nil ||` / State check in reconcileSignupBlocks → a block appears on an
// app nobody gated.
func TestSignupBlock_NeverOnAnAppThisBoxDidNotGate(t *testing.T) {
m := gateManager(t, signupYml)
dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp")
cfg := &AppConfig{Deployed: true, Env: map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}}
must(t, SaveAppConfig(dir, cfg, m.encKey, nil))
m.mu.Lock()
m.stacks["gapp"].Deployed, m.stacks["gapp"].State, m.stacks["gapp"].AppConfig = true, StateRunning, cfg
m.mu.Unlock()
must(t, os.MkdirAll(m.setupGateDir(), 0o755))
must(t, os.WriteFile(filepath.Join(m.setupGateDir(), "signup-block-gone.yml"), []byte("x"), 0o644))
m.SetupGateTick()
if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) {
t.Fatal("a sign-up block appeared on an app this box never gated")
}
if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) {
t.Fatal("a catalog setup_gate closed an app that was already installed")
}
if _, err := os.Stat(filepath.Join(m.setupGateDir(), "signup-block-gone.yml")); !os.IsNotExist(err) {
t.Fatal("a block nobody owns was kept")
}
if _, err := m.OpenSignupWindow("gapp"); err != ErrNoSignupBlock {
t.Fatalf("window on an ungated app: %v", err)
}
}
+10
View File
@@ -774,6 +774,16 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
// v0.280.0 (decision 46): the setup gate's card, while the gate stands.
data["SetupGateClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.SetupGate.Closed()
data["SetupGateHasProbe"] = found.Meta.SetupDoneProbe != nil && found.Meta.SetupDoneProbe.URL != ""
// v0.281.0 (decision 47): the sign-up card — closed, or open for the household's 15 minutes.
if s.stackMgr != nil {
blocked, until := s.stackMgr.SignupBlocked(found.Name)
data["SignupClosed"] = blocked
if until != "" {
if t, err := time.Parse(time.RFC3339, until); err == nil {
data["SignupOpenUntil"] = t.In(getTimezone()).Format("15:04")
}
}
}
data["HasAppInfo"] = found.Meta.HasAppInfo()
data["EffectiveSubdomain"] = effectiveSubdomain
@@ -152,6 +152,9 @@ func i18nCasesC() []i18nCase {
return m{"AppName": "Private Bin", "AppSlug": "privatebin", "ControllerURL": "https://felhom.example.hu", "Status": "stopped",
"StatusText": "Az alkalmazás jelenleg le van állítva", "Host": "paste.example.hu"}
}},
{"signupclosed", "signupclosed", func() map[string]interface{} {
return m{"AppName": "Opengist", "Host": "gist.example.hu"}
}},
{"setupgate", "setupgate", func() map[string]interface{} {
return m{"AppName": "Immich", "Host": "photos.example.hu", "LoginURL": "/login?next=%2F__gate%2Fstart%3Frd%3Dhttps%253A%252F%252Fphotos.example.hu%252F"}
}},
@@ -329,6 +329,23 @@ func i18nCases() []i18nCase {
}}
}
base = append(base, gateCase("app_info_setup_gate_button", false), gateCase("app_info_setup_gate_probe", true))
// v0.281.0 (decision 47): the sign-up card, closed (with the app's how-to) and open for the household's window.
signupCase := func(name string, closed bool, until string) i18nCase {
return i18nCase{name, "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Opengist")
st := stacks.Stack{Name: "opengist", Deployed: true, State: stacks.StateRunning}
st.Meta = stacks.Metadata{DisplayName: "Opengist", Slug: "opengist", AppInfo: stacks.AppInfo{AddPeople: "Admin panel → Users."}}
d["Stack"] = st
d["Meta"] = st.Meta
d["AppInfo"] = st.Meta.AppInfo
d["SignupClosed"] = closed
if until != "" {
d["SignupOpenUntil"] = until
}
return d
}}
}
base = append(base, signupCase("app_info_signup_closed", true, ""), signupCase("app_info_signup_window", false, "14:05"))
base = append(base, i18nCase{"app_info_known_login_changed", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Calibre-Web")
st := stacks.Stack{Name: "calibre-web", Deployed: true, State: stacks.StateRunning}
@@ -414,6 +431,7 @@ var i18nDirectTemplates = map[string]bool{
"login": true, "claim": true, "recovery": true,
"launcher_shared": true, "launcher_share_password": true, "catchall": true,
"setupgate": true, // v0.280.0 (decision 46): the gate page a stranger meets
"signupclosed": true, // v0.281.0 (decision 47): an app's sign-up address once closed
}
func i18nTestServer(t *testing.T) *Server {
@@ -314,6 +314,7 @@ var i18nDirectPages = []struct{ tmpl, caseName, enProbe string }{
{"launcher_share_password", "launcher_share_password", "This page is protected by a password."},
{"catchall", "catchall_app", "Manage app"},
{"setupgate", "setupgate", "waiting for its first setup"},
{"signupclosed", "signupclosed", "You cannot sign up on this app"},
}
// TestI18nDirectRenderPagesFollowLanguage — with the household language saved as English the page is
+6
View File
@@ -818,6 +818,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// it" under a known default login (R-710). POST, so CsrfProtect covers them.
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/setup-gate/open") && r.Method == http.MethodPost:
s.appSetupGateOpenHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/setup-gate/open"))
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/signup-window") && r.Method == http.MethodPost:
s.appSignupWindowHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/signup-window"))
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/default-login/changed") && r.Method == http.MethodPost:
s.appDefaultLoginChangedHandler(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/default-login/changed"))
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/initial-credentials/reveal") && r.Method == http.MethodPost:
@@ -854,6 +856,10 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
s.ServeGateAuth(w, r)
return
}
if r.URL.Path == signupClosedPath { // v0.281.0 (decision 47): an app's own sign-up address while closed
s.ServeSignupClosed(w, r)
return
}
if r.URL.Path == gateStartPath && strings.EqualFold(host, controllerHost) && r.Method == http.MethodGet {
s.ServeGateStart(w, r)
return
+59
View File
@@ -48,6 +48,8 @@ const (
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 {
@@ -285,6 +287,14 @@ func (s *Server) appSetupGateOpenHandler(w http.ResponseWriter, r *http.Request,
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
}
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"))
@@ -326,3 +336,52 @@ func (s *Server) stackBySlug(slug string) *stacks.Stack {
}
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)
}
}
@@ -37,8 +37,9 @@ func TestSetupGatePage_TheCardAndItsButton(t *testing.T) {
t.Fatal("closed, no probe: the card or its button is missing")
}
closedProbe := renderAppInfoWith(t, map[string]interface{}{"SetupGateClosed": true, "SetupGateHasProbe": true})
if !strings.Contains(closedProbe, `id="setup-gate-card"`) || strings.Contains(closedProbe, "/apps/gapp/setup-gate/open") || !strings.Contains(closedProbe, "magától észreveszi") {
t.Fatal("closed with a probe: want the card and the 'notices it by itself' line, no button")
// v0.281.0: a probe app has the press too — the server asks the probe before it opens (TestSetupGateButton_*).
if !strings.Contains(closedProbe, `id="setup-gate-card"`) || !strings.Contains(closedProbe, "/apps/gapp/setup-gate/open") || !strings.Contains(closedProbe, "magától észreveszi") {
t.Fatal("closed with a probe: want the card, the 'notices it by itself' line and the press")
}
if open := renderAppInfoWith(t, nil); strings.Contains(open, `id="setup-gate-card"`) {
t.Fatal("an app with no closed gate shows the gate card")
@@ -0,0 +1,105 @@
package web
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// v0.281.0 — the household's "Done" press asks the app first (Part A), and the sign-up block's answer (decision 47).
func pressDone(s *Server) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "/apps/gapp/setup-gate/open", nil)
w := httptest.NewRecorder()
s.appSetupGateOpenHandler(w, r, "gapp")
return w
}
// The uptime-kuma shape of 2026-09-29: a press BEFORE the setup. With a probe it is refused while the app says
// "not done", and when the probe cannot be read; it opens once the app says "done".
// COMPANION RED-PROOF: remove the SetupGateProbe check in appSetupGateOpenHandler → the first press opens the app.
func TestSetupGateButton_RefusedWhileTheAppSaysNotDone(t *testing.T) {
s := gateHarness(t)
app := filepath.Join(s.cfg.Paths.StacksDir, "gapp")
if err := os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Gated App\nslug: gapp\nsetup_gate: true\nsetup_done_probe:\n url: http://gapp:80/status\n field: isInit\n done: \"true\"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := s.stackMgr.ScanStacks(); err != nil {
t.Fatal(err)
}
answer, probeErr := `{"isInit":false}`, error(nil)
defer stacks.SetSetupGateProbeGetForTest(func(string) ([]byte, error) { return []byte(answer), probeErr })()
if w := pressDone(s); w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "még nincs kész") {
t.Fatalf("press before the setup: %d %s — want 409 and the sentence", w.Code, w.Body.String())
}
if _, closed, _ := s.stackMgr.SetupGateHost("gapp.example.hu"); !closed {
t.Fatal("the press before the setup opened the gate")
}
answer, probeErr = `{"isInit":true}`, errors.New("connection refused")
if w := pressDone(s); w.Code != http.StatusConflict {
t.Fatalf("an unreadable status: %d — want 409 (fail closed)", w.Code)
}
probeErr = nil
if w := pressDone(s); w.Code != http.StatusOK {
t.Fatalf("the app says done: %d %s", w.Code, w.Body.String())
}
if _, closed, _ := s.stackMgr.SetupGateHost("gapp.example.hu"); closed {
t.Fatal("still closed after a press the app agreed with")
}
}
// The sign-up address answers "closed": a page for a browser, 403 JSON for anything else.
func TestSignupClosed_TheAnswer(t *testing.T) {
s := gateHarness(t)
r := httptest.NewRequest(http.MethodGet, "https://gapp.example.hu"+signupClosedPath, nil)
r.Host = "gapp.example.hu"
r.Header.Set("Accept", "text/html")
w := httptest.NewRecorder()
s.ServeSignupClosed(w, r)
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "nem lehet regisztr") {
t.Fatalf("browser: %d\n%s", w.Code, w.Body.String())
}
r = httptest.NewRequest(http.MethodPost, signupClosedPath, strings.NewReader(`{"username":"x"}`))
w = httptest.NewRecorder()
s.ServeSignupClosed(w, r)
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "sign-up is closed") {
t.Fatalf("API: %d %s", w.Code, w.Body.String())
}
// No block on this app (it is still gated): the window press says so.
req := httptest.NewRequest(http.MethodPost, "/apps/gapp/signup-window", nil)
rec := httptest.NewRecorder()
s.appSignupWindowHandler(rec, req, "gapp")
if rec.Code != http.StatusConflict {
t.Fatalf("window on a gated app: %d", rec.Code)
}
}
// The page: a probe app now has the press too; an app without one asks first, in the page (felhomConfirm); the
// sign-up card with the app's own "how to add a family member" and the window button.
func TestSetupGatePage_ConfirmAndSignupCard(t *testing.T) {
noProbe := renderAppInfoWith(t, map[string]interface{}{"SetupGateClosed": true})
if !strings.Contains(noProbe, "felhomConfirm(b, 'Csak akkor nyomd meg") {
t.Fatal("no-probe press without the in-page confirm")
}
probe := renderAppInfoWith(t, map[string]interface{}{"SetupGateClosed": true, "SetupGateHasProbe": true})
if !strings.Contains(probe, "/apps/gapp/setup-gate/open") || strings.Contains(probe, "felhomConfirm(b, 'Csak akkor") {
t.Fatal("probe app: want the press, without the confirm")
}
closed := renderAppInfoWith(t, map[string]interface{}{"SignupClosed": true, "AppInfo": stacks.AppInfo{Tagline: "t", AddPeople: "Beállítások → Felhasználók → Meghívás."}})
if !strings.Contains(closed, `id="signup-card"`) || !strings.Contains(closed, "Meghívás") || !strings.Contains(closed, "/apps/gapp/signup-window") {
t.Fatal("sign-up card: text, how-to or window button missing")
}
open := renderAppInfoWith(t, map[string]interface{}{"SignupOpenUntil": "14:05"})
if !strings.Contains(open, "14:05") || strings.Contains(open, "/apps/gapp/signup-window") {
t.Fatal("open window: want the end time, no button")
}
if none := renderAppInfoWith(t, nil); strings.Contains(none, `id="signup-card"`) {
t.Fatal("a sign-up card on an app with no block")
}
}
@@ -91,9 +91,27 @@
<p>{{T "app_info.setup_gate_closed"}}</p>
{{- if .SetupGateHasProbe}}
<p class="form-hint">{{T "app_info.setup_gate_probe"}}</p>
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/setup-gate/open')">{{T "app_info.setup_gate_done_btn"}}</button>
{{- else}}
<p class="form-hint">{{T "app_info.setup_gate_button_hint"}}</p>
<button type="button" class="btn btn-sm btn-primary" onclick="appPress(this, '/apps/{{.Meta.Slug}}/setup-gate/open')">{{T "app_info.setup_gate_done_btn"}}</button>
<button type="button" class="btn btn-sm btn-primary" onclick="var b=this; felhomConfirm(b, '{{T "app_info.setup_gate_confirm"}}', function(){ appPress(b, '/apps/{{.Meta.Slug}}/setup-gate/open'); })">{{T "app_info.setup_gate_done_btn"}}</button>
{{- end}}
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{- if or .SignupClosed .SignupOpenUntil}}
<div class="app-info-card" style="margin-top:1rem" id="signup-card">
<h3>{{T "app_info.signup_title"}}</h3>
{{- if .SignupOpenUntil}}
<p>{{T "app_info.signup_open_until"}} {{.SignupOpenUntil}}</p>
{{- else}}
<p>{{T "app_info.signup_closed"}}</p>
{{- end}}
{{- if .AppInfo.AddPeople}}
<p class="form-hint" id="add-people">{{T "app_info.signup_add_how"}} {{.AppInfo.AddPeople}}</p>
{{- end}}
{{- if .SignupClosed}}
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/signup-window')">{{T "app_info.signup_window_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
{{- end}}
</div>
@@ -306,7 +324,7 @@ function icCopyPw(btn) {
{{end}}
{{template "layout_end" .}}
{{- if or .SetupGateClosed (and .Stack.Deployed .KnownLoginLine)}}
{{- if or .SetupGateClosed .SignupClosed (and .Stack.Deployed .KnownLoginLine)}}
<script>
// v0.280.0: the household's presses on this page — "Done, I set it up" (decision 46), "I changed it" (R-710).
function appPress(btn, url) {
@@ -0,0 +1,23 @@
{{define "signupclosed"}}
<!DOCTYPE html>
<html lang="{{T "layout.html_lang"}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>{{if .AppName}}{{.AppName}} — {{end}}{{T "signup_closed.page_title"}}</title>
<link rel="stylesheet" href="/static/style.css?v={{.Version}}">
</head>
<body class="login-body">
<div class="login-card">
<img src="/static/felhom-logo.svg?v={{.Version}}" alt="Felhom.eu" class="login-logo">
{{- if .AppName}}
<h1 class="login-title">{{.AppName}}</h1>
{{- end}}
<p id="signup-closed-text">{{T "signup_closed.page_body"}}</p>
<p class="login-footer">{{.Host}}</p>
<div class="shell-lang">{{template "lang_globe" .}}</div>
</div>
</body>
</html>
{{end}}
@@ -225,7 +225,7 @@
<h3>Első beállítás</h3>
<p>Most csak te éred el ezt az alkalmazást, amíg be vagy jelentkezve a vezérlőpultba. Így más nem hozhatja létre az első admin fiókot. Nyisd meg, és végezd el az első beállítást.</p>
<p class="form-hint">Ha kész, nyomd meg ezt a gombot. Addig a telefonos alkalmazások és a család többi tagja nem éri el.</p>
<button type="button" class="btn btn-sm btn-primary" onclick="appPress(this, '/apps/immich/setup-gate/open')">Kész, beállítottam</button>
<button type="button" class="btn btn-sm btn-primary" onclick="var b=this; felhomConfirm(b, 'Csak akkor nyomd meg, ha már létrehoztad a saját fiókodat. Utána bárki elérheti az alkalmazás címét.', function(){ appPress(b, '/apps/immich/setup-gate/open'); })">Kész, beállítottam</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
@@ -225,6 +225,8 @@
<h3>Első beállítás</h3>
<p>Most csak te éred el ezt az alkalmazást, amíg be vagy jelentkezve a vezérlőpultba. Így más nem hozhatja létre az első admin fiókot. Nyisd meg, és végezd el az első beállítást.</p>
<p class="form-hint">Ha kész, a doboz magától észreveszi, és mindenkinek megnyitja az alkalmazást.</p>
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/immich/setup-gate/open')">Kész, beállítottam</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
@@ -0,0 +1,588 @@
<!DOCTYPE html>
<html lang="hu" class="no-js">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Opengist — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<meta name="csrf-token" content="tok">
<script>
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
function felhomConfirm(el,question,onYes){
if(!el||el.dataset.fcOpen)return;
el.dataset.fcOpen='1';
var wrap=document.createElement('span');wrap.className='inline-confirm';
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
no.addEventListener('click',close);
yes.addEventListener('click',function(){close();onYes();});
}
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
if(!btn)return;
e.preventDefault();
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
var form=btn.closest('form');
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
});
});
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
if(!btn)return;
var group=btn.closest('.nav-group');
if(!group)return;
var willOpen=!group.classList.contains('is-open');
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
g.classList.remove('is-open');
var t=g.querySelector('.nav-group-toggle');
if(t)t.setAttribute('aria-expanded','false');
});
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
});
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
</script>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
</svg>
<header class="mobile-topbar">
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
<svg class="ico"><use href="#i-menu"/></svg>
</button>
</header>
<div class="nav-backdrop" hidden></div>
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
</div>
<ul class="nav-links">
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
<li><a href="/stacks" class="active"><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>Tárhely<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/storage" class="">Meghajtók</a></li>
<li><a href="/storage/network" class="">Hálózati tárhely</a></li>
</ul>
</li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>Biztonsági mentés<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/backups" class="">Áttekintés</a></li>
<li><a href="/backups/remote" class="">Távoli mentés</a></li>
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
<li><a href="/backups/restore" class="">Visszaállítás</a></li>
</ul>
</li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>Megosztás<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/sharing" class="">Hálózati megosztás</a></li>
</ul>
</li>
<li><a href="/monitoring" class=""><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
<li><a href="/debug" class=""><svg class="ico"><use href="#i-wrench"/></svg>Debug</a></li>
</ul>
<div class="sidebar-bottom">
<div class="nav-group-label">Beállítások</div>
<ul class="nav-links nav-links-sub">
<li><a href="/settings" class=""><svg class="ico"><use href="#i-settings"/></svg>Rendszer</a></li>
<li><a href="/settings/notifications" class=""><svg class="ico"><use href="#i-bell"/></svg>Értesítések</a></li>
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
</nav>
<main class="content">
<div class="page-header">
<div style="display:flex;align-items:center;gap:1rem">
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
<h2>Opengist</h2>
</div>
<div style="display:flex;align-items:center;gap:.5rem">
<span class="stack-state-badge state-run">Fut</span>
<a href="/stacks/opengist/logs" class="btn btn-sm btn-outline">Napló</a>
<a href="/stacks/opengist/export" class="btn btn-sm btn-outline">Exportálás</a>
<a href="/stacks/opengist/deploy" class="btn btn-sm btn-outline">Beállítások</a>
</div>
</div>
<div class="app-info-hero">
<img class="app-info-logo" src="/static/assets/opengist-logo.svg"
alt="Opengist" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/opengist-logo.png';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
<div class="app-info-hero-text">
<p class="app-info-tagline"></p>
<div class="stack-meta-badges">
<span class="meta-badge">~ RAM</span>
<span class="meta-badge"></span>
<span class="meta-badge meta-badge-warn">Csak x86</span>
</div>
</div>
</div>
<div class="app-screenshots" id="screenshots">
<img src="/static/assets/opengist-screenshot-1.webp" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="/static/assets/opengist-screenshot-2.webp" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="/static/assets/opengist-screenshot-3.webp" alt="" class="app-screenshot"
onerror="this.style.display='none'">
</div>
<div class="app-info-card" style="margin-top:1rem" id="signup-card">
<h3>Regisztráció</h3>
<p>Az első admin fiók után a regisztráció zárva: idegen nem hozhat létre fiókot.</p>
<p class="form-hint" id="add-people">Családtagot így adhatsz hozzá: Admin panel → Users.</p>
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/opengist/signup-window')">Regisztráció megnyitása 15 percre</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
</main>
<script>
(function(){
var burger=document.querySelector('.nav-burger');
var sidebar=document.getElementById('sidebar');
var backdrop=document.querySelector('.nav-backdrop');
if(!burger||!sidebar||!backdrop)return;
function setOpen(open){
sidebar.classList.toggle('is-open',open);
document.body.classList.toggle('nav-open',open);
backdrop.hidden=!open;
burger.setAttribute('aria-expanded',open?'true':'false');
}
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
backdrop.addEventListener('click',function(){setOpen(false);});
document.addEventListener('keydown',function(e){
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
});
})();
document.addEventListener('click', function(e) {
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
var card = e.target.closest('[data-href]');
if (card) window.location.href = card.dataset.href;
});
async function checkBeforeDeploy(e, name) {
try {
var resp = await fetch('/api/stacks/' + name);
var data = await resp.json();
if (data.ok && data.data && data.data.deployed) {
e.preventDefault();
showAlert('Ez az alkalmazás már telepítve van.');
window.location.reload();
return false;
}
} catch(err) {}
return true;
}
async function syncTemplates() {
const btn = document.getElementById('sync-btn');
const toast = document.getElementById('sync-toast');
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '↻ Frissítés...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/sync', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (toast) {
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
setTimeout(function() { window.location.reload(); }, 1500);
}
} catch (err) {
if (toast) {
toast.textContent = 'Hálózati hiba: ' + err.message;
toast.className = 'sync-toast sync-toast-err';
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
}
btn.innerHTML = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
async function stackAction(event, name, action) {
const btn = event.currentTarget;
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Folyamatban...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/stacks/' + name + '/' + action, {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (!data.ok) {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
return;
}
if (action === 'update') {
followUpdate(name, btn);
return;
}
window.location.reload();
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
}
function followUpdate(name, btn) {
var started = Date.now();
var timer = setInterval(async function() {
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
try {
var r = await fetch('/api/stacks/' + name);
var d = await r.json();
if (!d.ok || !d.data) return;
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
} catch (e) {}
}, 3000);
}
async function deleteOrphanStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'delete-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
document.body.appendChild(modal);
try {
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
var data = await resp.json();
var hddInfo = '';
var checkboxHTML = '';
if (data.ok && data.data && data.data.has_hdd_data) {
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
data.data.hdd_paths.forEach(function(p) {
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
});
hddInfo += '</div>';
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
hddInfo + checkboxHTML +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
'</div>';
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
}
}
function closeDeleteModal() {
var modal = document.getElementById('delete-modal');
if (modal) modal.remove();
}
async function confirmDelete(name) {
var btn = document.getElementById('confirm-delete-btn');
var checkbox = document.getElementById('delete-hdd-check');
var removeHDD = checkbox ? checkbox.checked : false;
btn.disabled = true;
btn.textContent = 'Törlés folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name, {
method: 'DELETE',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('delete-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen törölve!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') törölve lett.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Törlés';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Törlés';
}
}
async function removeStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'remove-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
document.body.appendChild(modal);
try {
var [hddResp, backupResp] = await Promise.all([
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
]);
var sections = '';
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
if (keepOnly) {
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.</div>';
}
sections += '<div class="modal-section">' +
'<strong>Mindig törlődik:</strong>' +
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
'<li>Telepítési konfiguráció (app.yaml)</li>' +
'<li>Másodlagos mentés ütemezése</li>' +
'</ul></div>';
var hddCheckbox = '';
if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
var hddPaths = '';
hddResp.data.hdd_paths.forEach(function(p) {
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
});
sections += '<div class="modal-section">' +
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
'Ha újratelepíti az alkalmazást, az adatokat újra importálnia kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
'</div></div>';
hddCheckbox = '\x3Cscript>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
}
var backupCheckbox = '';
if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) {
var bkPaths = '';
backupResp.data.backup_paths.forEach(function(p) {
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
});
if (bkPaths) {
sections += '<div class="modal-section">' +
'<strong>Mentési adatok:</strong>' + bkPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
'</div></div>';
}
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
sections +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
'</div>' + hddCheckbox;
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
}
}
function closeRemoveModal() {
var modal = document.getElementById('remove-modal');
if (modal) modal.remove();
}
async function confirmRemoveStack(name) {
var btn = document.getElementById('confirm-remove-btn');
var hddCheck = document.getElementById('remove-hdd-check');
var backupCheck = document.getElementById('remove-backup-check');
var removeHDD = hddCheck ? hddCheck.checked : false;
var removeBackups = backupCheck ? backupCheck.checked : false;
btn.disabled = true;
btn.textContent = 'Eltávolítás folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name + '/remove', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('remove-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen eltávolítva!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
}
</script>
</body>
</html>
<script>
function appPress(btn, url) {
var err = btn.parentNode.querySelector('.app-press-err');
if (err) { err.style.display = 'none'; }
btn.disabled = true;
fetch(url, {method: 'POST', headers: csrfHeaders(), credentials: 'same-origin'})
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok) { throw new Error(j.error || ''); }
window.location.reload();
})
.catch(function (e) { btn.disabled = false; if (err) { err.textContent = e.message; err.style.display = 'inline'; } });
}
</script>
@@ -0,0 +1,571 @@
<!DOCTYPE html>
<html lang="hu" class="no-js">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Opengist — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
<meta name="csrf-token" content="tok">
<script>
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
function felhomConfirm(el,question,onYes){
if(!el||el.dataset.fcOpen)return;
el.dataset.fcOpen='1';
var wrap=document.createElement('span');wrap.className='inline-confirm';
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
no.addEventListener('click',close);
yes.addEventListener('click',function(){close();onYes();});
}
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
if(!btn)return;
e.preventDefault();
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
var form=btn.closest('form');
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
});
});
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
if(!btn)return;
var group=btn.closest('.nav-group');
if(!group)return;
var willOpen=!group.classList.contains('is-open');
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
g.classList.remove('is-open');
var t=g.querySelector('.nav-group-toggle');
if(t)t.setAttribute('aria-expanded','false');
});
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
});
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
</script>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
</svg>
<header class="mobile-topbar">
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
<svg class="ico"><use href="#i-menu"/></svg>
</button>
</header>
<div class="nav-backdrop" hidden></div>
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
</div>
<ul class="nav-links">
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
<li><a href="/stacks" class="active"><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>Tárhely<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/storage" class="">Meghajtók</a></li>
<li><a href="/storage/network" class="">Hálózati tárhely</a></li>
</ul>
</li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>Biztonsági mentés<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/backups" class="">Áttekintés</a></li>
<li><a href="/backups/remote" class="">Távoli mentés</a></li>
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
<li><a href="/backups/restore" class="">Visszaállítás</a></li>
</ul>
</li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>Megosztás<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/sharing" class="">Hálózati megosztás</a></li>
</ul>
</li>
<li><a href="/monitoring" class=""><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
<li><a href="/debug" class=""><svg class="ico"><use href="#i-wrench"/></svg>Debug</a></li>
</ul>
<div class="sidebar-bottom">
<div class="nav-group-label">Beállítások</div>
<ul class="nav-links nav-links-sub">
<li><a href="/settings" class=""><svg class="ico"><use href="#i-settings"/></svg>Rendszer</a></li>
<li><a href="/settings/notifications" class=""><svg class="ico"><use href="#i-bell"/></svg>Értesítések</a></li>
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
</nav>
<main class="content">
<div class="page-header">
<div style="display:flex;align-items:center;gap:1rem">
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
<h2>Opengist</h2>
</div>
<div style="display:flex;align-items:center;gap:.5rem">
<span class="stack-state-badge state-run">Fut</span>
<a href="/stacks/opengist/logs" class="btn btn-sm btn-outline">Napló</a>
<a href="/stacks/opengist/export" class="btn btn-sm btn-outline">Exportálás</a>
<a href="/stacks/opengist/deploy" class="btn btn-sm btn-outline">Beállítások</a>
</div>
</div>
<div class="app-info-hero">
<img class="app-info-logo" src="/static/assets/opengist-logo.svg"
alt="Opengist" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/opengist-logo.png';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
<div class="app-info-hero-text">
<p class="app-info-tagline"></p>
<div class="stack-meta-badges">
<span class="meta-badge">~ RAM</span>
<span class="meta-badge"></span>
<span class="meta-badge meta-badge-warn">Csak x86</span>
</div>
</div>
</div>
<div class="app-screenshots" id="screenshots">
<img src="/static/assets/opengist-screenshot-1.webp" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="/static/assets/opengist-screenshot-2.webp" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="/static/assets/opengist-screenshot-3.webp" alt="" class="app-screenshot"
onerror="this.style.display='none'">
</div>
<div class="app-info-card" style="margin-top:1rem" id="signup-card">
<h3>Regisztráció</h3>
<p>A regisztráció most nyitva, eddig: 14:05</p>
<p class="form-hint" id="add-people">Családtagot így adhatsz hozzá: Admin panel → Users.</p>
</div>
</main>
<script>
(function(){
var burger=document.querySelector('.nav-burger');
var sidebar=document.getElementById('sidebar');
var backdrop=document.querySelector('.nav-backdrop');
if(!burger||!sidebar||!backdrop)return;
function setOpen(open){
sidebar.classList.toggle('is-open',open);
document.body.classList.toggle('nav-open',open);
backdrop.hidden=!open;
burger.setAttribute('aria-expanded',open?'true':'false');
}
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
backdrop.addEventListener('click',function(){setOpen(false);});
document.addEventListener('keydown',function(e){
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
});
})();
document.addEventListener('click', function(e) {
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
var card = e.target.closest('[data-href]');
if (card) window.location.href = card.dataset.href;
});
async function checkBeforeDeploy(e, name) {
try {
var resp = await fetch('/api/stacks/' + name);
var data = await resp.json();
if (data.ok && data.data && data.data.deployed) {
e.preventDefault();
showAlert('Ez az alkalmazás már telepítve van.');
window.location.reload();
return false;
}
} catch(err) {}
return true;
}
async function syncTemplates() {
const btn = document.getElementById('sync-btn');
const toast = document.getElementById('sync-toast');
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '↻ Frissítés...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/sync', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (toast) {
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
setTimeout(function() { window.location.reload(); }, 1500);
}
} catch (err) {
if (toast) {
toast.textContent = 'Hálózati hiba: ' + err.message;
toast.className = 'sync-toast sync-toast-err';
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
}
btn.innerHTML = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
async function stackAction(event, name, action) {
const btn = event.currentTarget;
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Folyamatban...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/stacks/' + name + '/' + action, {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (!data.ok) {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
return;
}
if (action === 'update') {
followUpdate(name, btn);
return;
}
window.location.reload();
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
}
function followUpdate(name, btn) {
var started = Date.now();
var timer = setInterval(async function() {
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
try {
var r = await fetch('/api/stacks/' + name);
var d = await r.json();
if (!d.ok || !d.data) return;
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
} catch (e) {}
}, 3000);
}
async function deleteOrphanStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'delete-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
document.body.appendChild(modal);
try {
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
var data = await resp.json();
var hddInfo = '';
var checkboxHTML = '';
if (data.ok && data.data && data.data.has_hdd_data) {
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
data.data.hdd_paths.forEach(function(p) {
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
});
hddInfo += '</div>';
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
hddInfo + checkboxHTML +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
'</div>';
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
}
}
function closeDeleteModal() {
var modal = document.getElementById('delete-modal');
if (modal) modal.remove();
}
async function confirmDelete(name) {
var btn = document.getElementById('confirm-delete-btn');
var checkbox = document.getElementById('delete-hdd-check');
var removeHDD = checkbox ? checkbox.checked : false;
btn.disabled = true;
btn.textContent = 'Törlés folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name, {
method: 'DELETE',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('delete-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen törölve!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') törölve lett.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Törlés';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Törlés';
}
}
async function removeStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'remove-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
document.body.appendChild(modal);
try {
var [hddResp, backupResp] = await Promise.all([
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
]);
var sections = '';
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
if (keepOnly) {
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.</div>';
}
sections += '<div class="modal-section">' +
'<strong>Mindig törlődik:</strong>' +
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
'<li>Telepítési konfiguráció (app.yaml)</li>' +
'<li>Másodlagos mentés ütemezése</li>' +
'</ul></div>';
var hddCheckbox = '';
if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
var hddPaths = '';
hddResp.data.hdd_paths.forEach(function(p) {
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
});
sections += '<div class="modal-section">' +
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
'Ha újratelepíti az alkalmazást, az adatokat újra importálnia kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
'</div></div>';
hddCheckbox = '\x3Cscript>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
}
var backupCheckbox = '';
if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) {
var bkPaths = '';
backupResp.data.backup_paths.forEach(function(p) {
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
});
if (bkPaths) {
sections += '<div class="modal-section">' +
'<strong>Mentési adatok:</strong>' + bkPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
'</div></div>';
}
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
sections +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
'</div>' + hddCheckbox;
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
}
}
function closeRemoveModal() {
var modal = document.getElementById('remove-modal');
if (modal) modal.remove();
}
async function confirmRemoveStack(name) {
var btn = document.getElementById('confirm-remove-btn');
var hddCheck = document.getElementById('remove-hdd-check');
var backupCheck = document.getElementById('remove-backup-check');
var removeHDD = hddCheck ? hddCheck.checked : false;
var removeBackups = backupCheck ? backupCheck.checked : false;
btn.disabled = true;
btn.textContent = 'Eltávolítás folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name + '/remove', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('remove-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen eltávolítva!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
}
</script>
</body>
</html>
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Opengist — A regisztráció zárva</title>
<link rel="stylesheet" href="/static/style.css?v=test">
</head>
<body class="login-body">
<div class="login-card">
<img src="/static/felhom-logo.svg?v=test" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">Opengist</h1>
<p id="signup-closed-text">Ezen az alkalmazáson nem lehet regisztrálni. Új fiókot az alkalmazás adminja hoz létre.</p>
<p class="login-footer">gist.example.hu</p>
<div class="shell-lang"><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details></div>
</div>
</body>
</html>
+2
View File
@@ -95,6 +95,8 @@
"err.setup_gate.not_closed": "BORN AS A KEY, v0.280.0 (09 decision 46 / R-710) -- a NEW sentence, never a Go literal. Pinned by internal/web/setup_gate_test.go / internal/stacks/setup_gate_test.go.",
"err.setup_gate.open_failed": "BORN AS A KEY, v0.280.0 (09 decision 46 / R-710) -- a NEW sentence, never a Go literal. Pinned by internal/web/setup_gate_test.go / internal/stacks/setup_gate_test.go.",
"err.stacks.setup_gate_failed": "BORN AS A KEY, v0.280.0 (09 decision 46 / R-710) -- a NEW sentence, never a Go literal. Pinned by internal/web/setup_gate_test.go / internal/stacks/setup_gate_test.go.",
"err.setup_gate.probe_not_done": "BORN AS A KEY, v0.281.0 (09 decision 46/47) -- a NEW sentence, never a Go literal. Pinned by internal/web/signup_block_test.go.",
"err.setup_gate.no_signup_block": "BORN AS A KEY, v0.281.0 (09 decision 46/47) -- a NEW sentence, never a Go literal. Pinned by internal/web/signup_block_test.go.",
"backups_apps.hollow_local": "BORN AS A KEY, v0.279.0 (09 decision 45 / Part D) -- a NEW sentence, never a Go literal. Pinned by internal/web/known_login_test.go / internal/web/r_partd_hollow_page_test.go.",
"backups_apps.hollow_offsite": "BORN AS A KEY, v0.279.0 (09 decision 45 / Part D) -- a NEW sentence, never a Go literal. Pinned by internal/web/known_login_test.go / internal/web/r_partd_hollow_page_test.go.",
"err.kept.offsite_not_usable": "BORN AS A KEY, R-691 (2) (v0.277.0) -- a NEW sentence, never a Go literal. Pinned by internal/api/kept_install_test.go TestR691_InstallChoiceNamesTheOffsiteCopy / internal/backup/r691_kept_offsite_test.go.",