v0.23.0 — CSRF protection on all browser-facing POST endpoints
Controller: - internal/web/csrf.go (new): CsrfProtect middleware, csrfToken/csrfField helpers - auth.go: per-session CSRF token (csrfToken field, csrfTokenForSession method) - server.go: executeTemplate wrapper auto-injects CSRFField+CSRFToken - main.go: wire CsrfProtect on all routes; bump to v0.23.0 - handlers.go, storage_handlers.go, handler_restore.go: executeTemplate - All templates: CSRFField in forms, meta csrf-token, csrfHeaders() JS helper, fetch calls updated; sendBeacon→fetch+keepalive in storage_attach.html Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+28
-3
@@ -757,6 +757,30 @@ self_update:
|
||||
- Session cleanup every 15 minutes
|
||||
- All sessions invalidated on password change
|
||||
- Conditional logout link (hidden when auth is disabled)
|
||||
- Each session stores a dedicated CSRF token (separate 32-byte random value) alongside the session token
|
||||
|
||||
#### CSRF Protection (`internal/web/csrf.go`)
|
||||
|
||||
Synchronizer-token CSRF protection on all browser-facing state-mutating endpoints.
|
||||
|
||||
**How it works:**
|
||||
- `CsrfProtect` middleware wraps all route handlers in `main.go`
|
||||
- Safe methods (GET, HEAD, OPTIONS) pass through without validation
|
||||
- For POST/DELETE/PATCH: reads token from `_csrf` form field or `X-CSRF-Token` request header; constant-time compares against the session's stored CSRF token
|
||||
- On rejection: JSON `{"ok":false,"error":"CSRF token missing or invalid"}` for `/api/` paths; HTTP 403 text page for UI routes
|
||||
- Logs: `[WARN] CSRF rejected: METHOD /path from addr (reason)`
|
||||
|
||||
**Exempt paths (no CSRF check):**
|
||||
- Requests with `Authorization: Bearer ...` header — hub→controller API calls (selfupdate, config/apply). Browsers cannot auto-send Bearer headers, so cross-site requests are impossible on these endpoints.
|
||||
- Auth-disabled mode (`authEnabled() == false`) — CSRF is meaningless when there is no session.
|
||||
|
||||
**Token delivery to templates:**
|
||||
- `executeTemplate(w, r, name, data)` wrapper in `server.go` auto-injects `CSRFField` (`template.HTML` hidden `<input>`) and `CSRFToken` (raw string) into every page's data map
|
||||
- `layout.html` emits `<meta name="csrf-token" content="{{.CSRFToken}}">` and defines `csrfHeaders()` JS function in `<head>` (before page scripts)
|
||||
- Forms: `{{.CSRFField}}` (or `{{$.CSRFField}}` inside `{{range}}` loops — outer scope required)
|
||||
- JS `fetch()` calls: `headers: csrfHeaders()` — returns `{'X-CSRF-Token': metaContent}`
|
||||
- Dynamically-created JS forms: read token from `document.querySelector('meta[name="csrf-token"]').content`
|
||||
- `navigator.sendBeacon()` replaced with `fetch(..., {keepalive: true})` where used — `sendBeacon` cannot send custom headers
|
||||
|
||||
#### Settings Persistence (`internal/settings/settings.go`)
|
||||
|
||||
@@ -1033,8 +1057,9 @@ controller/
|
||||
│ │ └── templates/ # 7 wizard HTML templates (Hungarian)
|
||||
│ ├── recovery/info.go # Recovery info file generator (recovery-info.txt)
|
||||
│ └── web/
|
||||
│ ├── server.go # HTTP server, routing, static files
|
||||
│ ├── auth.go # Session auth, login/logout, session cleanup
|
||||
│ ├── server.go # HTTP server, routing, static files, executeTemplate wrapper
|
||||
│ ├── auth.go # Session auth + per-session CSRF token, login/logout, session cleanup
|
||||
│ ├── csrf.go # CsrfProtect middleware, csrfToken/csrfField helpers
|
||||
│ ├── handlers.go # Page handlers (dashboard, stacks, deploy, backups, etc.)
|
||||
│ ├── handler_restore.go # DR: restore page handler + APIs (scan, restore all, skip)
|
||||
│ ├── storage_handlers.go # Storage API handlers (scan, format, attach, migrate, cleanup, disconnect/reconnect)
|
||||
@@ -1329,7 +1354,7 @@ See `docker-compose.yml` for the full volume configuration.
|
||||
- [ ] Update classification and auto-apply (optional/required/security markers)
|
||||
- [ ] Docker volume backup (`/var/lib/docker/volumes:ro`)
|
||||
- [ ] Raspberry Pi testing (pi-customer-1)
|
||||
- [ ] CSRF protection on POST endpoints
|
||||
- [x] CSRF protection on POST endpoints (v0.23.0)
|
||||
- [ ] Login rate limiting
|
||||
|
||||
---
|
||||
|
||||
@@ -640,15 +640,16 @@ func main() {
|
||||
// API routes (no auth for health endpoint, auth for everything else)
|
||||
mux.HandleFunc("/api/health", apiRouter.HealthHandler)
|
||||
// Storage API routes handled by web server (longer prefix takes precedence over /api/)
|
||||
mux.Handle("/api/storage/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeStorageAPI)))
|
||||
mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI))))
|
||||
// Self-update API — accepts session auth OR hub API key (for external triggering)
|
||||
mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, http.HandlerFunc(apiRouter.ServeHTTP)))
|
||||
// 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, http.HandlerFunc(apiRouter.ServeHTTP)))
|
||||
mux.Handle("/api/", webServer.RequireAuth(http.HandlerFunc(apiRouter.ServeHTTP)))
|
||||
mux.Handle("/api/config/", 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(http.HandlerFunc(webServer.ServeHTTP)))
|
||||
mux.Handle("/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeHTTP))))
|
||||
|
||||
// --- Start HTTP server ---
|
||||
server := &http.Server{
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
type session struct {
|
||||
expiresAt time.Time
|
||||
csrfToken string
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -141,13 +142,32 @@ func (s *Server) createSession() string {
|
||||
_, _ = rand.Read(b)
|
||||
token := hex.EncodeToString(b)
|
||||
|
||||
csrfB := make([]byte, 32)
|
||||
_, _ = rand.Read(csrfB)
|
||||
csrfToken := hex.EncodeToString(csrfB)
|
||||
|
||||
s.sessionsMu.Lock()
|
||||
s.sessions[token] = &session{expiresAt: time.Now().Add(sessionMaxAge)}
|
||||
s.sessions[token] = &session{
|
||||
expiresAt: time.Now().Add(sessionMaxAge),
|
||||
csrfToken: csrfToken,
|
||||
}
|
||||
s.sessionsMu.Unlock()
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// csrfTokenForSession returns the CSRF token for the given session cookie value.
|
||||
// Returns "" if the session is invalid or expired.
|
||||
func (s *Server) csrfTokenForSession(sessionToken string) string {
|
||||
s.sessionsMu.RLock()
|
||||
defer s.sessionsMu.RUnlock()
|
||||
sess, ok := s.sessions[sessionToken]
|
||||
if !ok || time.Now().After(sess.expiresAt) {
|
||||
return ""
|
||||
}
|
||||
return sess.csrfToken
|
||||
}
|
||||
|
||||
func (s *Server) isValidSession(token string) bool {
|
||||
s.sessionsMu.RLock()
|
||||
defer s.sessionsMu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const csrfFormField = "_csrf"
|
||||
const csrfHeaderName = "X-CSRF-Token"
|
||||
|
||||
// CsrfProtect validates CSRF tokens on unsafe HTTP methods (POST, PUT, DELETE, PATCH).
|
||||
// Safe methods (GET, HEAD, OPTIONS) pass through unchanged.
|
||||
//
|
||||
// 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
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip CSRF if auth is disabled (no password set = open access)
|
||||
if !s.authEnabled() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip CSRF for Bearer-token authenticated requests.
|
||||
// These endpoints also accept session auth, but when a Bearer token
|
||||
// is present, the request is from a script/hub, not a browser.
|
||||
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the session's CSRF token
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
s.csrfReject(w, r, "no session cookie")
|
||||
return
|
||||
}
|
||||
expected := s.csrfTokenForSession(cookie.Value)
|
||||
if expected == "" {
|
||||
s.csrfReject(w, r, "invalid or expired session")
|
||||
return
|
||||
}
|
||||
|
||||
// Check form field first, then header (for fetch/AJAX calls)
|
||||
submitted := r.FormValue(csrfFormField)
|
||||
if submitted == "" {
|
||||
submitted = r.Header.Get(csrfHeaderName)
|
||||
}
|
||||
|
||||
if submitted == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(expected)) != 1 {
|
||||
s.csrfReject(w, r, "token mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// csrfReject sends a 403 response. Returns JSON for /api/ paths, plain text otherwise.
|
||||
func (s *Server) csrfReject(w http.ResponseWriter, r *http.Request, reason string) {
|
||||
s.logger.Printf("[WARN] CSRF rejected: %s %s from %s (%s)", r.Method, r.URL.Path, r.RemoteAddr, reason)
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprint(w, `{"ok":false,"error":"CSRF token missing or invalid"}`)
|
||||
return
|
||||
}
|
||||
http.Error(w, "CSRF token missing or invalid. Please reload the page and try again.", http.StatusForbidden)
|
||||
}
|
||||
|
||||
// csrfToken returns the CSRF token for the current request's session.
|
||||
func (s *Server) csrfToken(r *http.Request) string {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return s.csrfTokenForSession(cookie.Value)
|
||||
}
|
||||
|
||||
// csrfField returns an HTML hidden input for embedding in forms.
|
||||
func (s *Server) csrfField(r *http.Request) template.HTML {
|
||||
token := s.csrfToken(r)
|
||||
return template.HTML(`<input type="hidden" name="` + csrfFormField + `" value="` + template.HTMLEscapeString(token) + `">`)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"PlanStatus": status,
|
||||
}
|
||||
|
||||
s.render(w, "restore", data)
|
||||
s.executeTemplate(w, r, "restore", data)
|
||||
}
|
||||
|
||||
// apiRestoreStatus returns the current restore plan status as JSON.
|
||||
|
||||
@@ -174,7 +174,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
|
||||
}
|
||||
|
||||
s.render(w, "dashboard", data)
|
||||
s.executeTemplate(w, r, "dashboard", data)
|
||||
}
|
||||
|
||||
func (s *Server) stacksHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -201,7 +201,7 @@ func (s *Server) stacksHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
data["StorageLabels"] = storageLabels
|
||||
|
||||
s.render(w, "stacks", data)
|
||||
s.executeTemplate(w, r, "stacks", data)
|
||||
}
|
||||
|
||||
func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
@@ -226,7 +226,7 @@ func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string
|
||||
data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
|
||||
data["Stack"] = stack
|
||||
data["Logs"] = logs
|
||||
s.render(w, "logs", data)
|
||||
s.executeTemplate(w, r, "logs", data)
|
||||
}
|
||||
|
||||
func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
@@ -370,7 +370,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
data["FlashError"] = flashErr
|
||||
}
|
||||
|
||||
s.render(w, "deploy", data)
|
||||
s.executeTemplate(w, r, "deploy", data)
|
||||
}
|
||||
|
||||
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
||||
@@ -403,7 +403,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
||||
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
|
||||
|
||||
s.render(w, "app_info", data)
|
||||
s.executeTemplate(w, r, "app_info", data)
|
||||
}
|
||||
|
||||
func (s *Server) monitoringHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -453,7 +453,7 @@ func (s *Server) monitoringHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
data["AllPingsConfigured"] = allConfigured
|
||||
}
|
||||
|
||||
s.render(w, "monitoring", data)
|
||||
s.executeTemplate(w, r, "monitoring", data)
|
||||
}
|
||||
|
||||
// isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder.
|
||||
@@ -638,7 +638,7 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data["Backup"] = nil
|
||||
}
|
||||
|
||||
s.render(w, "backups", data)
|
||||
s.executeTemplate(w, r, "backups", data)
|
||||
}
|
||||
|
||||
// Tier2DriveGroup holds grouped Tier 2 cross-drive backup items for one destination drive.
|
||||
@@ -1017,7 +1017,7 @@ func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if msg := r.URL.Query().Get("storage_msg"); msg == "success" {
|
||||
data["StorageSuccess"] = r.URL.Query().Get("storage_detail")
|
||||
}
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
}
|
||||
|
||||
func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1032,21 +1032,21 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
|
||||
effectiveHash := s.effectivePasswordHash()
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
|
||||
data["PasswordError"] = "Hibás jelenlegi jelszó"
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate new password length
|
||||
if len(newPassword) < 8 {
|
||||
data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate passwords match
|
||||
if newPassword != confirmPassword {
|
||||
data["PasswordError"] = "A két jelszó nem egyezik"
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1055,7 +1055,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to hash new password: %v", err)
|
||||
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1063,7 +1063,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
|
||||
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to save password to settings.json: %v", err)
|
||||
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1126,7 +1126,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
|
||||
s.logger.Printf("[ERROR] Failed to save notification prefs: %v", err)
|
||||
data := s.settingsData()
|
||||
data["NotificationError"] = "Hiba a beállítások mentésekor"
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1144,7 +1144,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
|
||||
} else {
|
||||
data["NotificationSuccess"] = "Értesítési beállítások mentve."
|
||||
}
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
}
|
||||
|
||||
func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1152,7 +1152,7 @@ func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http
|
||||
|
||||
if s.notifier == nil {
|
||||
data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1160,12 +1160,12 @@ func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Test notification failed: %v", err)
|
||||
data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err)
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
data["NotificationSuccess"] = "Teszt email elküldve."
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
}
|
||||
|
||||
// --- Storage path management handlers ---
|
||||
@@ -1279,21 +1279,21 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil || !fi.IsDir() {
|
||||
data["StorageError"] = "Az útvonal nem létezik vagy nem mappa."
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Is mount point
|
||||
if !system.IsMountPoint(path) {
|
||||
data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!"
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Writable
|
||||
if !system.IsWritable(path) {
|
||||
data["StorageError"] = "Az útvonal nem írható."
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1301,7 +1301,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
|
||||
for _, existing := range s.settings.GetStoragePaths() {
|
||||
if system.PathsOverlap(path, existing.Path) {
|
||||
data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path)
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1322,7 +1322,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
|
||||
if err := s.settings.AddStoragePath(sp); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to add storage path: %v", err)
|
||||
data["StorageError"] = "Hiba a mentés során."
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1341,7 +1341,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
|
||||
apps := s.appsUsingPath(path)
|
||||
if len(apps) > 0 {
|
||||
data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", "))
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1349,7 +1349,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Path == path && sp.IsDefault {
|
||||
data["StorageError"] = "Az alapértelmezett adattároló nem törölhető."
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1357,13 +1357,13 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
|
||||
// Check: last path
|
||||
if len(s.settings.GetStoragePaths()) <= 1 {
|
||||
data["StorageError"] = "Az utolsó adattároló nem törölhető."
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.settings.RemoveStoragePath(path); err != nil {
|
||||
data["StorageError"] = "Hiba a törlés során."
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1406,7 +1406,7 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
|
||||
if label == "" || len(label) > 50 {
|
||||
data := s.settingsData()
|
||||
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1414,7 +1414,7 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
|
||||
s.logger.Printf("[ERROR] Failed to set storage label: %v", err)
|
||||
data := s.settingsData()
|
||||
data["StorageError"] = "Hiba a megnevezés mentésekor."
|
||||
s.render(w, "settings", data)
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,21 @@ func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// executeTemplate renders a template with CSRF data auto-injected into the data map.
|
||||
// Use this instead of render() for all authenticated page handlers.
|
||||
func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
|
||||
if data == nil {
|
||||
data = make(map[string]interface{})
|
||||
}
|
||||
data["CSRFField"] = s.csrfField(r)
|
||||
data["CSRFToken"] = s.csrfToken(r)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Static file / asset serving ---
|
||||
|
||||
func (s *Server) serveCSSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -144,7 +144,7 @@ func (s *Server) currentDiskJob() *activeDiskJob {
|
||||
// storageInitHandler serves the storage init wizard page.
|
||||
func (s *Server) storageInitHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.baseData("settings", "Meghajtó inicializálása")
|
||||
s.render(w, "storage_init", data)
|
||||
s.executeTemplate(w, r, "storage_init", data)
|
||||
}
|
||||
|
||||
// storageAPIHandler is the main handler for /api/storage/* routes.
|
||||
@@ -415,7 +415,7 @@ func (s *Server) migratePageHandler(w http.ResponseWriter, r *http.Request, stac
|
||||
data["CurrentLabel"] = currentLabel
|
||||
data["OtherPaths"] = otherPaths
|
||||
data["DataSizeHuman"] = totalSizeHuman
|
||||
s.render(w, "migrate", data)
|
||||
s.executeTemplate(w, r, "migrate", data)
|
||||
}
|
||||
|
||||
// storageMigrateAPIHandler handles POST /api/storage/migrate — starts migration job.
|
||||
@@ -892,7 +892,7 @@ func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request)
|
||||
// storageAttachHandler serves the attach wizard page.
|
||||
func (s *Server) storageAttachHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.baseData("settings", "Meglévő meghajtó csatolása")
|
||||
s.render(w, "storage_attach", data)
|
||||
s.executeTemplate(w, r, "storage_attach", data)
|
||||
}
|
||||
|
||||
// storageAttachMountRawHandler handles POST /api/storage/attach/mount-raw.
|
||||
@@ -1362,7 +1362,7 @@ func (s *Server) migrateDrivePageHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
data["Tier2Impact"] = tier2Impact
|
||||
|
||||
s.render(w, "migrate_drive", data)
|
||||
s.executeTemplate(w, r, "migrate_drive", data)
|
||||
}
|
||||
|
||||
// driveMigrateAPIHandler handles POST /api/storage/migrate-drive — starts drive migration.
|
||||
|
||||
@@ -153,7 +153,7 @@ async function saveOptionalConfig(stackName) {
|
||||
try {
|
||||
const resp = await fetch('/api/stacks/' + stackName + '/optional-config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({values: values})
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
@@ -580,7 +580,7 @@ function toggleTier(header) {
|
||||
function triggerCrossDriveBackup(stackName, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Fut...';
|
||||
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST'})
|
||||
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d.ok) {
|
||||
@@ -602,7 +602,7 @@ function triggerCrossDriveBackup(stackName, btn) {
|
||||
function triggerAllCrossDrive(btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Indítás...';
|
||||
fetch('/api/backup/cross-drive/run-all', {method: 'POST'})
|
||||
fetch('/api/backup/cross-drive/run-all', {method: 'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d.ok) {
|
||||
@@ -625,7 +625,7 @@ function triggerBackupFromPage() {
|
||||
const btn = document.getElementById('backup-page-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Mentés indítása...';
|
||||
fetch('/api/backup/run', { method: 'POST' })
|
||||
fetch('/api/backup/run', { method: 'POST', headers: csrfHeaders() })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
@@ -782,6 +782,11 @@ function submitRestore() {
|
||||
form.method = 'POST';
|
||||
form.action = '/backup/restore';
|
||||
|
||||
var fc = document.createElement('input');
|
||||
fc.type = 'hidden'; fc.name = '_csrf';
|
||||
fc.value = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||
form.appendChild(fc);
|
||||
|
||||
var f1 = document.createElement('input');
|
||||
f1.type = 'hidden'; f1.name = 'stack_name'; f1.value = app;
|
||||
form.appendChild(f1);
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
</div>
|
||||
{{else}}
|
||||
<form method="post" action="/settings/cross-backup/{{.Meta.Slug}}">
|
||||
{{.CSRFField}}
|
||||
<div class="settings-grid" style="margin-bottom:1rem">
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Engedélyezve</span>
|
||||
@@ -375,7 +376,7 @@ function onScheduleChange() {
|
||||
function triggerCrossDriveBackup(stackName, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Mentés folyamatban...';
|
||||
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST'})
|
||||
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d.ok) {
|
||||
@@ -456,7 +457,7 @@ function deleteStaleData(stackName, stalePath, btn) {
|
||||
|
||||
fetch('/api/storage/stale-cleanup', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({stack_name: stackName, stale_path: stalePath})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
@@ -553,7 +554,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + stackName + '/deploy', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({values: values})
|
||||
});
|
||||
var data = await resp.json();
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} — Felhom.eu</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<meta name="csrf-token" content="{{.CSRFToken}}">
|
||||
<script>function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}</script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar">
|
||||
@@ -75,7 +77,7 @@
|
||||
try {
|
||||
const resp = await fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'}
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (toast) {
|
||||
@@ -108,7 +110,7 @@
|
||||
try {
|
||||
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'}
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
@@ -174,7 +176,7 @@
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name, {
|
||||
method: 'DELETE',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD})
|
||||
});
|
||||
var data = await resp.json();
|
||||
@@ -286,7 +288,7 @@
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/remove', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
|
||||
});
|
||||
var data = await resp.json();
|
||||
|
||||
@@ -128,7 +128,7 @@ function startMigrate() {
|
||||
|
||||
fetch('/api/storage/migrate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({stack_name: stackName, target_path: targetPath, auto_delete_stale: autoDelete})
|
||||
})
|
||||
.then(function(r){ return r.json(); })
|
||||
@@ -236,7 +236,7 @@ function deleteOldMigrationData() {
|
||||
|
||||
fetch('/api/storage/stale-cleanup', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({stack_name: stackName, stale_path: oldPath})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
|
||||
@@ -128,7 +128,7 @@ function startDriveMigrate() {
|
||||
|
||||
fetch('/api/storage/migrate-drive', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({source_path: sourcePath, dest_path: destPath})
|
||||
})
|
||||
.then(function(r){ return r.json(); })
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
btn.innerHTML = '<span class="spinner"></span> Visszaállítás indítása...';
|
||||
if (skipBtn) skipBtn.style.display = 'none';
|
||||
|
||||
fetch('/api/restore/all', { method: 'POST' })
|
||||
fetch('/api/restore/all', { method: 'POST', headers: csrfHeaders() })
|
||||
.then(function(resp) { return resp.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
@@ -229,7 +229,7 @@
|
||||
|
||||
function skipRestore() {
|
||||
if (!confirm('Biztosan ki szeretné hagyni a visszaállítást? A vezérlőpult üres alkalmazáslistával fog elindulni.')) return;
|
||||
fetch('/api/restore/skip', { method: 'POST' })
|
||||
fetch('/api/restore/skip', { method: 'POST', headers: csrfHeaders() })
|
||||
.then(function(resp) { return resp.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
@@ -243,7 +243,7 @@
|
||||
|
||||
function finishRestore(e) {
|
||||
e.preventDefault();
|
||||
fetch('/api/restore/skip', { method: 'POST' })
|
||||
fetch('/api/restore/skip', { method: 'POST', headers: csrfHeaders() })
|
||||
.then(function() { window.location.href = '/'; })
|
||||
.catch(function() { window.location.href = '/'; });
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ function checkUpdate() {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Ellenőrzés...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/api/selfupdate/check', {method:'POST'})
|
||||
fetch('/api/selfupdate/check', {method:'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
@@ -161,7 +161,7 @@ function triggerUpdate() {
|
||||
if (checkBtn) checkBtn.disabled = true;
|
||||
msg.textContent = 'Frissítés folyamatban...';
|
||||
msg.style.display = 'inline';
|
||||
fetch('/api/selfupdate/update', {method:'POST'})
|
||||
fetch('/api/selfupdate/update', {method:'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
@@ -248,6 +248,7 @@ function pollUntilBack() {
|
||||
<div class="storage-path-actions">
|
||||
<form method="POST" action="/settings/storage/remove" style="display:inline"
|
||||
onsubmit="return confirm('Biztosan eltávolítja a(z) {{.Label}} ({{.Path}}) meghajtót a rendszerből?\n\nA meghajtó adatai NEM törlődnek.')">
|
||||
{{$.CSRFField}}
|
||||
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||
<button type="submit" class="btn btn-xs btn-outline">Eltávolítás a rendszerből</button>
|
||||
</form>
|
||||
@@ -300,18 +301,21 @@ function pollUntilBack() {
|
||||
<div class="storage-path-actions">
|
||||
{{if not .IsDefault}}
|
||||
<form method="POST" action="/settings/storage/default" style="display:inline">
|
||||
{{$.CSRFField}}
|
||||
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||
<button type="submit" class="btn btn-xs btn-outline">Legyen alapértelmezett</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Schedulable}}
|
||||
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
|
||||
{{$.CSRFField}}
|
||||
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||
<input type="hidden" name="schedulable" value="false">
|
||||
<button type="submit" class="btn btn-xs btn-outline">Letiltás</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
|
||||
{{$.CSRFField}}
|
||||
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||
<input type="hidden" name="schedulable" value="true">
|
||||
<button type="submit" class="btn btn-xs btn-outline">Engedélyezés</button>
|
||||
@@ -323,6 +327,7 @@ function pollUntilBack() {
|
||||
{{if and (not .IsDefault) (eq .AppCount 0)}}
|
||||
<form method="POST" action="/settings/storage/remove" style="display:inline"
|
||||
onsubmit="return confirm('Biztosan eltávolítja a(z) {{.Path}} adattárolót?')">
|
||||
{{$.CSRFField}}
|
||||
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||
<button type="submit" class="btn btn-xs btn-danger-outline">Eltávolítás</button>
|
||||
</form>
|
||||
@@ -349,6 +354,7 @@ function pollUntilBack() {
|
||||
<details class="storage-add-details">
|
||||
<summary class="btn btn-sm btn-outline" style="margin-top:.75rem;cursor:pointer">Már csatlakoztatott tárhely hozzáadása kézzel</summary>
|
||||
<form method="POST" action="/settings/storage/add" class="storage-add-form">
|
||||
{{.CSRFField}}
|
||||
<div class="form-group">
|
||||
<label for="storage_path">Elérési út</label>
|
||||
<input type="text" id="storage_path" name="storage_path" class="form-control"
|
||||
@@ -375,6 +381,7 @@ function pollUntilBack() {
|
||||
{{if .AuthEnabled}}
|
||||
{{if .PasswordError}}<div class="alert alert-error">{{.PasswordError}}</div>{{end}}
|
||||
<form method="POST" action="/settings/password">
|
||||
{{.CSRFField}}
|
||||
<div class="form-group">
|
||||
<label for="current_password">Jelenlegi jelszó</label>
|
||||
<input type="password" id="current_password" name="current_password" required
|
||||
@@ -406,6 +413,7 @@ function pollUntilBack() {
|
||||
{{if .NotificationSuccess}}<div class="alert alert-info">{{.NotificationSuccess}}</div>{{end}}
|
||||
{{if .NotificationError}}<div class="alert alert-error">{{.NotificationError}}</div>{{end}}
|
||||
<form method="POST" action="/settings/notifications">
|
||||
{{.CSRFField}}
|
||||
<div class="form-group">
|
||||
<label for="notification_email">E-mail cím</label>
|
||||
<input type="email" id="notification_email" name="notification_email"
|
||||
@@ -531,7 +539,9 @@ function pollUntilBack() {
|
||||
function editStorageLabel(path, currentLabel) {
|
||||
var wrap = document.getElementById('label-wrap-' + path);
|
||||
if (!wrap) return;
|
||||
var csrfTok = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||
wrap.innerHTML = '<form method="POST" action="/settings/storage/label" style="display:inline-flex;gap:.5rem;align-items:center">' +
|
||||
'<input type="hidden" name="_csrf" value="' + csrfTok + '">' +
|
||||
'<input type="hidden" name="storage_path" value="' + path + '">' +
|
||||
'<input type="text" name="storage_label" class="form-control" value="' + currentLabel.replace(/"/g, '"') + '" style="width:200px;padding:.3rem .5rem;font-size:.9rem" maxlength="50">' +
|
||||
'<button type="submit" class="btn btn-xs btn-primary">OK</button>' +
|
||||
@@ -546,7 +556,7 @@ function storageDisconnect(path, label, appCount) {
|
||||
if (!confirm(msg)) return;
|
||||
fetch('/api/storage/disconnect', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({path: path})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.ok) {
|
||||
@@ -562,7 +572,7 @@ function storageReconnect(path) {
|
||||
if (actionsDiv) actionsDiv.innerHTML = '<span class="form-hint">Csatlakoztatás...</span>';
|
||||
fetch('/api/storage/reconnect', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({path: path})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.ok) {
|
||||
@@ -579,7 +589,7 @@ function storageReconnect(path) {
|
||||
function storageRestartApps(path) {
|
||||
fetch('/api/storage/restart-apps', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({path: path})
|
||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.ok) {
|
||||
|
||||
@@ -169,9 +169,9 @@ function scanDisks() {
|
||||
|
||||
// Clean up any stale raw mounts from interrupted previous sessions first,
|
||||
// so the device appears as available in the scan results.
|
||||
fetch('/api/storage/attach/cancel', {method:'POST'})
|
||||
fetch('/api/storage/attach/cancel', {method:'POST', headers: csrfHeaders()})
|
||||
.catch(function(){}) // ignore cancel errors
|
||||
.then(function() { return fetch('/api/storage/scan', {method:'POST'}); })
|
||||
.then(function() { return fetch('/api/storage/scan', {method:'POST', headers: csrfHeaders()}); })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
btn.textContent = '🔍 Meghajtók keresése';
|
||||
@@ -274,7 +274,7 @@ function mountRawAndBrowse(devicePath, fsType) {
|
||||
|
||||
fetch('/api/storage/attach/mount-raw', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({device_path: devicePath})
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
@@ -407,7 +407,7 @@ function createDir() {
|
||||
|
||||
fetch('/api/storage/attach/mkdir', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({path: currentBrowsePath, name: name})
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
@@ -447,7 +447,7 @@ function backToBrowse() {
|
||||
|
||||
function cancelAttach() {
|
||||
// Cleanup raw mount
|
||||
fetch('/api/storage/attach/cancel', {method:'POST'}).catch(function(){});
|
||||
fetch('/api/storage/attach/cancel', {method:'POST', headers: csrfHeaders()}).catch(function(){});
|
||||
window.location.href = '/settings';
|
||||
}
|
||||
|
||||
@@ -482,7 +482,7 @@ document.getElementById('attach-form').addEventListener('submit', function(e) {
|
||||
|
||||
fetch('/api/storage/attach', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify(body)
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
@@ -572,8 +572,8 @@ function escapeAttr(s) {
|
||||
// Cleanup on page unload (best-effort)
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (rawMountPath && !document.getElementById('wizard-done').style.display !== 'none') {
|
||||
// Best-effort cleanup via sendBeacon
|
||||
navigator.sendBeacon('/api/storage/attach/cancel');
|
||||
// Best-effort cleanup via fetch (sendBeacon can't send CSRF headers)
|
||||
fetch('/api/storage/attach/cancel', {method:'POST', headers: csrfHeaders(), keepalive: true}).catch(function(){});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -122,7 +122,7 @@ function scanDisks() {
|
||||
errEl.style.display = 'none';
|
||||
resultEl.style.display = 'none';
|
||||
|
||||
fetch('/api/storage/scan', {method:'POST'})
|
||||
fetch('/api/storage/scan', {method:'POST', headers: csrfHeaders()})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
btn.textContent = '🔍 Meghajtók keresése';
|
||||
@@ -242,7 +242,7 @@ document.getElementById('init-form').addEventListener('submit', function(e) {
|
||||
|
||||
fetch('/api/storage/init', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify(body)
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
|
||||
Reference in New Issue
Block a user