R-970: the box's hub key opens no route; Data tab leftovers; R-1103: the install form's dead branches
gates / gates (push) Successful in 48s

R-970: /api/config, /api/config/hash, /api/config/apply removed (they read and replaced controller.yaml with
every secret, reachable with the box's own hub API key, unsigned; nothing called them). The hub-key Bearer
door on /api/selfupdate/ and /api/geo/ and the matching CSRF exemption are gone; the dashboard keeps using
those with a session. The route table is buildMux, so a test drives the real one.

Data tab: the export password field is autocomplete=new-password with a non-login name; the 2nd copy's
status rows that repeated the form are gone (the no-target reason moves into the form).

R-1103: app_fields.html loses its installed-only branches (no page serves them since R-1095); the tests
that rendered that dead path are retired, their rules asserted on the settings tab (R-694 moved there).

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-16 18:24:00 +02:00
parent 8fe46a1313
commit f037be7a6a
25 changed files with 331 additions and 2987 deletions
+53 -67
View File
@@ -18,7 +18,6 @@ import (
"syscall"
"time"
"crypto/subtle"
"strconv"
"strings"
"sync"
@@ -2190,56 +2189,7 @@ func main() {
// the controller no longer runs a DriveMigrator.
// --- Build HTTP mux ---
mux := http.NewServeMux()
// API routes (no auth for health endpoint, auth for everything else)
mux.HandleFunc("/api/health", apiRouter.HealthHandler)
// Disk management API — thin proxy to the host agent (slice 8C). The agent owns
// disk execution; the controller forwards list/assign/eject/format.
mux.Handle("/api/disks", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI))))
mux.Handle("/api/disks/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI))))
// Guided storage provisioning (init/attach/eject orchestration over the agent disk API + registry).
mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI))))
// LAN network-sharing folder picker (R-7 slice 1). Must live here, not in the web ServeHTTP
// switch: the /api/ subtree is routed on this mux, so a case there is shadowed and 401s.
mux.Handle("/api/sharing/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSharingAPI))))
// Guest RAM resize (v0.143.0, R-24): read current allocation/bounds + apply a bounded resize.
mux.Handle("/api/system/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSystemAPI))))
// R-490 (v0.242.0): the web layer's /api/system/ prefix knows only the memory routes and answered
// 404 to /api/system/info, so the monitoring page's memory-distribution card never rendered. An
// exact pattern wins over the prefix in ServeMux. Pinned by TestR490_SystemInfoIsMountedAheadOfTheWebPrefix.
mux.Handle("/api/system/info", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
// Standalone full-server (guest) restart — the "Kiszolgáló újraindítása" maintenance affordance,
// a sibling to the controller-only /api/selfrestart. Reuses the agent GuestReboot primitive.
mux.Handle("/api/server/reboot", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerReboot))))
// R-998: „A gép kikapcsolása" — the household turns the whole machine off (agent POST /host/poweroff, doc 14).
// Dashboard login only (RequireAuth): family members' app logins never reach the dashboard.
mux.Handle("/api/server/poweroff", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerPowerOff))))
// Whole-guest (appliance) backup visibility + manual trigger. Distinct prefix from apiRouter's
// app-data /api/backup/{run,status} (DB dumps) to avoid shadowing the /api/ catch-all subtree.
mux.Handle("/api/guest-backup/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeBackupAPI))))
// Host metrics API — thin proxy to the host agent (slice 9). Read-only host-wide health +
// per-storage capacity for the monitoring view; the de-privileged controller can't read the
// host itself. GET only, so no CSRF wrapper needed.
mux.Handle("/api/host-metrics", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHostMetricsAPI)))
// App export/import API routes handled by web server
mux.Handle("/api/export/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeExportAPI))))
// Debug API routes handled by web server (debug-mode gating inside handler)
mux.Handle("/api/debug/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDebugAPI))))
// Escrow ceremony wizard API (v0.127.0) — session auth + CSRF on POSTs; the claim response is
// the ONLY surface the recovery code R ever crosses (no-store, never logged).
mux.Handle("/api/escrow/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeEscrowAPI))))
// Self-update API — accepts session auth OR hub API key (for external triggering)
// CsrfProtect exempts Bearer-token requests automatically.
mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
// Config API — accepts session auth OR hub API key (for Hub config push)
mux.Handle("/api/config/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
// Geo API — accepts session auth OR hub API key (for Hub geo-disable)
mux.Handle("/api/geo/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
mux.Handle("/api/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
// Web UI routes (auth required)
mux.Handle("/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeHTTP))))
mux := buildMux(apiRouter, webServer)
// --- Start HTTP server ---
server := &http.Server{
@@ -2282,7 +2232,6 @@ func main() {
logger.Println("[INFO] felhom-controller stopped")
}
// selfUpdateAuthMiddleware allows access via session auth (normal UI) OR hub API key bearer token (external).
// deadAppBootGrace is the startup settle window before fix-3 evaluates deployed-app run states — apps
// legitimately take 30–60 s to come up, so a shorter grace would false-alarm during the controller's
// own boot. After the grace, an app that still isn't running alerts (the F11 dead-at-boot case).
@@ -3011,21 +2960,6 @@ func classifyRunStates(sts []stacks.Stack, quiesced map[string]bool, failedResta
return dead, states
}
func selfUpdateAuthMiddleware(cfg *config.Config, webServer *web.Server, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check bearer token first (for external API calls: hub, build scripts)
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
token := strings.TrimPrefix(auth, "Bearer ")
if token != "" && cfg.Hub.APIKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Hub.APIKey)) == 1 {
next.ServeHTTP(w, r)
return
}
}
// Fall back to session auth
webServer.RequireAuth(next).ServeHTTP(w, r)
})
}
// setupLogger builds the v0.116.0 capture layer: the LogBuffer ring ALWAYS exists
// and captures every line (DEBUG included — remote diagnostics need the detail to
// exist without a config flip), while stdout (docker logs) keeps respecting
@@ -4168,3 +4102,55 @@ func keptSpaceSentence(sm *stacks.Manager, drive, lang string) string {
}
return util.Text(lang, "kept.diskwarn", strings.Join(parts, ", "))
}
// buildMux is the controller's HTTP route table. One function so a test can drive the REAL table
// (cmd/controller r970_no_hub_key_door_test.go) rather than a copy of it.
func buildMux(apiRouter *api.Router, webServer *web.Server) *http.ServeMux {
mux := http.NewServeMux()
// API routes (no auth for health endpoint, auth for everything else)
mux.HandleFunc("/api/health", apiRouter.HealthHandler)
// Disk management API — thin proxy to the host agent (slice 8C). The agent owns
// disk execution; the controller forwards list/assign/eject/format.
mux.Handle("/api/disks", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI))))
mux.Handle("/api/disks/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI))))
// Guided storage provisioning (init/attach/eject orchestration over the agent disk API + registry).
mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI))))
// LAN network-sharing folder picker (R-7 slice 1). Must live here, not in the web ServeHTTP
// switch: the /api/ subtree is routed on this mux, so a case there is shadowed and 401s.
mux.Handle("/api/sharing/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSharingAPI))))
// Guest RAM resize (v0.143.0, R-24): read current allocation/bounds + apply a bounded resize.
mux.Handle("/api/system/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSystemAPI))))
// R-490 (v0.242.0): the web layer's /api/system/ prefix knows only the memory routes and answered
// 404 to /api/system/info, so the monitoring page's memory-distribution card never rendered. An
// exact pattern wins over the prefix in ServeMux. Pinned by TestR490_SystemInfoIsMountedAheadOfTheWebPrefix.
mux.Handle("/api/system/info", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
// Standalone full-server (guest) restart — the "Kiszolgáló újraindítása" maintenance affordance,
// a sibling to the controller-only /api/selfrestart. Reuses the agent GuestReboot primitive.
mux.Handle("/api/server/reboot", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerReboot))))
// R-998: „A gép kikapcsolása" — the household turns the whole machine off (agent POST /host/poweroff, doc 14).
// Dashboard login only (RequireAuth): family members' app logins never reach the dashboard.
mux.Handle("/api/server/poweroff", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerPowerOff))))
// Whole-guest (appliance) backup visibility + manual trigger. Distinct prefix from apiRouter's
// app-data /api/backup/{run,status} (DB dumps) to avoid shadowing the /api/ catch-all subtree.
mux.Handle("/api/guest-backup/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeBackupAPI))))
// Host metrics API — thin proxy to the host agent (slice 9). Read-only host-wide health +
// per-storage capacity for the monitoring view; the de-privileged controller can't read the
// host itself. GET only, so no CSRF wrapper needed.
mux.Handle("/api/host-metrics", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHostMetricsAPI)))
// App export/import API routes handled by web server
mux.Handle("/api/export/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeExportAPI))))
// Debug API routes handled by web server (debug-mode gating inside handler)
mux.Handle("/api/debug/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDebugAPI))))
// Escrow ceremony wizard API (v0.127.0) — session auth + CSRF on POSTs; the claim response is
// the ONLY surface the recovery code R ever crosses (no-store, never logged).
mux.Handle("/api/escrow/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeEscrowAPI))))
// v0.322.0 (R-970): no route accepts the box's hub API key any more. /api/selfupdate/ and /api/geo/ were open to
// it as a Bearer token (and /api/config/, now removed); the hub never calls into a box (it disables geo through
// Cloudflare directly), so they are served like every other /api/ route below: a dashboard session + CSRF.
mux.Handle("/api/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
// Web UI routes (auth required)
mux.Handle("/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeHTTP))))
return mux
}
@@ -0,0 +1,84 @@
package main
import (
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
)
// R-970 (v0.322.0) — the box's own hub API key opens NO route on the box.
//
// Until v0.322.0 a Bearer equal to the hub key reached /api/config/ (read the whole controller.yaml with every secret,
// and REPLACE it), /api/selfupdate/ and /api/geo/ from the internet, unsigned — against `01` §4 („no inbound control
// endpoint") and §10. The test drives the REAL route table (buildMux) with real web.Server and api.Router: a password
// is set (so auth is on), the request carries the key and no session, and the planted controller.yaml must be neither
// served nor changed.
//
// RED-PROOF (2026-10-16, REPORT): with an /api/geo/ route mounted without the session check, this failed at
// „GET /api/geo/countries with the hub key answered 200".
func TestR970_HubKeyOpensNoDoor(t *testing.T) {
const hubKey = "TESTONLY-hub-key-5d1b"
const secret = "TESTONLY-tunnel-token-q9"
dir := t.TempDir()
lg := log.New(io.Discard, "", 0)
cfgPath := filepath.Join(dir, "controller.yaml")
planted := "customer:\n id: t\n domain: example.hu\ninfrastructure:\n cf_tunnel_token: " + secret + "\n"
if err := os.WriteFile(cfgPath, []byte(planted), 0o600); err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.Customer.Domain = "example.hu"
cfg.Hub.APIKey = hubKey
cfg.Paths.DataDir = filepath.Join(dir, "data")
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if err := sett.SetPasswordHash("$2a$10$abcdefghijklmnopqrstuuJ1A9pDq6s9F2x1H3bQb0v8Y3wq6z2Om"); err != nil {
t.Fatal(err)
}
ws := web.NewServer(cfg, nil, nil, nil, nil, sett, nil, nil, nil, lg, "test")
ar := api.NewRouter(cfg, cfgPath, sett, nil, nil, nil, nil, nil, nil, nil, lg)
mux := buildMux(ar, ws)
// Control: auth is on — a request with no key and no session is refused, so a refusal below means something.
ctl := httptest.NewRequest(http.MethodGet, "/api/stacks", nil)
ctl.Host = "felhom.example.hu"
cw := httptest.NewRecorder()
mux.ServeHTTP(cw, ctl)
if cw.Code != http.StatusUnauthorized {
t.Fatalf("control: an anonymous /api/stacks answered %d, want 401 — auth is not on in this harness", cw.Code)
}
paths := []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash",
"/api/selfupdate/check", "/api/selfupdate/update", "/api/geo/settings", "/api/geo/countries", "/api/geo/sync"}
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
for _, p := range paths {
for _, m := range methods {
req := httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: evil.example\n"))
req.Host = "felhom.example.hu"
req.Header.Set("Authorization", "Bearer "+hubKey)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code/100 == 2 {
t.Errorf("%s %s with the hub key answered %d — the key opens a door", m, p, w.Code)
}
if strings.Contains(w.Body.String(), secret) {
t.Fatalf("%s %s with the hub key served the settings file", m, p)
}
}
}
if got, _ := os.ReadFile(cfgPath); string(got) != planted {
t.Fatal("controller.yaml was replaced through a hub-key request")
}
}
@@ -1,39 +0,0 @@
package api
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// TestWriteConfig0600 asserts F8: controller.yaml is persisted 0600 (it holds infra secrets), even when
// the target file already existed with looser (0644) permissions. POSIX modes only — skipped on Windows.
func TestWriteConfig0600(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX file modes not represented on Windows")
}
dir := t.TempDir()
path := filepath.Join(dir, "controller.yaml")
// Pre-create with world-readable 0644 to prove the helper tightens an existing file.
if err := os.WriteFile(path, []byte("old: true\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := writeConfig0600(path, []byte("hub:\n api_key: redacted\n")); err != nil {
t.Fatalf("writeConfig0600: %v", err)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if mode := fi.Mode().Perm(); mode != 0o600 {
t.Fatalf("config mode = %o, want 0600", mode)
}
// No leftover temp file.
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("temp file not cleaned up")
}
}
@@ -0,0 +1,41 @@
package api
import (
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// R-970 (v0.322.0): the router has no /config routes at all — even a SIGNED-IN request gets the router's 404 for every
// method, and the settings file is neither served nor written.
// RED-PROOF (2026-10-16, REPORT): with the `/config` GET case put back, this failed at „GET /api/config answered 200".
func TestR970_ConfigRoutesAreGone(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "controller.yaml")
planted := "customer:\n id: t\n domain: example.hu\nhub:\n api_key: TESTONLY-key-r970\n"
if err := os.WriteFile(path, []byte(planted), 0o600); err != nil {
t.Fatal(err)
}
r := &Router{configPath: path, logger: log.New(io.Discard, "", 0)}
r.restart = func() { t.Fatal("a /config request restarted the controller") }
for _, p := range []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash"} {
for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: e.example\n")))
if w.Code != http.StatusNotFound {
t.Errorf("%s %s answered %d, want the router's 404", m, p, w.Code)
}
if strings.Contains(w.Body.String(), "TESTONLY-key-r970") {
t.Fatalf("%s %s served the settings file", m, p)
}
}
}
if got, _ := os.ReadFile(path); string(got) != planted {
t.Fatal("the settings file was written")
}
}
+5 -105
View File
@@ -1,15 +1,12 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
@@ -180,17 +177,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/selfupdate/update" && req.Method == http.MethodPost:
r.selfupdateTrigger(w, req)
// POST /api/config/apply — Hub pushes generated YAML to update controller.yaml
case path == "/config/apply" && req.Method == http.MethodPost:
r.configApply(w, req)
// GET /api/config/hash — return current config file hash
case path == "/config/hash" && req.Method == http.MethodGet:
r.configHash(w, req)
// GET /api/config — return raw controller.yaml content
case path == "/config" && req.Method == http.MethodGet:
r.configContent(w, req)
// v0.322.0 (R-970): /api/config, /api/config/hash and /api/config/apply are GONE. They read the box's
// controller.yaml with every secret in it and replaced it — reachable with the box's own hub key, unsigned,
// which contradicted `01-topology-and-trust.md` §4/§10. Nothing called them (the hub never connects to a box;
// the agent injects config at provision; the signed config bundle writes root files). Pinned by
// TestR970_ConfigRoutesAreGone and cmd/controller TestR970_HubKeyOpensNoDoor.
// POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount)
case path == "/selfrestart" && req.Method == http.MethodPost:
@@ -1531,97 +1522,6 @@ func (r *Router) selfupdateTrigger(w http.ResponseWriter, req *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.update.kicked_off")})
}
// --- Config apply handler ---
func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
r.dbg("configApply: contentLength=%d remoteAddr=%s", req.ContentLength, req.RemoteAddr)
// Read YAML body (limit to 1MB)
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
if err != nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"})
return
}
if len(body) == 0 {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"})
return
}
// Validate it's parseable YAML by attempting to load it
if _, err := config.LoadFromBytes(body); err != nil {
r.logger.Printf("[WARN] [api] Config apply rejected: invalid YAML: %v", err)
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)})
return
}
// No-op guard: if the pushed config is byte-identical to what is already on disk, do
// nothing — don't rewrite, don't restart. The hub may re-push the same config
// idempotently, and a self-restart on every push would be a needless flap.
if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.config.unchanged")})
return
}
// Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in
// plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the
// target file already existed with looser perms (os.WriteFile does not chmod an existing file).
if err := writeConfig0600(r.configPath, body); err != nil {
r.logger.Printf("[ERROR] [api] Config apply: failed to write config: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
return
}
// Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes
// effect on restart — singletons such as the Cloudflare client are built once at
// startup, so an in-process write alone would leave e.g. a rotated CF token unused.
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.config.applied")})
flushResponse(w)
if r.restart != nil {
r.restart()
}
}
// writeConfig0600 writes config bytes to path with mode 0600, atomically when possible (tmp+rename),
// falling back to a direct write for Docker bind mounts (where os.Rename returns EBUSY). It ALWAYS
// enforces 0600 on the final file — even if it already existed with looser perms — because controller.yaml
// holds infra credentials in plaintext (F8); os.WriteFile only applies the mode when creating a new file.
func writeConfig0600(path string, body []byte) error {
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, body, 0600); err != nil {
return fmt.Errorf("writing temp config: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
if err := os.WriteFile(path, body, 0600); err != nil { // bind-mount fallback
return fmt.Errorf("writing config: %w", err)
}
}
if err := os.Chmod(path, 0600); err != nil {
return fmt.Errorf("chmod config 0600: %w", err)
}
return nil
}
func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
hash, err := config.FileHash(r.configPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}})
}
func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
data, err := os.ReadFile(r.configPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
return
}
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
w.Write(data)
}
// --- Asset sync handlers ---
func (r *Router) triggerAssetSync(w http.ResponseWriter, req *http.Request) {
@@ -1,7 +1,6 @@
package api
import (
"bytes"
"io"
"log"
"net/http"
@@ -28,61 +27,6 @@ func newRestartTestRouter(t *testing.T, prior []byte) (*Router, *int, string) {
return r, &calls, path
}
// a minimal config that passes config.LoadFromBytes (customer.id + customer.domain required).
var validConfig = []byte("customer:\n id: test\n domain: test.example\n")
func TestConfigApply_ChangedConfig_Restarts(t *testing.T) {
prior := []byte("customer:\n id: old\n domain: old.example\n")
r, calls, path := newRestartTestRouter(t, prior)
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
rec := httptest.NewRecorder()
r.configApply(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if *calls != 1 {
t.Fatalf("restart called %d times, want 1 (config changed)", *calls)
}
got, _ := os.ReadFile(path)
if !bytes.Equal(got, validConfig) {
t.Fatalf("config not written: got %q", got)
}
}
// COMPANION: an identical re-push must NOT restart (the old handler always restarted /
// always ran its post-apply hook regardless of whether anything changed).
func TestConfigApply_IdenticalConfig_NoRestart(t *testing.T) {
r, calls, _ := newRestartTestRouter(t, validConfig) // prior == body
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
rec := httptest.NewRecorder()
r.configApply(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if *calls != 0 {
t.Fatalf("restart called %d times, want 0 (config byte-identical)", *calls)
}
}
func TestConfigApply_InvalidYAML_NoRestart(t *testing.T) {
r, calls, _ := newRestartTestRouter(t, validConfig)
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader([]byte("customer:\n id: only-id-no-domain\n")))
rec := httptest.NewRecorder()
r.configApply(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (missing customer.domain)", rec.Code)
}
if *calls != 0 {
t.Fatalf("restart called %d times, want 0 (validation failed)", *calls)
}
}
func TestSelfRestart_InvokesRestarter(t *testing.T) {
r, calls, _ := newRestartTestRouter(t, nil)
+3 -12
View File
@@ -16,7 +16,6 @@ const csrfHeaderName = "X-CSRF-Token"
//
// Exempt cases:
// - Auth is disabled (no password configured)
// - Request has a valid Authorization: Bearer header (API key / hub auth)
func (s *Server) CsrfProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Safe methods: no CSRF check needed
@@ -53,17 +52,9 @@ func (s *Server) CsrfProtect(next http.Handler) http.Handler {
return
}
// Skip CSRF for Bearer-token authenticated requests.
// Validate the token against the configured API key before skipping.
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
token := strings.TrimPrefix(auth, "Bearer ")
apiKey := s.cfg.Hub.APIKey
if apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(apiKey)) == 1 {
next.ServeHTTP(w, r)
return
}
// Invalid Bearer token — fall through to CSRF validation
}
// v0.322.0 (R-970): no Bearer exemption. It existed for the routes that accepted the box's hub API key
// (/api/config/, /api/selfupdate/, /api/geo/); none does now, so a request carrying that key is a dashboard
// request like any other and needs the session's CSRF token. Pinned by TestR970_HubKeyIsNoCSRFExemption.
// Get the session's CSRF token
cookie, err := r.Cookie(sessionCookieName)
@@ -45,28 +45,10 @@ func renderDeployPage(t *testing.T, alreadyDeployed bool) string {
return buf.String()
}
// RED-PROOF: drop the `{{if $isDeployed}}` branch so the deployed page renders `value="{{$val}}"`
// again — this fails, showing the secret returning to the body of a page with nothing to submit.
func TestDeployPage_DeployedApp_DoesNotCarryTheSecret(t *testing.T) {
html := renderDeployPage(t, true)
if strings.Contains(html, testDeploySecret) {
t.Error("R-254 site two: an already-deployed app's generated secret is in the response body " +
"of its settings page — nothing there submits it, so there is no form reason for it to " +
"be in the page at all")
}
// Assert the CONTROL, not the URL: the revealAutoField() function ships in the page script on
// both variants, so a substring match on the endpoint path matches the script and would report a
// control that is not there. (This test caught exactly that on itself.)
if !strings.Contains(html, `onclick="revealAutoField('vaultwarden','DB_PASSWORD'`) {
t.Error("no reveal control rendered, so the customer cannot see their own generated secret")
}
// The hidden input must NOT appear on a deployed app — it never did, and that is the asymmetry
// that makes the readonly input indefensible there.
if strings.Contains(html, `<input type="hidden" name="DB_PASSWORD"`) {
t.Error("a submit-carrying hidden input rendered on an already-deployed app")
}
}
// v0.322.0 (R-1103): TestDeployPage_DeployedApp_DoesNotCarryTheSecret is retired — the install form no longer renders
// for an installed app (R-1095, 302 to the settings tab). Its rule (an installed app's generated secret is not in the
// page; an eye fetches it) is asserted where the value is shown now: TestAppPage_LoginPairPanel (SECRET_KEY,
// ADMIN_USER, ADMIN_PASSWORD never in the settings page; every eye answers) and TestR255_NoPlantedSecretReachesAnyPage.
// The OTHER half of §7.2's answer: the pre-deploy form still carries the value, deliberately. If this
// ever starts failing, someone has "fixed" a form by stopping it submitting what it must submit —
+2 -12
View File
@@ -187,18 +187,8 @@ func i18nCasesA() []i18nCase {
{"deploy_new_memory_blocked", "deploy", func() map[string]interface{} {
return i18nDeployData(false, stacks.StateNotDeployed, 3)
}},
// v0.319.0: `/apps/{slug}/install` opened for an app that IS installed — the form read-only, its
// secrets fetched on demand. The settings tab stopped rendering this form, so these two cases are
// what renders its installed-app branches now.
{"deploy_installed", "deploy", func() map[string]interface{} {
return i18nDeployData(true, stacks.StateRunning, 1)
}},
{"deploy_installed_restored", "deploy", func() map[string]interface{} {
d := i18nDeployData(true, stacks.StateRunning, 1)
d["DeployedFieldValues"] = map[string]string{"SUBDOMAIN": "paste"}
d["RestoredLogins"] = map[string]bool{"ADMIN_PASSWORD": true}
return d
}},
// v0.322.0 (R-1103): `deploy_installed` and `deploy_installed_restored` are retired — the install form no longer
// renders for an installed app (R-1095). The settings tab's cases cover what an installed app shows.
{"app_settings_running", "app_settings", func() map[string]interface{} {
return i18nDeployData(true, stacks.StateRunning, 1)
}},
+14 -3
View File
@@ -388,6 +388,7 @@ var lfOffDriveOrPBS = regexp.MustCompile(`(?i)off-drive|\bPBS\b`)
// The Data tab has ONE „Biztonsagi mentes" section, with no „off-drive" and no „PBS" in either language.
// RED-PROOF (2026-10-16, REPORT): with the old `tier2_config.a_2_mentes_egy_masik` text put back in hu.json, this
// failed at „hu: off-drive/PBS on the Data tab"; with the second <div class="app-section"> put back, at „2 sections".
// v0.322.0: with the status rows put back, at „status rows repeat the form"; without autocomplete, at „lacks".
func TestAppData_OneBackupSection(t *testing.T) {
if !lfOffDriveOrPBS.MatchString("a teljes rendszermentés/PBS — off-drive") {
t.Fatal("control: the matcher does not find the words it looks for")
@@ -407,9 +408,19 @@ func TestAppData_OneBackupSection(t *testing.T) {
if n := strings.Count(html, `data-backup-section="1"`); n != 1 {
t.Errorf("%s: %d backup sections, want 1", lang, n)
}
if !strings.Contains(html, `data-backup-row="nightly"`) || !strings.Contains(html, `data-backup-row="second"`) ||
!strings.Contains(html, `data-second-backup-form="1"`) {
t.Errorf("%s: the nightly row, the 2nd-copy row or its form is missing", lang)
if !strings.Contains(html, `data-backup-row="nightly"`) || !strings.Contains(html, `data-second-backup-form="1"`) {
t.Errorf("%s: the nightly row or the 2nd-copy form is missing", lang)
}
// v0.322.0: the 2nd copy's state is said once — by the form; no status rows repeat it.
if strings.Contains(html, `data-backup-row="second"`) || strings.Contains(html, `data-backup-row="target"`) {
t.Errorf("%s: the 2nd copy's status rows repeat the form", lang)
}
if n := strings.Count(html, `<label for="target">`); n != 1 {
t.Errorf("%s: the target drive is labelled %d times, want once (the form)", lang, n)
}
// The export key: the browser must not fill a saved login into it.
if !strings.Contains(html, `name="fab_encryption_key" autocomplete="new-password"`) {
t.Errorf("%s: the export password field lacks autocomplete=\"new-password\" or still has a login-like name", lang)
}
if m := lfOffDriveOrPBS.FindString(html); m != "" {
t.Errorf("%s: off-drive/PBS on the Data tab (%q)", lang, m)
@@ -1,34 +1,45 @@
package web
import (
"net/http"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-694 (v0.275.0) — a deployed app whose admin login was GENERATED by a restore: the page shows no value
// for it and says the old password is the one that works; an app without that record renders as before.
// Rendered through the production template path (renderI18nCase), both languages.
func TestR694_TheDeployPageDoesNotOfferAGeneratedLogin(t *testing.T) {
s := i18nTestServer(t)
base := func() map[string]interface{} { return i18nDeployData(true, stacks.StateRunning, 1) }
restored := func() map[string]interface{} {
d := base()
d["DeployedFieldValues"] = map[string]string{"SUBDOMAIN": "paste"} // the handler removed the value
d["RestoredLogins"] = map[string]bool{"ADMIN_PASSWORD": true}
return d
// R-694 (v0.275.0) — an installed app whose admin login was GENERATED by a restore: the page shows no value and no eye
// for it and says the old password is the one that works; an app without that record shows the eye.
//
// v0.322.0 (R-1103): moved from the install form (which no longer renders for an installed app, R-1095) to the settings
// tab, through the production route (s.ServeHTTP) on a real stack manager.
// RED-PROOF (2026-10-16, REPORT): with the `{{- if .Restored}}` branch removed from the login panel, this failed at
// „the restored login is offered for reveal".
func TestR694_TheSettingsTabDoesNotOfferAGeneratedLogin(t *testing.T) {
s := tuningHarness(t, tuningEnv())
dir := filepath.Join(s.cfg.Paths.StacksDir, "calibre-web")
if err := stacks.SaveAppConfig(dir, &stacks.AppConfig{Deployed: true, Env: tuningEnv(), RestoredLogins: []string{"ADMIN_PASSWORD"}}, nil, nil); err != nil {
t.Fatal(err)
}
hu := renderI18nCase(t, s, "hu", i18nCase{"r694", "deploy", restored})
if !strings.Contains(hu, "Mentésből töltötted vissza") || strings.Contains(hu, "rejtett érték") || strings.Contains(hu, "Telepítéskor beállított kezdeti jelszó") {
t.Fatalf("hu page does not say to use the old password, or still shows a value")
_ = s.stackMgr.ScanStacks()
rec := appGet(t, s, "/apps/calibre-web/settings")
if rec.Code != http.StatusOK {
t.Fatalf("GET settings = %d", rec.Code)
}
en := renderI18nCase(t, s, "en", i18nCase{"r694", "deploy", restored})
if !strings.Contains(en, "Restored from a backup: log in with the password that was valid when the backup was taken") {
t.Fatal("en page lacks the sentence")
body := rec.Body.String()
if strings.Contains(body, tuningPassword) {
t.Fatal("the restore-generated password is in the page")
}
plain := renderI18nCase(t, s, "hu", i18nCase{"r694-plain", "deploy", base})
if strings.Contains(plain, "Mentésből töltötted vissza") || !strings.Contains(plain, "Telepítéskor beállított kezdeti jelszó") {
t.Fatal("an app without restored_logins changed")
if strings.Contains(body, `data-login-reveal="ADMIN_PASSWORD"`) {
t.Fatal("the restored login is offered for reveal")
}
if !strings.Contains(body, `data-login-restored="ADMIN_PASSWORD"`) {
t.Fatal("the page does not say to use the old password")
}
// Control: without the record the same app offers the eye.
plain := tuningHarness(t, tuningEnv())
if b := appGet(t, plain, "/apps/calibre-web/settings").Body.String(); !strings.Contains(b, `data-login-reveal="ADMIN_PASSWORD"`) || strings.Contains(b, `data-login-restored`) {
t.Fatal("control: an app without restored_logins does not offer its eye")
}
}
@@ -41,21 +41,11 @@ func renderDeployWithPassword(t *testing.T, alreadyDeployed bool, restored bool)
return buf.String()
}
// COMPANION RED-PROOF: put `value="{{index $.DeployedFieldValues .EnvVar}}"` back on the deployed password input
// → the first assertion fails with the password in the body.
func TestR709_AnInstalledAppsPasswordIsNotInThePage(t *testing.T) {
html := renderDeployWithPassword(t, true, false)
if strings.Contains(html, testAdminPassword) {
t.Fatal("R-709: the installed app's admin password is in the HTML of its settings page")
}
if !strings.Contains(html, `onclick="revealPasswordField('grafana', 'ADMIN_PASSWORD', this)"`) {
t.Fatal("no reveal control: the household cannot see its own password")
}
// A login a restore generated is not the app's login (R-694): no reveal for it.
if html := renderDeployWithPassword(t, true, true); strings.Contains(html, `revealPasswordField('grafana'`) || strings.Contains(html, testAdminPassword) {
t.Fatal("a restore-generated login is offered for reveal")
}
// The pre-deploy form keeps its generator, with the field's own spec.
// v0.322.0 (R-1103): the installed half of this test is retired — the install form no longer renders for an installed
// app (R-1095). „The password is not in the page, an eye fetches it" is asserted on the settings tab
// (TestAppPage_LoginPairPanel), and „no eye for a restore-generated login" too (TestR694_TheSettingsTabDoesNotOfferAGeneratedLogin).
// What stays here is the install form's own rule: it keeps its generator, with the field's spec.
func TestR709_TheInstallFormKeepsItsGenerator(t *testing.T) {
if html := renderDeployWithPassword(t, false, false); !strings.Contains(html, `generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', 'password:24:special')`) {
t.Fatal("the install form lost its generator (or its spec)")
}
+42
View File
@@ -0,0 +1,42 @@
package web
import (
"io"
"log"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-970 (v0.322.0): the box's hub API key is no CSRF exemption any more — no route accepts it, so a POST that carries
// it and no session CSRF token is refused like any other.
// RED-PROOF (2026-10-16, REPORT): with the Bearer exemption put back in CsrfProtect, this failed at „passed CSRF".
func TestR970_HubKeyIsNoCSRFExemption(t *testing.T) {
cfg := config.Default()
cfg.Hub.APIKey = "TESTONLY-hub-key-csrf"
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if err := sett.SetPasswordHash("$2a$10$abcdefghijklmnopqrstuuJ1A9pDq6s9F2x1H3bQb0v8Y3wq6z2Om"); err != nil {
t.Fatal(err)
}
s := &Server{cfg: cfg, settings: sett, logger: lg}
if !s.authEnabled() {
t.Fatal("control: auth is not on, so CSRF would pass everything and this test would measure nothing")
}
reached := false
h := s.CsrfProtect(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
req := httptest.NewRequest(http.MethodPost, "/api/selfupdate/update", nil)
req.Header.Set("Authorization", "Bearer "+cfg.Hub.APIKey)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if reached || w.Code/100 == 2 {
t.Fatalf("a POST carrying the hub key passed CSRF (code %d)", w.Code)
}
}
+12 -29
View File
@@ -90,7 +90,8 @@ function appMigrate(btn,app,label){
{{/* „BIZTONSAGI MENTES" — ONE section (v0.321.0). The nightly backup and the 2nd copy were two sections with
two titles for one subject, and the 2nd copy's state sat in a two-column grid of monospace values. Now: the
nightly backup is a row, the 2nd copy's state is rows, then what it means and the form to change it. The words
nightly backup is a row, then what the 2nd copy means and the form that shows and changes it (v0.322.0: the
rows that repeated the form are gone). The words
are plain: no „off-drive", no „PBS", no „DB/konfiguráció". Pinned by TestAppData_OneBackupSection. */}}
<div class="app-section" id="app-backup-section" data-backup-section="1">
<h3>{{T "deploy.biztonsagi_mentes"}}</h3>
@@ -99,31 +100,8 @@ function appMigrate(btn,app,label){
<div class="app-field-value">{{T "app_page.backup_nightly_text"}} <a href="/backups" class="app-info-link">{{T "app_page.backup_status_link"}}</a></div>
</div>
{{- if .Tier2}}{{with .Tier2}}
{{- if .IsHDDApp}}
<div class="app-field-row" data-backup-row="second">
<div class="app-field-label">{{T "tier2_config.2_mentes"}}</div>
<div class="app-field-value">{{if .Disabled}}{{T "tier2_config.kikapcsolva"}}{{else}}{{T "tier2_config.bekapcsolva"}}{{end}}</div>
</div>
{{- if .NoTarget}}
<div class="app-field-row" data-backup-row="target">
<div class="app-field-label">{{T "tier2_config.cel"}}</div>
<div class="app-field-value text-error">{{T "tier2_config.nincs_elerheto_off_drive_cel"}}</div>
</div>
<div class="app-field-row">
<div class="app-field-label">{{T "tier2_config.megjegyzes"}}</div>
<div class="app-field-value">{{.NoTargetReason}}</div>
</div>
{{- else}}
<div class="app-field-row" data-backup-row="target">
<div class="app-field-label">{{T "tier2_config.cel_meghajto"}}</div>
<div class="app-field-value">{{.EffectiveLabel}}{{if .EffectiveIsSSD}} {{T "tier2_config.csak_db_konfiguracio"}}{{end}}</div>
</div>
<div class="app-field-row">
<div class="app-field-label">{{T "tier2_config.kivalasztas_modja"}}</div>
<div class="app-field-value">{{if .Preferred}}{{T "tier2_config.kezi_valasztas"}}{{else}}{{T "tier2_config.automatikus_kisbetu"}}{{end}} — {{.EffectiveDesc}}</div>
</div>
{{- end}}
{{- end}}
{{- /* v0.322.0: the 2nd copy's state is said ONCE — by the form below (checkbox, target, hint). Its rows here
repeated the same three facts. */}}
<p class="app-field-hint" style="margin-top:12px">{{T "tier2_config.a_2_mentes_egy_masik"}}</p>
{{if not .IsHDDApp}}
@@ -167,6 +145,9 @@ function appMigrate(btn,app,label){
<option value="{{.Path}}" {{if eq .Path $.Tier2.Preferred}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
{{if .NoTarget}}
<div class="form-hint text-error" style="margin-top:.4rem" data-second-backup-notarget="1">{{T "tier2_config.nincs_elerheto_off_drive_cel"}} — {{.NoTargetReason}}</div>
{{end}}
{{if not .Alternatives}}
<div style="font-size:.8rem;color:var(--text-3);margin-top:.4rem">
{{T "tier2_config.nincs_masik_adatmeghajto_automatikus_cel"}}
@@ -229,7 +210,9 @@ function appMigrate(btn,app,label){
<h3>{{T "app_export.jelszo_opcionalis"}}</h3>
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
<input type="password" id="exportPassword" class="form-control" placeholder="{{T "app_export.titkositasi_jelszo"}}" style="flex:1">
{{/* v0.322.0: autocomplete="new-password" and a name that is not a login field — the browser filled the household's
saved dashboard password in here, and an export could be locked with it unnoticed. */}}
<input type="password" id="fabEncryptionKey" name="fab_encryption_key" autocomplete="new-password" data-export-key="1" class="form-control" placeholder="{{T "app_export.titkositasi_jelszo"}}" style="flex:1">
<button class="btn btn-sm btn-outline" onclick="togglePw(this)" type="button" id="exportPwToggle" aria-label="{{T "app_export.jelszo_mutatasa"}}">{{T "deploy.megjelenites"}}</button>
</div>
@@ -272,7 +255,7 @@ function csrfH() {
}
function togglePw(btn) {
var inp = document.getElementById('exportPassword');
var inp = document.getElementById('fabEncryptionKey');
var show = inp.type === 'password';
inp.type = show ? 'text' : 'password';
if (btn) { btn.textContent = show ? '{{T "app_info.elrejtes"}}' : '{{T "deploy.megjelenites"}}'; }
@@ -394,7 +377,7 @@ function esc(s) { return String(s).replace(/[&<>"]/g, function(c){ return {'&':'
async function startExport() {
var drive = document.getElementById('destDrive').value;
var password = document.getElementById('exportPassword').value;
var password = document.getElementById('fabEncryptionKey').value;
var stopApp = document.getElementById('stopApp').checked;
document.getElementById('startBtn').disabled = true;
@@ -1,55 +1,34 @@
{{/* THE APP'S CONFIGURATION FIELDS — the install wizard's form (deploy.html).
v0.319.0: the settings tab no longer renders this — it shows the install answers as rows and
the login as one panel (app_settings.html, app_login.go). The `.AlreadyDeployed` branches below
are NOT dead: `/apps/{slug}/install` still answers for an app that is installed, and renders
this form read-only. Their guards are what keep an installed app's secrets out of that page
(TestDeployPage_DeployedApp_DoesNotCarryTheSecret, TestR709_AnInstalledAppsPasswordIsNotInThePage)
and the parity case `deploy_installed` renders them.
It renders ONLY for an app that is not installed (v0.322.0, R-1103): since v0.321.0 (R-1095)
`/apps/{slug}/install` of an installed app answers 302 to its settings tab, and the settings tab
shows the install answers as rows and the login as one panel (app_settings.html, app_login.go).
The old read-only „installed" branches are gone; their secret rules live where the values are
shown now — R-254/R-709 on the settings tab (TestAppPage_LoginPairPanel, TestR255_NoPlantedSecretReachesAnyPage).
The secret rules are unchanged and load-bearing:
R-254 — an INSTALLED app's `type: secret` value is not in the page; the eye fetches it.
R-709 — nor is a `type: password` one.
The PRE-DEPLOY hidden input is deliberate: the form must carry what it submits, so the value
saved is the one the household was shown (controller/README §318). */}}
The PRE-DEPLOY hidden input is deliberate: the form must carry what it submits, so the value
saved is the one the household was shown (controller/README §318). */}}
{{define "app_deploy_fields"}}
{{if .AutoFields}}
<div class="form-section">
<h4>{{T "deploy.automatikusan_generalt_ertekek"}}</h4>
{{if .AlreadyDeployed}}
<p class="form-section-desc">{{T "deploy.ezek_az_ertekek_automatikusan_jottek"}}</p>
{{else}}
<p class="form-section-desc">{{T "deploy.ezek_az_ertekek_a_telepitessel"}}</p>
{{end}}
{{$autoValues := .AutoFieldValues}}
{{$stackName := .Stack.Name}}
{{$isDeployed := .AlreadyDeployed}}
{{range .AutoFields}}
{{$val := index $autoValues .EnvVar}}
<div class="form-group form-group-auto">
<label>{{.Label}} {{if eq .Type "secret"}}<span class="auto-generated-badge"><svg class="ico ico-sm"><use href="#i-check"/></svg> {{T "deploy.automatikusan_generalva"}}</span>{{end}}</label>
{{if $val}}
{{if eq .Type "secret"}}
{{if $isDeployed}}
<!-- R-254 site two: on an ALREADY-DEPLOYED app nothing is being submitted, so there is
no reason for the value to be in this page at all. It is fetched on demand. The
pre-deploy branch below is different and deliberate — the form must carry what it
submits, so the saved value is the one the customer was shown (README §318). -->
<div class="input-with-button">
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="" placeholder="••••••••••••" readonly>
<button type="button" class="btn btn-sm btn-outline" onclick="revealAutoField('{{$stackName}}','{{.EnvVar}}', this)">{{T "deploy.megjelenites"}}</button>
</div>
{{else}}
<div class="input-with-button">
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly>
<button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-{{.EnvVar}}', this)">{{T "deploy.megjelenites"}}</button>
</div>
{{end}}
{{else}}
<input type="text" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly>
{{end}}
{{if and (not $isDeployed) (eq .Type "secret")}}
{{if eq .Type "secret"}}
<input type="hidden" name="{{.EnvVar}}" value="{{$val}}">
{{end}}
{{end}}
@@ -62,10 +41,6 @@
<div class="form-section">
<h4>{{T "deploy.beallitasok"}}</h4>
{{range .UserFields}}
{{/* v0.316.0: an INSTALLED app shows its address once, as the „Cim" row above these
fields, so the disabled subdomain input saying the same thing is skipped. The
install wizard still asks for it — this is the read-only view's duplicate only. */}}
{{if and $.AlreadyDeployed (eq .Type "subdomain")}}{{continue}}{{end}}
<div class="form-group">
<label for="field-{{.EnvVar}}">
{{.Label}}
@@ -79,35 +54,22 @@
class="form-control subdomain-input"
{{/* R-351: a reinstall offers the address the customer's own backup recorded,
ahead of the catalog default. Editable — a domain can legitimately change. */}}
value="{{if and $.AlreadyDeployed $.DeployedFieldValues}}{{index $.DeployedFieldValues .EnvVar}}{{else if index $.RestoreFieldValues .EnvVar}}{{index $.RestoreFieldValues .EnvVar}}{{else}}{{.Default}}{{end}}"
value="{{if index $.RestoreFieldValues .EnvVar}}{{index $.RestoreFieldValues .EnvVar}}{{else}}{{.Default}}{{end}}"
placeholder="{{T "deploy.aldomain_placeholder"}}"
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
required
{{if $.AlreadyDeployed}}disabled{{end}}
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')">
<span class="subdomain-suffix">.{{$.Domain}}</span>
</div>
{{if not $.AlreadyDeployed}}
<span class="form-hint">{{T "deploy.az_aldomain_telepites_utan_nem"}}</span>
{{end}}
{{else if eq .Type "select"}}
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
{{if $.AlreadyDeployed}}disabled{{end}}>
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control">
{{range .Options}}
<option value="{{.Value}}">{{.Label}}</option>
{{end}}
</select>
{{else if eq .Type "password"}}
<div class="input-with-button">
{{- if $.AlreadyDeployed}}
{{- /* R-709 (v0.280.0): an installed app's password is never in this page; the eye fetches it. */}}
<input type="password" id="field-{{.EnvVar}}" class="form-control" value="" placeholder="••••••••••••" disabled>
{{- if not (and $.RestoredLogins (index $.RestoredLogins .EnvVar))}}
<button type="button" class="btn btn-sm btn-outline pw-toggle-btn"
onclick="revealPasswordField('{{$.Stack.Name}}', '{{.EnvVar}}', this)"
title="{{T "deploy.megjelenites"}}">&#128065;</button>
{{- end}}
{{- else}}
<input type="password" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}"
@@ -118,27 +80,20 @@
title="{{T "deploy.megjelenites"}}">&#128065;</button>
<button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-{{.EnvVar}}', 'field-confirm-{{.EnvVar}}', '{{.Generate}}')">{{T "deploy.generalas"}}</button>
{{- end}}
</div>
{{if $.AlreadyDeployed}}
<span class="form-hint">{{if and $.RestoredLogins (index $.RestoredLogins .EnvVar)}}{{T "deploy.login_from_backup"}}{{else}}{{T "deploy.telepiteskor_beallitott_kezdeti_jelszo_h"}}{{end}}</span>
{{else}}
<div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-{{.EnvVar}}"
class="form-control" placeholder="{{T "deploy.jelszo_megerositese"}}"
data-confirm-for="field-{{.EnvVar}}">
</div>
{{end}}
{{else if eq .Type "boolean"}}
<label class="toggle">
<input type="checkbox" id="field-{{.EnvVar}}" name="{{.EnvVar}}" value="true"
{{if $.AlreadyDeployed}}disabled{{end}}>
<input type="checkbox" id="field-{{.EnvVar}}" name="{{.EnvVar}}" value="true">
<span class="toggle-label">{{T "deploy.igen"}}</span>
</label>
{{else if eq .Type "path"}}
{{if $.StoragePaths}}
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
{{if $.AlreadyDeployed}}disabled{{end}}
onchange="checkStorageSpace(this)">
{{range $.StoragePaths}}
<option value="{{.Path}}" data-free-percent="{{printf "%.0f" .FreePercent}}"
@@ -147,13 +102,10 @@
configured default — it is where this app's data actually lived, and
restoring into a different drive is the mismatch the restore then has
to stop and name. The customer can still pick another. */}}
{{if $.AlreadyDeployed}}{{if eq .Path $.CurrentHDDPath}}selected{{end}}{{else if $.RestorePrefillHDDPath}}{{if and (eq .Path $.RestorePrefillHDDPath) (not .NotAllowed)}}selected{{end}}{{else if and .IsDefault (not .NotAllowed)}}selected{{end}}>
{{if $.RestorePrefillHDDPath}}{{if and (eq .Path $.RestorePrefillHDDPath) (not .NotAllowed)}}selected{{end}}{{else if and .IsDefault (not .NotAllowed)}}selected{{end}}>
{{T "deploy.szabad_2"}}{{if .NotAllowed}} ({{.NotAllowedNote}}){{else if .IsDefault}} {{T "deploy.alapertelmezett"}}{{end}}
</option>
{{end}}
{{if $.CurrentHDDPathMissing}}
<option value="{{$.CurrentHDDPath}}" selected disabled>{{T "deploy.nem_elerheto"}}</option>
{{end}}
</select>
<div id="storage-space-warn" class="form-hint" style="color:var(--warn);display:none">
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> {{T "deploy.a_kivalasztott_tarhely_majdnem_megtelt"}}
@@ -165,16 +117,14 @@
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}"
{{if .Required}}required{{end}}
{{if $.AlreadyDeployed}}disabled{{end}}>
{{if .Required}}required{{end}}>
<span class="form-hint" style="color:var(--warn)">{{T "deploy.nincs_regisztralt_adattarolo_adja_meg"}}</span>
{{end}}
{{else}}
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}"
{{if .Required}}required{{end}}
{{if $.AlreadyDeployed}}disabled{{end}}>
{{if .Required}}required{{end}}>
{{end}}
{{if and .Description (ne .Type "subdomain")}}
@@ -299,18 +299,6 @@ function appMigrate(btn,app,label){
<div class="app-field-label">Éjszakai mentés</div>
<div class="app-field-value">Az alkalmazás adatbázisa és belső tárolói minden éjjel bekerülnek a mentésbe. <a href="/backups" class="app-info-link">Mentési állapot →</a></div>
</div>
<div class="app-field-row" data-backup-row="second">
<div class="app-field-label">2. mentés</div>
<div class="app-field-value">Bekapcsolva</div>
</div>
<div class="app-field-row" data-backup-row="target">
<div class="app-field-label">Cél meghajtó</div>
<div class="app-field-value">HDD kettő</div>
</div>
<div class="app-field-row">
<div class="app-field-label">Kiválasztás módja</div>
<div class="app-field-value">automatikus — a legnagyobb szabad hely</div>
</div>
<p class="app-field-hint" style="margin-top:12px">A 2. mentés egy <strong>másik meghajtóra</strong> másolja az alkalmazás helyreállítási csomagját és adatait. A böngészhető fájljaidat csak ez védi meg egy meghajtóhiba ellen — a teljes rendszermentés ezeket nem tartalmazza.</p>
@@ -331,6 +319,7 @@ function appMigrate(btn,app,label){
</select>
<div style="font-size:.8rem;color:var(--text-3);margin-top:.4rem">
Nincs másik adatmeghajtó — automatikusan a belső SSD-re kerül (csak az adatbázis és a beállítások). Egy 2. adatmeghajtóval a teljes adat is másik meghajtóra menthető.
</div>
@@ -392,7 +381,8 @@ function appMigrate(btn,app,label){
<h3>Jelszó (opcionális)</h3>
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
<input type="password" id="exportPassword" class="form-control" placeholder="Titkosítási jelszó" style="flex:1">
<input type="password" id="fabEncryptionKey" name="fab_encryption_key" autocomplete="new-password" data-export-key="1" class="form-control" placeholder="Titkosítási jelszó" style="flex:1">
<button class="btn btn-sm btn-outline" onclick="togglePw(this)" type="button" id="exportPwToggle" aria-label="Jelszó mutatása">Megjelenítés</button>
</div>
@@ -435,7 +425,7 @@ function csrfH() {
}
function togglePw(btn) {
var inp = document.getElementById('exportPassword');
var inp = document.getElementById('fabEncryptionKey');
var show = inp.type === 'password';
inp.type = show ? 'text' : 'password';
if (btn) { btn.textContent = show ? 'Elrejtés' : 'Megjelenítés'; }
@@ -557,7 +547,7 @@ function esc(s) { return String(s).replace(/[&<>"]/g, function(c){ return {'&':'
async function startExport() {
var drive = document.getElementById('destDrive').value;
var password = document.getElementById('exportPassword').value;
var password = document.getElementById('fabEncryptionKey').value;
var stopApp = document.getElementById('stopApp').checked;
document.getElementById('startBtn').disabled = true;
@@ -287,7 +287,8 @@
<h3>Jelszó (opcionális)</h3>
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
<input type="password" id="exportPassword" class="form-control" placeholder="Titkosítási jelszó" style="flex:1">
<input type="password" id="fabEncryptionKey" name="fab_encryption_key" autocomplete="new-password" data-export-key="1" class="form-control" placeholder="Titkosítási jelszó" style="flex:1">
<button class="btn btn-sm btn-outline" onclick="togglePw(this)" type="button" id="exportPwToggle" aria-label="Jelszó mutatása">Megjelenítés</button>
</div>
@@ -330,7 +331,7 @@ function csrfH() {
}
function togglePw(btn) {
var inp = document.getElementById('exportPassword');
var inp = document.getElementById('fabEncryptionKey');
var show = inp.type === 'password';
inp.type = show ? 'text' : 'password';
if (btn) { btn.textContent = show ? 'Elrejtés' : 'Megjelenítés'; }
@@ -452,7 +453,7 @@ function esc(s) { return String(s).replace(/[&<>"]/g, function(c){ return {'&':'
async function startExport() {
var drive = document.getElementById('destDrive').value;
var password = document.getElementById('exportPassword').value;
var password = document.getElementById('fabEncryptionKey').value;
var stopApp = document.getElementById('stopApp').checked;
document.getElementById('startBtn').disabled = true;
@@ -233,18 +233,6 @@
<div class="app-field-label">Éjszakai mentés</div>
<div class="app-field-value">Az alkalmazás adatbázisa és belső tárolói minden éjjel bekerülnek a mentésbe. <a href="/backups" class="app-info-link">Mentési állapot →</a></div>
</div>
<div class="app-field-row" data-backup-row="second">
<div class="app-field-label">2. mentés</div>
<div class="app-field-value">Kikapcsolva</div>
</div>
<div class="app-field-row" data-backup-row="target">
<div class="app-field-label">Cél</div>
<div class="app-field-value text-error">Nincs másik meghajtó, ahová másolhatna</div>
</div>
<div class="app-field-row">
<div class="app-field-label">Megjegyzés</div>
<div class="app-field-value">Nincs második meghajtó csatlakoztatva</div>
</div>
<p class="app-field-hint" style="margin-top:12px">A 2. mentés egy <strong>másik meghajtóra</strong> másolja az alkalmazás helyreállítási csomagját és adatait. A böngészhető fájljaidat csak ez védi meg egy meghajtóhiba ellen — a teljes rendszermentés ezeket nem tartalmazza.</p>
@@ -265,6 +253,9 @@
</select>
<div class="form-hint text-error" style="margin-top:.4rem" data-second-backup-notarget="1">Nincs másik meghajtó, ahová másolhatna — Nincs második meghajtó csatlakoztatva</div>
<div style="font-size:.8rem;color:var(--text-3);margin-top:.4rem">
Nincs másik adatmeghajtó — automatikusan a belső SSD-re kerül (csak az adatbázis és a beállítások). Egy 2. adatmeghajtóval a teljes adat is másik meghajtóra menthető.
</div>
@@ -233,18 +233,6 @@
<div class="app-field-label">Éjszakai mentés</div>
<div class="app-field-value">Az alkalmazás adatbázisa és belső tárolói minden éjjel bekerülnek a mentésbe. <a href="/backups" class="app-info-link">Mentési állapot →</a></div>
</div>
<div class="app-field-row" data-backup-row="second">
<div class="app-field-label">2. mentés</div>
<div class="app-field-value">Bekapcsolva</div>
</div>
<div class="app-field-row" data-backup-row="target">
<div class="app-field-label">Cél meghajtó</div>
<div class="app-field-value">HDD kettő</div>
</div>
<div class="app-field-row">
<div class="app-field-label">Kiválasztás módja</div>
<div class="app-field-value">automatikus — a legnagyobb szabad hely</div>
</div>
<p class="app-field-hint" style="margin-top:12px">A 2. mentés egy <strong>másik meghajtóra</strong> másolja az alkalmazás helyreállítási csomagját és adatait. A böngészhető fájljaidat csak ez védi meg egy meghajtóhiba ellen — a teljes rendszermentés ezeket nem tartalmazza.</p>
@@ -265,6 +253,7 @@
</select>
<div style="font-size:.8rem;color:var(--text-3);margin-top:.4rem">
Nincs másik adatmeghajtó — automatikusan a belső SSD-re kerül (csak az adatbázis és a beállítások). Egy 2. adatmeghajtóval a teljes adat is másik meghajtóra menthető.
</div>
@@ -233,18 +233,6 @@
<div class="app-field-label">Éjszakai mentés</div>
<div class="app-field-value">Az alkalmazás adatbázisa és belső tárolói minden éjjel bekerülnek a mentésbe. <a href="/backups" class="app-info-link">Mentési állapot →</a></div>
</div>
<div class="app-field-row" data-backup-row="second">
<div class="app-field-label">2. mentés</div>
<div class="app-field-value">Bekapcsolva</div>
</div>
<div class="app-field-row" data-backup-row="target">
<div class="app-field-label">Cél meghajtó</div>
<div class="app-field-value">SSD meghajtó egy — csak az adatbázis és a beállítások</div>
</div>
<div class="app-field-row">
<div class="app-field-label">Kiválasztás módja</div>
<div class="app-field-value">kézi választás — a leggyorsabb meghajtó</div>
</div>
<p class="app-field-hint" style="margin-top:12px">A 2. mentés egy <strong>másik meghajtóra</strong> másolja az alkalmazás helyreállítási csomagját és adatait. A böngészhető fájljaidat csak ez védi meg egy meghajtóhiba ellen — a teljes rendszermentés ezeket nem tartalmazza.</p>
@@ -271,6 +259,7 @@
</select>
</div>
<button type="submit" class="btn btn-primary">Mentés</button>
</form>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -251,26 +251,20 @@
<div class="form-section">
<h4>Automatikusan generált értékek</h4>
<p class="form-section-desc">Ezek az értékek a telepítéssel együtt mentésre kerülnek. Jegyezd fel a szükséges jelszavakat!</p>
<div class="form-group form-group-auto">
<label>Titkos kulcs érték <span class="auto-generated-badge"><svg class="ico ico-sm"><use href="#i-check"/></svg> Automatikusan generálva</span></label>
<div class="input-with-button">
<input type="password" id="auto-field-SECRET_KEY" class="form-control" value="generated-secret-value" readonly>
<button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-SECRET_KEY', this)">Megjelenítés</button>
</div>
<input type="hidden" name="SECRET_KEY" value="generated-secret-value">
@@ -300,8 +294,6 @@
<div class="form-section">
<h4>Beállítások</h4>
<div class="form-group">
<label for="field-SUBDOMAIN">
Webcím eleje
@@ -318,20 +310,15 @@
placeholder="aldomain"
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
required
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')">
<span class="subdomain-suffix">.example.hu</span>
</div>
<span class="form-hint">Az aldomain telepítés után nem módosítható. Megváltoztatáshoz az alkalmazás eltávolítása és újratelepítése szükséges (minden adat törlődik).</span>
</div>
<div class="form-group">
<label for="field-LANG_SEL">
Felület nyelve
@@ -340,8 +327,7 @@
</label>
<select id="field-LANG_SEL" name="LANG_SEL" class="form-control"
>
<select id="field-LANG_SEL" name="LANG_SEL" class="form-control">
<option value="hu">Magyar nyelv</option>
@@ -351,8 +337,6 @@
</div>
<div class="form-group">
<label for="field-ADMIN_PASSWORD">
Admin jelszó mező
@@ -373,20 +357,16 @@
<button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás</button>
</div>
<div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-ADMIN_PASSWORD"
class="form-control" placeholder="Jelszó megerősítése"
data-confirm-for="field-ADMIN_PASSWORD">
</div>
</div>
<div class="form-group">
<label for="field-FLAG">
Kapcsoló mező itt
@@ -396,8 +376,7 @@
<label class="toggle">
<input type="checkbox" id="field-FLAG" name="FLAG" value="true"
>
<input type="checkbox" id="field-FLAG" name="FLAG" value="true">
<span class="toggle-label">Igen</span>
</label>
@@ -407,8 +386,6 @@
</div>
<div class="form-group">
<label for="field-HDD_PATH">
Adatok helye itt
@@ -419,7 +396,6 @@
<select id="field-HDD_PATH" name="HDD_PATH" class="form-control"
onchange="checkStorageSpace(this)">
<option value="/mnt/hdd1" data-free-percent="50"
@@ -443,7 +419,6 @@
HDD kettő — 100 GB szabad
</option>
</select>
<div id="storage-space-warn" class="form-hint" style="color:var(--warn);display:none">
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A kiválasztott tárhely majdnem megtelt.
@@ -458,8 +433,6 @@
</div>
<div class="form-group">
<label for="field-NOTE">
Megjegyzés mező
@@ -471,7 +444,6 @@
<input type="text" id="field-NOTE" name="NOTE"
class="form-control" value=""
placeholder="Írj ide valamit"
>
@@ -233,26 +233,20 @@
<div class="form-section">
<h4>Automatikusan generált értékek</h4>
<p class="form-section-desc">Ezek az értékek a telepítéssel együtt mentésre kerülnek. Jegyezd fel a szükséges jelszavakat!</p>
<div class="form-group form-group-auto">
<label>Titkos kulcs érték <span class="auto-generated-badge"><svg class="ico ico-sm"><use href="#i-check"/></svg> Automatikusan generálva</span></label>
<div class="input-with-button">
<input type="password" id="auto-field-SECRET_KEY" class="form-control" value="generated-secret-value" readonly>
<button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-SECRET_KEY', this)">Megjelenítés</button>
</div>
<input type="hidden" name="SECRET_KEY" value="generated-secret-value">
@@ -282,8 +276,6 @@
<div class="form-section">
<h4>Beállítások</h4>
<div class="form-group">
<label for="field-SUBDOMAIN">
Webcím eleje
@@ -300,20 +292,15 @@
placeholder="aldomain"
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
required
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')">
<span class="subdomain-suffix">.example.hu</span>
</div>
<span class="form-hint">Az aldomain telepítés után nem módosítható. Megváltoztatáshoz az alkalmazás eltávolítása és újratelepítése szükséges (minden adat törlődik).</span>
</div>
<div class="form-group">
<label for="field-LANG_SEL">
Felület nyelve
@@ -322,8 +309,7 @@
</label>
<select id="field-LANG_SEL" name="LANG_SEL" class="form-control"
>
<select id="field-LANG_SEL" name="LANG_SEL" class="form-control">
<option value="hu">Magyar nyelv</option>
@@ -333,8 +319,6 @@
</div>
<div class="form-group">
<label for="field-ADMIN_PASSWORD">
Admin jelszó mező
@@ -355,20 +339,16 @@
<button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás</button>
</div>
<div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-ADMIN_PASSWORD"
class="form-control" placeholder="Jelszó megerősítése"
data-confirm-for="field-ADMIN_PASSWORD">
</div>
</div>
<div class="form-group">
<label for="field-FLAG">
Kapcsoló mező itt
@@ -378,8 +358,7 @@
<label class="toggle">
<input type="checkbox" id="field-FLAG" name="FLAG" value="true"
>
<input type="checkbox" id="field-FLAG" name="FLAG" value="true">
<span class="toggle-label">Igen</span>
</label>
@@ -389,8 +368,6 @@
</div>
<div class="form-group">
<label for="field-HDD_PATH">
Adatok helye itt
@@ -403,8 +380,7 @@
<input type="text" id="field-HDD_PATH" name="HDD_PATH"
class="form-control" value=""
placeholder="/mnt/valahol"
required
>
required>
<span class="form-hint" style="color:var(--warn)">Nincs regisztrált adattároló — add meg kézzel az útvonalat</span>
@@ -412,8 +388,6 @@
</div>
<div class="form-group">
<label for="field-NOTE">
Megjegyzés mező
@@ -425,7 +399,6 @@
<input type="text" id="field-NOTE" name="NOTE"
class="form-control" value=""
placeholder="Írj ide valamit"
>
@@ -228,26 +228,20 @@
<div class="form-section">
<h4>Automatikusan generált értékek</h4>
<p class="form-section-desc">Ezek az értékek a telepítéssel együtt mentésre kerülnek. Jegyezd fel a szükséges jelszavakat!</p>
<div class="form-group form-group-auto">
<label>Titkos kulcs érték <span class="auto-generated-badge"><svg class="ico ico-sm"><use href="#i-check"/></svg> Automatikusan generálva</span></label>
<div class="input-with-button">
<input type="password" id="auto-field-SECRET_KEY" class="form-control" value="generated-secret-value" readonly>
<button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-SECRET_KEY', this)">Megjelenítés</button>
</div>
<input type="hidden" name="SECRET_KEY" value="generated-secret-value">
@@ -277,8 +271,6 @@
<div class="form-section">
<h4>Beállítások</h4>
<div class="form-group">
<label for="field-SUBDOMAIN">
Webcím eleje
@@ -295,20 +287,15 @@
placeholder="aldomain"
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
required
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')">
<span class="subdomain-suffix">.example.hu</span>
</div>
<span class="form-hint">Az aldomain telepítés után nem módosítható. Megváltoztatáshoz az alkalmazás eltávolítása és újratelepítése szükséges (minden adat törlődik).</span>
</div>
<div class="form-group">
<label for="field-LANG_SEL">
Felület nyelve
@@ -317,8 +304,7 @@
</label>
<select id="field-LANG_SEL" name="LANG_SEL" class="form-control"
>
<select id="field-LANG_SEL" name="LANG_SEL" class="form-control">
<option value="hu">Magyar nyelv</option>
@@ -328,8 +314,6 @@
</div>
<div class="form-group">
<label for="field-ADMIN_PASSWORD">
Admin jelszó mező
@@ -350,20 +334,16 @@
<button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás</button>
</div>
<div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-ADMIN_PASSWORD"
class="form-control" placeholder="Jelszó megerősítése"
data-confirm-for="field-ADMIN_PASSWORD">
</div>
</div>
<div class="form-group">
<label for="field-FLAG">
Kapcsoló mező itt
@@ -373,8 +353,7 @@
<label class="toggle">
<input type="checkbox" id="field-FLAG" name="FLAG" value="true"
>
<input type="checkbox" id="field-FLAG" name="FLAG" value="true">
<span class="toggle-label">Igen</span>
</label>
@@ -384,8 +363,6 @@
</div>
<div class="form-group">
<label for="field-HDD_PATH">
Adatok helye itt
@@ -396,7 +373,6 @@
<select id="field-HDD_PATH" name="HDD_PATH" class="form-control"
onchange="checkStorageSpace(this)">
<option value="/mnt/hdd1" data-free-percent="50"
@@ -420,9 +396,6 @@
HDD kettő — 100 GB szabad
</option>
<option value="/mnt/gone" selected disabled>/mnt/gone (nem elérhető)</option>
</select>
<div id="storage-space-warn" class="form-hint" style="color:var(--warn);display:none">
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A kiválasztott tárhely majdnem megtelt.
@@ -437,8 +410,6 @@
</div>
<div class="form-group">
<label for="field-NOTE">
Megjegyzés mező
@@ -450,7 +421,6 @@
<input type="text" id="field-NOTE" name="NOTE"
class="form-control" value=""
placeholder="Írj ide valamit"
>