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
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
}},
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}}">👁</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"}}">👁</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;
|
||||
|
||||
+3
-12
@@ -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
-1197
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"
|
||||
|
||||
>
|
||||
|
||||
|
||||
|
||||
+2
-32
@@ -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"
|
||||
|
||||
>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user