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
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:
@@ -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:"
|
||||
}
|
||||
|
||||
@@ -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á:"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +60,11 @@ type Metadata struct {
|
||||
// app's first setup is done — for an app whose first visitor creates the admin. See setup_gate.go.
|
||||
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"`
|
||||
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
|
||||
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
|
||||
// surfaces it on the app page, so the customer never has to dig through logs. Optional.
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -413,7 +430,8 @@ var i18nSessionTemplates = map[string]bool{"recovery": true}
|
||||
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
|
||||
"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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
+1
-1
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>')+'</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,'&').replace(/</g,'<').replace(/>/g,'>')+'</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>
|
||||
Reference in New Issue
Block a user