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:
admin committed 2026-10-16 18:24:00 +02:00
1 parent 8fe46a1313
commit f037be7a6a
25 files changed
+331 -2987

No files matched your search

+53 -67
View File
@@ -18,7 +18,6 @@ import (
"syscall" "syscall"
"time" "time"
"crypto/subtle"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -2190,56 +2189,7 @@ func main() {
// the controller no longer runs a DriveMigrator. // the controller no longer runs a DriveMigrator.
// --- Build HTTP mux --- // --- Build HTTP mux ---
mux := http.NewServeMux() mux := buildMux(apiRouter, webServer)
// 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))))
// --- Start HTTP server --- // --- Start HTTP server ---
server := &http.Server{ server := &http.Server{
@@ -2282,7 +2232,6 @@ func main() {
logger.Println("[INFO] felhom-controller stopped") 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 // 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 // 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). // 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 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 // 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 // and captures every line (DEBUG included — remote diagnostics need the detail to
// exist without a config flip), while stdout (docker logs) keeps respecting // 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, ", ")) 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 package api
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -180,17 +177,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/selfupdate/update" && req.Method == http.MethodPost: case path == "/selfupdate/update" && req.Method == http.MethodPost:
r.selfupdateTrigger(w, req) r.selfupdateTrigger(w, req)
// POST /api/config/apply — Hub pushes generated YAML to update controller.yaml // v0.322.0 (R-970): /api/config, /api/config/hash and /api/config/apply are GONE. They read the box's
case path == "/config/apply" && req.Method == http.MethodPost: // controller.yaml with every secret in it and replaced it — reachable with the box's own hub key, unsigned,
r.configApply(w, req) // 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
// GET /api/config/hash — return current config file hash // TestR970_ConfigRoutesAreGone and cmd/controller TestR970_HubKeyOpensNoDoor.
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)
// POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount) // POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount)
case path == "/selfrestart" && req.Method == http.MethodPost: 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")}) 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 --- // --- Asset sync handlers ---
func (r *Router) triggerAssetSync(w http.ResponseWriter, req *http.Request) { func (r *Router) triggerAssetSync(w http.ResponseWriter, req *http.Request) {
@@ -1,7 +1,6 @@
package api package api
import ( import (
"bytes"
"io" "io"
"log" "log"
"net/http" "net/http"
@@ -28,61 +27,6 @@ func newRestartTestRouter(t *testing.T, prior []byte) (*Router, *int, string) {
return r, &calls, path 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) { func TestSelfRestart_InvokesRestarter(t *testing.T) {
r, calls, _ := newRestartTestRouter(t, nil) r, calls, _ := newRestartTestRouter(t, nil)
+3 -12
View File
@@ -16,7 +16,6 @@ const csrfHeaderName = "X-CSRF-Token"
// //
// Exempt cases: // Exempt cases:
// - Auth is disabled (no password configured) // - 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 { func (s *Server) CsrfProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Safe methods: no CSRF check needed // Safe methods: no CSRF check needed
@@ -53,17 +52,9 @@ func (s *Server) CsrfProtect(next http.Handler) http.Handler {
return return
} }
// Skip CSRF for Bearer-token authenticated requests. // v0.322.0 (R-970): no Bearer exemption. It existed for the routes that accepted the box's hub API key
// Validate the token against the configured API key before skipping. // (/api/config/, /api/selfupdate/, /api/geo/); none does now, so a request carrying that key is a dashboard
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { // request like any other and needs the session's CSRF token. Pinned by TestR970_HubKeyIsNoCSRFExemption.
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
}
// Get the session's CSRF token // Get the session's CSRF token
cookie, err := r.Cookie(sessionCookieName) cookie, err := r.Cookie(sessionCookieName)
@@ -45,28 +45,10 @@ func renderDeployPage(t *testing.T, alreadyDeployed bool) string {
return buf.String() return buf.String()
} }
// RED-PROOF: drop the `{{if $isDeployed}}` branch so the deployed page renders `value="{{$val}}"` // v0.322.0 (R-1103): TestDeployPage_DeployedApp_DoesNotCarryTheSecret is retired — the install form no longer renders
// again — this fails, showing the secret returning to the body of a page with nothing to submit. // for an installed app (R-1095, 302 to the settings tab). Its rule (an installed app's generated secret is not in the
func TestDeployPage_DeployedApp_DoesNotCarryTheSecret(t *testing.T) { // page; an eye fetches it) is asserted where the value is shown now: TestAppPage_LoginPairPanel (SECRET_KEY,
html := renderDeployPage(t, true) // ADMIN_USER, ADMIN_PASSWORD never in the settings page; every eye answers) and TestR255_NoPlantedSecretReachesAnyPage.
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")
}
}
// The OTHER half of §7.2's answer: the pre-deploy form still carries the value, deliberately. If this // 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 — // 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{} { {"deploy_new_memory_blocked", "deploy", func() map[string]interface{} {
return i18nDeployData(false, stacks.StateNotDeployed, 3) return i18nDeployData(false, stacks.StateNotDeployed, 3)
}}, }},
// v0.319.0: `/apps/{slug}/install` opened for an app that IS installed — the form read-only, its // v0.322.0 (R-1103): `deploy_installed` and `deploy_installed_restored` are retired — the install form no longer
// secrets fetched on demand. The settings tab stopped rendering this form, so these two cases are // renders for an installed app (R-1095). The settings tab's cases cover what an installed app shows.
// 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
}},
{"app_settings_running", "app_settings", func() map[string]interface{} { {"app_settings_running", "app_settings", func() map[string]interface{} {
return i18nDeployData(true, stacks.StateRunning, 1) 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. // 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 // 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". // 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) { func TestAppData_OneBackupSection(t *testing.T) {
if !lfOffDriveOrPBS.MatchString("a teljes rendszermentés/PBS — off-drive") { if !lfOffDriveOrPBS.MatchString("a teljes rendszermentés/PBS — off-drive") {
t.Fatal("control: the matcher does not find the words it looks for") 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 { if n := strings.Count(html, `data-backup-section="1"`); n != 1 {
t.Errorf("%s: %d backup sections, want 1", lang, n) 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"`) || if !strings.Contains(html, `data-backup-row="nightly"`) || !strings.Contains(html, `data-second-backup-form="1"`) {
!strings.Contains(html, `data-second-backup-form="1"`) { t.Errorf("%s: the nightly row or the 2nd-copy form is missing", lang)
t.Errorf("%s: the nightly row, the 2nd-copy row or its 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 != "" { if m := lfOffDriveOrPBS.FindString(html); m != "" {
t.Errorf("%s: off-drive/PBS on the Data tab (%q)", lang, m) t.Errorf("%s: off-drive/PBS on the Data tab (%q)", lang, m)
@@ -1,34 +1,45 @@
package web package web
import ( import (
"net/http"
"path/filepath"
"strings" "strings"
"testing" "testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "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 // 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 renders as before. // for it and says the old password is the one that works; an app without that record shows the eye.
// Rendered through the production template path (renderI18nCase), both languages. //
func TestR694_TheDeployPageDoesNotOfferAGeneratedLogin(t *testing.T) { // v0.322.0 (R-1103): moved from the install form (which no longer renders for an installed app, R-1095) to the settings
s := i18nTestServer(t) // tab, through the production route (s.ServeHTTP) on a real stack manager.
base := func() map[string]interface{} { return i18nDeployData(true, stacks.StateRunning, 1) } // RED-PROOF (2026-10-16, REPORT): with the `{{- if .Restored}}` branch removed from the login panel, this failed at
restored := func() map[string]interface{} { // „the restored login is offered for reveal".
d := base() func TestR694_TheSettingsTabDoesNotOfferAGeneratedLogin(t *testing.T) {
d["DeployedFieldValues"] = map[string]string{"SUBDOMAIN": "paste"} // the handler removed the value s := tuningHarness(t, tuningEnv())
d["RestoredLogins"] = map[string]bool{"ADMIN_PASSWORD": true} dir := filepath.Join(s.cfg.Paths.StacksDir, "calibre-web")
return d 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}) _ = s.stackMgr.ScanStacks()
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ó") { rec := appGet(t, s, "/apps/calibre-web/settings")
t.Fatalf("hu page does not say to use the old password, or still shows a value") if rec.Code != http.StatusOK {
t.Fatalf("GET settings = %d", rec.Code)
} }
en := renderI18nCase(t, s, "en", i18nCase{"r694", "deploy", restored}) body := rec.Body.String()
if !strings.Contains(en, "Restored from a backup: log in with the password that was valid when the backup was taken") { if strings.Contains(body, tuningPassword) {
t.Fatal("en page lacks the sentence") t.Fatal("the restore-generated password is in the page")
} }
plain := renderI18nCase(t, s, "hu", i18nCase{"r694-plain", "deploy", base}) if strings.Contains(body, `data-login-reveal="ADMIN_PASSWORD"`) {
if strings.Contains(plain, "Mentésből töltötted vissza") || !strings.Contains(plain, "Telepítéskor beállított kezdeti jelszó") { t.Fatal("the restored login is offered for reveal")
t.Fatal("an app without restored_logins changed") }
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() return buf.String()
} }
// COMPANION RED-PROOF: put `value="{{index $.DeployedFieldValues .EnvVar}}"` back on the deployed password input // v0.322.0 (R-1103): the installed half of this test is retired — the install form no longer renders for an installed
// → the first assertion fails with the password in the body. // app (R-1095). „The password is not in the page, an eye fetches it" is asserted on the settings tab
func TestR709_AnInstalledAppsPasswordIsNotInThePage(t *testing.T) { // (TestAppPage_LoginPairPanel), and „no eye for a restore-generated login" too (TestR694_TheSettingsTabDoesNotOfferAGeneratedLogin).
html := renderDeployWithPassword(t, true, false) // What stays here is the install form's own rule: it keeps its generator, with the field's spec.
if strings.Contains(html, testAdminPassword) { func TestR709_TheInstallFormKeepsItsGenerator(t *testing.T) {
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.
if html := renderDeployWithPassword(t, false, false); !strings.Contains(html, `generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', 'password:24:special')`) { 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)") 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 {{/* „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 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. */}} 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"> <div class="app-section" id="app-backup-section" data-backup-section="1">
<h3>{{T "deploy.biztonsagi_mentes"}}</h3> <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 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> </div>
{{- if .Tier2}}{{with .Tier2}} {{- if .Tier2}}{{with .Tier2}}
{{- if .IsHDDApp}} {{- /* v0.322.0: the 2nd copy's state is said ONCE — by the form below (checkbox, target, hint). Its rows here
<div class="app-field-row" data-backup-row="second"> repeated the same three facts. */}}
<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}}
<p class="app-field-hint" style="margin-top:12px">{{T "tier2_config.a_2_mentes_egy_masik"}}</p> <p class="app-field-hint" style="margin-top:12px">{{T "tier2_config.a_2_mentes_egy_masik"}}</p>
{{if not .IsHDDApp}} {{if not .IsHDDApp}}
@@ -167,6 +145,9 @@ function appMigrate(btn,app,label){
<option value="{{.Path}}" {{if eq .Path $.Tier2.Preferred}}selected{{end}}>{{.Label}}</option> <option value="{{.Path}}" {{if eq .Path $.Tier2.Preferred}}selected{{end}}>{{.Label}}</option>
{{end}} {{end}}
</select> </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}} {{if not .Alternatives}}
<div style="font-size:.8rem;color:var(--text-3);margin-top:.4rem"> <div style="font-size:.8rem;color:var(--text-3);margin-top:.4rem">
{{T "tier2_config.nincs_masik_adatmeghajto_automatikus_cel"}} {{T "tier2_config.nincs_masik_adatmeghajto_automatikus_cel"}}
@@ -229,7 +210,9 @@ function appMigrate(btn,app,label){
<h3>{{T "app_export.jelszo_opcionalis"}}</h3> <h3>{{T "app_export.jelszo_opcionalis"}}</h3>
<div style="display:flex;gap:.5rem;margin-bottom:1rem"> <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> <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> </div>
@@ -272,7 +255,7 @@ function csrfH() {
} }
function togglePw(btn) { function togglePw(btn) {
var inp = document.getElementById('exportPassword'); var inp = document.getElementById('fabEncryptionKey');
var show = inp.type === 'password'; var show = inp.type === 'password';
inp.type = show ? 'text' : 'password'; inp.type = show ? 'text' : 'password';
if (btn) { btn.textContent = show ? '{{T "app_info.elrejtes"}}' : '{{T "deploy.megjelenites"}}'; } 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() { async function startExport() {
var drive = document.getElementById('destDrive').value; var drive = document.getElementById('destDrive').value;
var password = document.getElementById('exportPassword').value; var password = document.getElementById('fabEncryptionKey').value;
var stopApp = document.getElementById('stopApp').checked; var stopApp = document.getElementById('stopApp').checked;
document.getElementById('startBtn').disabled = true; document.getElementById('startBtn').disabled = true;
@@ -1,55 +1,34 @@
{{/* THE APP'S CONFIGURATION FIELDS — the install wizard's form (deploy.html). {{/* 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 It renders ONLY for an app that is not installed (v0.322.0, R-1103): since v0.321.0 (R-1095)
the login as one panel (app_settings.html, app_login.go). The `.AlreadyDeployed` branches below `/apps/{slug}/install` of an installed app answers 302 to its settings tab, and the settings tab
are NOT dead: `/apps/{slug}/install` still answers for an app that is installed, and renders shows the install answers as rows and the login as one panel (app_settings.html, app_login.go).
this form read-only. Their guards are what keep an installed app's secrets out of that page The old read-only „installed" branches are gone; their secret rules live where the values are
(TestDeployPage_DeployedApp_DoesNotCarryTheSecret, TestR709_AnInstalledAppsPasswordIsNotInThePage) shown now — R-254/R-709 on the settings tab (TestAppPage_LoginPairPanel, TestR255_NoPlantedSecretReachesAnyPage).
and the parity case `deploy_installed` renders them.
The secret rules are unchanged and load-bearing: The PRE-DEPLOY hidden input is deliberate: the form must carry what it submits, so the value
R-254 — an INSTALLED app's `type: secret` value is not in the page; the eye fetches it. saved is the one the household was shown (controller/README §318). */}}
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). */}}
{{define "app_deploy_fields"}} {{define "app_deploy_fields"}}
{{if .AutoFields}} {{if .AutoFields}}
<div class="form-section"> <div class="form-section">
<h4>{{T "deploy.automatikusan_generalt_ertekek"}}</h4> <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> <p class="form-section-desc">{{T "deploy.ezek_az_ertekek_a_telepitessel"}}</p>
{{end}}
{{$autoValues := .AutoFieldValues}} {{$autoValues := .AutoFieldValues}}
{{$stackName := .Stack.Name}}
{{$isDeployed := .AlreadyDeployed}}
{{range .AutoFields}} {{range .AutoFields}}
{{$val := index $autoValues .EnvVar}} {{$val := index $autoValues .EnvVar}}
<div class="form-group form-group-auto"> <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> <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 $val}}
{{if eq .Type "secret"}} {{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"> <div class="input-with-button">
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly> <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> <button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-{{.EnvVar}}', this)">{{T "deploy.megjelenites"}}</button>
</div> </div>
{{end}}
{{else}} {{else}}
<input type="text" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly> <input type="text" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly>
{{end}} {{end}}
{{if and (not $isDeployed) (eq .Type "secret")}} {{if eq .Type "secret"}}
<input type="hidden" name="{{.EnvVar}}" value="{{$val}}"> <input type="hidden" name="{{.EnvVar}}" value="{{$val}}">
{{end}} {{end}}
{{end}} {{end}}
@@ -62,10 +41,6 @@
<div class="form-section"> <div class="form-section">
<h4>{{T "deploy.beallitasok"}}</h4> <h4>{{T "deploy.beallitasok"}}</h4>
{{range .UserFields}} {{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"> <div class="form-group">
<label for="field-{{.EnvVar}}"> <label for="field-{{.EnvVar}}">
{{.Label}} {{.Label}}
@@ -79,35 +54,22 @@
class="form-control subdomain-input" class="form-control subdomain-input"
{{/* R-351: a reinstall offers the address the customer's own backup recorded, {{/* R-351: a reinstall offers the address the customer's own backup recorded,
ahead of the catalog default. Editable — a domain can legitimately change. */}} 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"}}" placeholder="{{T "deploy.aldomain_placeholder"}}"
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
required required
{{if $.AlreadyDeployed}}disabled{{end}}
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')"> oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')">
<span class="subdomain-suffix">.{{$.Domain}}</span> <span class="subdomain-suffix">.{{$.Domain}}</span>
</div> </div>
{{if not $.AlreadyDeployed}}
<span class="form-hint">{{T "deploy.az_aldomain_telepites_utan_nem"}}</span> <span class="form-hint">{{T "deploy.az_aldomain_telepites_utan_nem"}}</span>
{{end}}
{{else if eq .Type "select"}} {{else if eq .Type "select"}}
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control" <select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control">
{{if $.AlreadyDeployed}}disabled{{end}}>
{{range .Options}} {{range .Options}}
<option value="{{.Value}}">{{.Label}}</option> <option value="{{.Value}}">{{.Label}}</option>
{{end}} {{end}}
</select> </select>
{{else if eq .Type "password"}} {{else if eq .Type "password"}}
<div class="input-with-button"> <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}}" <input type="password" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}" class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}" placeholder="{{.Placeholder}}"
@@ -118,27 +80,20 @@
title="{{T "deploy.megjelenites"}}">&#128065;</button> title="{{T "deploy.megjelenites"}}">&#128065;</button>
<button type="button" class="btn btn-sm btn-outline" <button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-{{.EnvVar}}', 'field-confirm-{{.EnvVar}}', '{{.Generate}}')">{{T "deploy.generalas"}}</button> onclick="generatePassword('field-{{.EnvVar}}', 'field-confirm-{{.EnvVar}}', '{{.Generate}}')">{{T "deploy.generalas"}}</button>
{{- end}}
</div> </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"> <div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-{{.EnvVar}}" <input type="password" id="field-confirm-{{.EnvVar}}"
class="form-control" placeholder="{{T "deploy.jelszo_megerositese"}}" class="form-control" placeholder="{{T "deploy.jelszo_megerositese"}}"
data-confirm-for="field-{{.EnvVar}}"> data-confirm-for="field-{{.EnvVar}}">
</div> </div>
{{end}}
{{else if eq .Type "boolean"}} {{else if eq .Type "boolean"}}
<label class="toggle"> <label class="toggle">
<input type="checkbox" id="field-{{.EnvVar}}" name="{{.EnvVar}}" value="true" <input type="checkbox" id="field-{{.EnvVar}}" name="{{.EnvVar}}" value="true">
{{if $.AlreadyDeployed}}disabled{{end}}>
<span class="toggle-label">{{T "deploy.igen"}}</span> <span class="toggle-label">{{T "deploy.igen"}}</span>
</label> </label>
{{else if eq .Type "path"}} {{else if eq .Type "path"}}
{{if $.StoragePaths}} {{if $.StoragePaths}}
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control" <select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
{{if $.AlreadyDeployed}}disabled{{end}}
onchange="checkStorageSpace(this)"> onchange="checkStorageSpace(this)">
{{range $.StoragePaths}} {{range $.StoragePaths}}
<option value="{{.Path}}" data-free-percent="{{printf "%.0f" .FreePercent}}" <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 configured default — it is where this app's data actually lived, and
restoring into a different drive is the mismatch the restore then has restoring into a different drive is the mismatch the restore then has
to stop and name. The customer can still pick another. */}} 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}} {{T "deploy.szabad_2"}}{{if .NotAllowed}} ({{.NotAllowedNote}}){{else if .IsDefault}} {{T "deploy.alapertelmezett"}}{{end}}
</option> </option>
{{end}} {{end}}
{{if $.CurrentHDDPathMissing}}
<option value="{{$.CurrentHDDPath}}" selected disabled>{{T "deploy.nem_elerheto"}}</option>
{{end}}
</select> </select>
<div id="storage-space-warn" class="form-hint" style="color:var(--warn);display:none"> <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"}} <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}}" <input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}" class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}" placeholder="{{.Placeholder}}"
{{if .Required}}required{{end}} {{if .Required}}required{{end}}>
{{if $.AlreadyDeployed}}disabled{{end}}>
<span class="form-hint" style="color:var(--warn)">{{T "deploy.nincs_regisztralt_adattarolo_adja_meg"}}</span> <span class="form-hint" style="color:var(--warn)">{{T "deploy.nincs_regisztralt_adattarolo_adja_meg"}}</span>
{{end}} {{end}}
{{else}} {{else}}
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}" <input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}" class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}" placeholder="{{.Placeholder}}"
{{if .Required}}required{{end}} {{if .Required}}required{{end}}>
{{if $.AlreadyDeployed}}disabled{{end}}>
{{end}} {{end}}
{{if and .Description (ne .Type "subdomain")}} {{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-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 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>
<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> <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> </select>
<div style="font-size:.8rem;color:var(--text-3);margin-top:.4rem"> <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ő. 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> </div>
@@ -392,7 +381,8 @@ function appMigrate(btn,app,label){
<h3>Jelszó (opcionális)</h3> <h3>Jelszó (opcionális)</h3>
<div style="display:flex;gap:.5rem;margin-bottom:1rem"> <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> <button class="btn btn-sm btn-outline" onclick="togglePw(this)" type="button" id="exportPwToggle" aria-label="Jelszó mutatása">Megjelenítés</button>
</div> </div>
@@ -435,7 +425,7 @@ function csrfH() {
} }
function togglePw(btn) { function togglePw(btn) {
var inp = document.getElementById('exportPassword'); var inp = document.getElementById('fabEncryptionKey');
var show = inp.type === 'password'; var show = inp.type === 'password';
inp.type = show ? 'text' : 'password'; inp.type = show ? 'text' : 'password';
if (btn) { btn.textContent = show ? 'Elrejtés' : 'Megjelenítés'; } 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() { async function startExport() {
var drive = document.getElementById('destDrive').value; var drive = document.getElementById('destDrive').value;
var password = document.getElementById('exportPassword').value; var password = document.getElementById('fabEncryptionKey').value;
var stopApp = document.getElementById('stopApp').checked; var stopApp = document.getElementById('stopApp').checked;
document.getElementById('startBtn').disabled = true; document.getElementById('startBtn').disabled = true;
@@ -287,7 +287,8 @@
<h3>Jelszó (opcionális)</h3> <h3>Jelszó (opcionális)</h3>
<div style="display:flex;gap:.5rem;margin-bottom:1rem"> <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> <button class="btn btn-sm btn-outline" onclick="togglePw(this)" type="button" id="exportPwToggle" aria-label="Jelszó mutatása">Megjelenítés</button>
</div> </div>
@@ -330,7 +331,7 @@ function csrfH() {
} }
function togglePw(btn) { function togglePw(btn) {
var inp = document.getElementById('exportPassword'); var inp = document.getElementById('fabEncryptionKey');
var show = inp.type === 'password'; var show = inp.type === 'password';
inp.type = show ? 'text' : 'password'; inp.type = show ? 'text' : 'password';
if (btn) { btn.textContent = show ? 'Elrejtés' : 'Megjelenítés'; } 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() { async function startExport() {
var drive = document.getElementById('destDrive').value; var drive = document.getElementById('destDrive').value;
var password = document.getElementById('exportPassword').value; var password = document.getElementById('fabEncryptionKey').value;
var stopApp = document.getElementById('stopApp').checked; var stopApp = document.getElementById('stopApp').checked;
document.getElementById('startBtn').disabled = true; document.getElementById('startBtn').disabled = true;
@@ -233,18 +233,6 @@
<div class="app-field-label">Éjszakai mentés</div> <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 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>
<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> <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> </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"> <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ő. 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> </div>
@@ -233,18 +233,6 @@
<div class="app-field-label">Éjszakai mentés</div> <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 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>
<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> <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> </select>
<div style="font-size:.8rem;color:var(--text-3);margin-top:.4rem"> <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ő. 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> </div>
@@ -233,18 +233,6 @@
<div class="app-field-label">Éjszakai mentés</div> <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 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>
<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> <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> </select>
</div> </div>
<button type="submit" class="btn btn-primary">Mentés</button> <button type="submit" class="btn btn-primary">Mentés</button>
</form> </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"> <div class="form-section">
<h4>Automatikusan generált értékek</h4> <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> <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"> <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> <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"> <div class="input-with-button">
<input type="password" id="auto-field-SECRET_KEY" class="form-control" value="generated-secret-value" readonly> <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> <button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-SECRET_KEY', this)">Megjelenítés</button>
</div> </div>
<input type="hidden" name="SECRET_KEY" value="generated-secret-value"> <input type="hidden" name="SECRET_KEY" value="generated-secret-value">
@@ -300,8 +294,6 @@
<div class="form-section"> <div class="form-section">
<h4>Beállítások</h4> <h4>Beállítások</h4>
<div class="form-group"> <div class="form-group">
<label for="field-SUBDOMAIN"> <label for="field-SUBDOMAIN">
Webcím eleje Webcím eleje
@@ -318,20 +310,15 @@
placeholder="aldomain" placeholder="aldomain"
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
required required
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')"> oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')">
<span class="subdomain-suffix">.example.hu</span> <span class="subdomain-suffix">.example.hu</span>
</div> </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> <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>
<div class="form-group"> <div class="form-group">
<label for="field-LANG_SEL"> <label for="field-LANG_SEL">
Felület nyelve Felület nyelve
@@ -340,8 +327,7 @@
</label> </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> <option value="hu">Magyar nyelv</option>
@@ -351,8 +337,6 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="field-ADMIN_PASSWORD"> <label for="field-ADMIN_PASSWORD">
Admin jelszó mező Admin jelszó mező
@@ -373,20 +357,16 @@
<button type="button" class="btn btn-sm btn-outline" <button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás</button> onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás</button>
</div> </div>
<div class="input-with-button" style="margin-top:.25rem"> <div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-ADMIN_PASSWORD" <input type="password" id="field-confirm-ADMIN_PASSWORD"
class="form-control" placeholder="Jelszó megerősítése" class="form-control" placeholder="Jelszó megerősítése"
data-confirm-for="field-ADMIN_PASSWORD"> data-confirm-for="field-ADMIN_PASSWORD">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="field-FLAG"> <label for="field-FLAG">
Kapcsoló mező itt Kapcsoló mező itt
@@ -396,8 +376,7 @@
<label class="toggle"> <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> <span class="toggle-label">Igen</span>
</label> </label>
@@ -407,8 +386,6 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="field-HDD_PATH"> <label for="field-HDD_PATH">
Adatok helye itt Adatok helye itt
@@ -419,7 +396,6 @@
<select id="field-HDD_PATH" name="HDD_PATH" class="form-control" <select id="field-HDD_PATH" name="HDD_PATH" class="form-control"
onchange="checkStorageSpace(this)"> onchange="checkStorageSpace(this)">
<option value="/mnt/hdd1" data-free-percent="50" <option value="/mnt/hdd1" data-free-percent="50"
@@ -443,7 +419,6 @@
HDD kettő — 100 GB szabad HDD kettő — 100 GB szabad
</option> </option>
</select> </select>
<div id="storage-space-warn" class="form-hint" style="color:var(--warn);display:none"> <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. <svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A kiválasztott tárhely majdnem megtelt.
@@ -458,8 +433,6 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="field-NOTE"> <label for="field-NOTE">
Megjegyzés mező Megjegyzés mező
@@ -471,7 +444,6 @@
<input type="text" id="field-NOTE" name="NOTE" <input type="text" id="field-NOTE" name="NOTE"
class="form-control" value="" class="form-control" value=""
placeholder="Írj ide valamit" placeholder="Írj ide valamit"
> >
@@ -233,26 +233,20 @@
<div class="form-section"> <div class="form-section">
<h4>Automatikusan generált értékek</h4> <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> <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"> <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> <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"> <div class="input-with-button">
<input type="password" id="auto-field-SECRET_KEY" class="form-control" value="generated-secret-value" readonly> <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> <button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-SECRET_KEY', this)">Megjelenítés</button>
</div> </div>
<input type="hidden" name="SECRET_KEY" value="generated-secret-value"> <input type="hidden" name="SECRET_KEY" value="generated-secret-value">
@@ -282,8 +276,6 @@
<div class="form-section"> <div class="form-section">
<h4>Beállítások</h4> <h4>Beállítások</h4>
<div class="form-group"> <div class="form-group">
<label for="field-SUBDOMAIN"> <label for="field-SUBDOMAIN">
Webcím eleje Webcím eleje
@@ -300,20 +292,15 @@
placeholder="aldomain" placeholder="aldomain"
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
required required
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')"> oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')">
<span class="subdomain-suffix">.example.hu</span> <span class="subdomain-suffix">.example.hu</span>
</div> </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> <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>
<div class="form-group"> <div class="form-group">
<label for="field-LANG_SEL"> <label for="field-LANG_SEL">
Felület nyelve Felület nyelve
@@ -322,8 +309,7 @@
</label> </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> <option value="hu">Magyar nyelv</option>
@@ -333,8 +319,6 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="field-ADMIN_PASSWORD"> <label for="field-ADMIN_PASSWORD">
Admin jelszó mező Admin jelszó mező
@@ -355,20 +339,16 @@
<button type="button" class="btn btn-sm btn-outline" <button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás</button> onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás</button>
</div> </div>
<div class="input-with-button" style="margin-top:.25rem"> <div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-ADMIN_PASSWORD" <input type="password" id="field-confirm-ADMIN_PASSWORD"
class="form-control" placeholder="Jelszó megerősítése" class="form-control" placeholder="Jelszó megerősítése"
data-confirm-for="field-ADMIN_PASSWORD"> data-confirm-for="field-ADMIN_PASSWORD">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="field-FLAG"> <label for="field-FLAG">
Kapcsoló mező itt Kapcsoló mező itt
@@ -378,8 +358,7 @@
<label class="toggle"> <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> <span class="toggle-label">Igen</span>
</label> </label>
@@ -389,8 +368,6 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="field-HDD_PATH"> <label for="field-HDD_PATH">
Adatok helye itt Adatok helye itt
@@ -403,8 +380,7 @@
<input type="text" id="field-HDD_PATH" name="HDD_PATH" <input type="text" id="field-HDD_PATH" name="HDD_PATH"
class="form-control" value="" class="form-control" value=""
placeholder="/mnt/valahol" 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> <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>
<div class="form-group"> <div class="form-group">
<label for="field-NOTE"> <label for="field-NOTE">
Megjegyzés mező Megjegyzés mező
@@ -425,7 +399,6 @@
<input type="text" id="field-NOTE" name="NOTE" <input type="text" id="field-NOTE" name="NOTE"
class="form-control" value="" class="form-control" value=""
placeholder="Írj ide valamit" placeholder="Írj ide valamit"
> >
@@ -228,26 +228,20 @@
<div class="form-section"> <div class="form-section">
<h4>Automatikusan generált értékek</h4> <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> <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"> <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> <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"> <div class="input-with-button">
<input type="password" id="auto-field-SECRET_KEY" class="form-control" value="generated-secret-value" readonly> <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> <button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-SECRET_KEY', this)">Megjelenítés</button>
</div> </div>
<input type="hidden" name="SECRET_KEY" value="generated-secret-value"> <input type="hidden" name="SECRET_KEY" value="generated-secret-value">
@@ -277,8 +271,6 @@
<div class="form-section"> <div class="form-section">
<h4>Beállítások</h4> <h4>Beállítások</h4>
<div class="form-group"> <div class="form-group">
<label for="field-SUBDOMAIN"> <label for="field-SUBDOMAIN">
Webcím eleje Webcím eleje
@@ -295,20 +287,15 @@
placeholder="aldomain" placeholder="aldomain"
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
required required
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')"> oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')">
<span class="subdomain-suffix">.example.hu</span> <span class="subdomain-suffix">.example.hu</span>
</div> </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> <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>
<div class="form-group"> <div class="form-group">
<label for="field-LANG_SEL"> <label for="field-LANG_SEL">
Felület nyelve Felület nyelve
@@ -317,8 +304,7 @@
</label> </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> <option value="hu">Magyar nyelv</option>
@@ -328,8 +314,6 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="field-ADMIN_PASSWORD"> <label for="field-ADMIN_PASSWORD">
Admin jelszó mező Admin jelszó mező
@@ -350,20 +334,16 @@
<button type="button" class="btn btn-sm btn-outline" <button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás</button> onclick="generatePassword('field-ADMIN_PASSWORD', 'field-confirm-ADMIN_PASSWORD', '')">Generálás</button>
</div> </div>
<div class="input-with-button" style="margin-top:.25rem"> <div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-ADMIN_PASSWORD" <input type="password" id="field-confirm-ADMIN_PASSWORD"
class="form-control" placeholder="Jelszó megerősítése" class="form-control" placeholder="Jelszó megerősítése"
data-confirm-for="field-ADMIN_PASSWORD"> data-confirm-for="field-ADMIN_PASSWORD">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="field-FLAG"> <label for="field-FLAG">
Kapcsoló mező itt Kapcsoló mező itt
@@ -373,8 +353,7 @@
<label class="toggle"> <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> <span class="toggle-label">Igen</span>
</label> </label>
@@ -384,8 +363,6 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="field-HDD_PATH"> <label for="field-HDD_PATH">
Adatok helye itt Adatok helye itt
@@ -396,7 +373,6 @@
<select id="field-HDD_PATH" name="HDD_PATH" class="form-control" <select id="field-HDD_PATH" name="HDD_PATH" class="form-control"
onchange="checkStorageSpace(this)"> onchange="checkStorageSpace(this)">
<option value="/mnt/hdd1" data-free-percent="50" <option value="/mnt/hdd1" data-free-percent="50"
@@ -420,9 +396,6 @@
HDD kettő — 100 GB szabad HDD kettő — 100 GB szabad
</option> </option>
<option value="/mnt/gone" selected disabled>/mnt/gone (nem elérhető)</option>
</select> </select>
<div id="storage-space-warn" class="form-hint" style="color:var(--warn);display:none"> <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. <svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A kiválasztott tárhely majdnem megtelt.
@@ -437,8 +410,6 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="field-NOTE"> <label for="field-NOTE">
Megjegyzés mező Megjegyzés mező
@@ -450,7 +421,6 @@
<input type="text" id="field-NOTE" name="NOTE" <input type="text" id="field-NOTE" name="NOTE"
class="form-control" value="" class="form-control" value=""
placeholder="Írj ide valamit" placeholder="Írj ide valamit"
> >