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:
@@ -1,5 +1,44 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.23.0 — CSRF Protection (2026-02-21)
|
||||||
|
|
||||||
|
**CSRF (Cross-Site Request Forgery) protection on all browser-facing POST endpoints — controller and hub.**
|
||||||
|
|
||||||
|
**Controller changes:**
|
||||||
|
|
||||||
|
- New `internal/web/csrf.go`: `CsrfProtect` HTTP middleware validates CSRF tokens on all state-mutating requests (POST/DELETE/PATCH).
|
||||||
|
- Reads token from `_csrf` form field or `X-CSRF-Token` request header.
|
||||||
|
- Exempt paths: `Authorization: Bearer` requests (selfupdate, config/apply hub→controller calls) — browsers cannot auto-send Bearer headers, so no CSRF risk.
|
||||||
|
- Auth-disabled mode (no password set): CSRF check is skipped entirely.
|
||||||
|
- On rejection: JSON error for `/api/` paths, HTTP 403 text for page routes.
|
||||||
|
- `internal/web/auth.go`: `session` struct gains a `csrfToken string` field. `createSession()` generates a second 32-byte random CSRF token alongside the session token. New `csrfTokenForSession(sessionToken)` method returns the CSRF token for a given session.
|
||||||
|
- `internal/web/server.go`: New `executeTemplate(w, r, name, data)` wrapper auto-injects `CSRFField` (`template.HTML` hidden input) and `CSRFToken` (raw string) into every page render data map.
|
||||||
|
- `cmd/controller/main.go`: All route registrations wrapped with `webServer.CsrfProtect(...)` middleware. Version bumped to `v0.23.0`.
|
||||||
|
- All handlers (`handlers.go`, `storage_handlers.go`, `handler_restore.go`): Switched from `s.render(w, ...)` to `s.executeTemplate(w, r, ...)`.
|
||||||
|
- All templates updated:
|
||||||
|
- `layout.html`: Added `<meta name="csrf-token">` and inline `csrfHeaders()` JS helper (returns `{'X-CSRF-Token': ...}`) in `<head>` (before page-specific scripts). Updated 4 fetch POST/DELETE calls.
|
||||||
|
- `settings.html`: Added `{{$.CSRFField}}` to 5 forms inside `{{range .StoragePaths}}` (must use `$` for outer scope inside range). Added `{{.CSRFField}}` to 3 page-level forms. Inline-label form uses `document.querySelector('meta[name="csrf-token"]').content`. Updated 5 fetch calls.
|
||||||
|
- `deploy.html`: Added `{{.CSRFField}}` to cross-backup form. Updated 3 fetch calls.
|
||||||
|
- `backups.html`: Updated 3 fetch calls. Dynamically-created restore form injects `_csrf` from meta tag.
|
||||||
|
- `storage_init.html`, `storage_attach.html`, `migrate.html`, `migrate_drive.html`, `app_info.html`, `restore.html`: All fetch calls updated.
|
||||||
|
- `storage_attach.html`: Replaced `navigator.sendBeacon()` with `fetch(..., {keepalive: true})` — `sendBeacon` cannot send custom headers, making CSRF impossible.
|
||||||
|
|
||||||
|
**Hub changes (v0.3.8):**
|
||||||
|
|
||||||
|
- `internal/web/server.go`: Replaced insecure literal `hub_session=authenticated` cookie with proper server-side session map.
|
||||||
|
- New `hubSession` struct with `csrfToken string` and `expiresAt time.Time`.
|
||||||
|
- `sessions map[string]*hubSession` + `sessionsMu sync.RWMutex` on `Server` struct.
|
||||||
|
- `handleLogin`: Generates cryptographically random 64-char hex session token + 64-char hex CSRF token. Cookie gains `SameSite=Lax` and `Secure` (when TLS) attributes. Session expires after 7 days.
|
||||||
|
- `RequireAuth`: Validates session token against map (constant-time compare), redirects to `/login` on failure.
|
||||||
|
- `CleanupSessions(ctx)`: Goroutine that purges expired sessions every hour.
|
||||||
|
- CSRF validation block at top of `ServeHTTP`: checks `X-CSRF-Token` header or `_csrf` form field on POST/DELETE/PATCH. Skips when no session cookie (Basic Auth / API path).
|
||||||
|
- `csrfToken(r)`, `csrfField(r)` helpers for template data injection.
|
||||||
|
- `internal/web/configs.go`: Added `html/template` import. All template render calls pass `CSRFField template.HTML` and/or `CSRFToken string`. `renderConfigForm` gains `r *http.Request` parameter.
|
||||||
|
- Templates updated:
|
||||||
|
- `config_form.html`: Added `{{.CSRFField}}` inside the `<form>`.
|
||||||
|
- `customer_unified.html`: Added `<meta name="csrf-token">` + inline `csrfHeaders()` in `<head>`. Added `{{.CSRFField}}` to all 5 POST forms (unblock, block, delete config, create-config, regen-password). Updated 3 JS fetch POST calls (trigger-update, push-config, pull-config).
|
||||||
|
- `cmd/hub/main.go`: Started `go webServer.CleanupSessions(ctx)` goroutine.
|
||||||
|
|
||||||
### v0.22.3 — Hub Asset Sync (2026-02-21)
|
### v0.22.3 — Hub Asset Sync (2026-02-21)
|
||||||
|
|
||||||
**Hub-managed asset downloads**
|
**Hub-managed asset downloads**
|
||||||
|
|||||||
@@ -0,0 +1,855 @@
|
|||||||
|
# TASK: CSRF Protection on POST Endpoints
|
||||||
|
|
||||||
|
**Controller:** v0.22.3 → v0.23.0
|
||||||
|
**Hub:** v0.3.7 → v0.3.8
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add CSRF (Cross-Site Request Forgery) protection to all browser-facing POST endpoints in both the controller and the hub. Currently, any page on the internet can craft a form that POSTs to the controller/hub if the user has a valid session cookie — no origin verification exists.
|
||||||
|
|
||||||
|
**Approach: Synchronizer Token Pattern** (per-session CSRF token stored server-side, embedded in forms, validated on POST). This matches the existing setup wizard implementation in `controller/internal/setup/csrf.go` but is more secure: tokens are tied to server-side sessions rather than using the double-submit cookie pattern.
|
||||||
|
|
||||||
|
**Key principle:** Bearer-token-authenticated API endpoints (selfupdate, config/apply) do NOT need CSRF protection — browsers never auto-send `Authorization` headers. Only cookie/session-authenticated POST endpoints need protection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Audit Summary
|
||||||
|
|
||||||
|
### Controller (current state)
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| Session cookie HttpOnly | OK | `HttpOnly: true` |
|
||||||
|
| Session cookie SameSite | PARTIAL | `SameSite=Lax` (good defense-in-depth but not sufficient alone) |
|
||||||
|
| Session cookie Secure | OK | Set when TLS detected |
|
||||||
|
| Random session tokens | OK | 64-char hex (32 bytes) |
|
||||||
|
| CSRF on web form POSTs | MISSING | **None — vulnerable** |
|
||||||
|
| CSRF on JS-driven API POSTs | MISSING | **None — vulnerable** |
|
||||||
|
| Bearer-only endpoints exempt | n/a | selfupdate + config endpoints accept Bearer OR session |
|
||||||
|
|
||||||
|
### Hub (current state — MORE CRITICAL)
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|------|--------|-------|
|
||||||
|
| Session cookie HttpOnly | OK | `HttpOnly: true` |
|
||||||
|
| Session cookie SameSite | MISSING | **Not set** (browser default = Lax in modern browsers, but should be explicit) |
|
||||||
|
| Session cookie Secure | MISSING | **Not set** |
|
||||||
|
| Random session tokens | MISSING | **Cookie value is literal string `"authenticated"`** — any cookie injection = full session |
|
||||||
|
| CSRF on web form POSTs | MISSING | **None — vulnerable** |
|
||||||
|
| CSRF on JS-driven POSTs | MISSING | **None — vulnerable** |
|
||||||
|
|
||||||
|
**Hub session model is fundamentally weak:** The `hub_session=authenticated` cookie contains no randomness. This means:
|
||||||
|
1. Any XSS or subdomain-based cookie injection can forge a session
|
||||||
|
2. There is no way to invalidate individual sessions
|
||||||
|
3. Combined with no CSRF = highly vulnerable
|
||||||
|
|
||||||
|
This task fixes both the CSRF gap AND the hub session model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: Controller CSRF (v0.23.0)
|
||||||
|
|
||||||
|
### Endpoint Map: All State-Changing (POST/DELETE) Endpoints
|
||||||
|
|
||||||
|
#### Web Form POSTs (need CSRF token in hidden field)
|
||||||
|
|
||||||
|
| # | Method | Path | Handler | Auth |
|
||||||
|
|---|--------|------|---------|------|
|
||||||
|
| 1 | POST | `/settings/password` | `handlePasswordChange` | Session |
|
||||||
|
| 2 | POST | `/settings/notifications` | `handleNotificationSettings` | Session |
|
||||||
|
| 3 | POST | `/settings/notifications/test` | `handleTestNotification` | Session |
|
||||||
|
| 4 | POST | `/settings/storage/add` | `handleStorageAdd` | Session |
|
||||||
|
| 5 | POST | `/settings/storage/remove` | `handleStorageRemove` | Session |
|
||||||
|
| 6 | POST | `/settings/storage/default` | `handleStorageDefault` | Session |
|
||||||
|
| 7 | POST | `/settings/storage/schedulable` | `handleStorageSchedulable` | Session |
|
||||||
|
| 8 | POST | `/settings/storage/label` | `handleStorageLabel` | Session |
|
||||||
|
| 9 | POST | `/settings/cross-backup/{name}` | `handleCrossBackupSave` | Session |
|
||||||
|
| 10 | POST | `/backup/restore` | `handleRestore` | Session |
|
||||||
|
|
||||||
|
#### JSON API POSTs called via `fetch()` from UI pages (need X-CSRF-Token header)
|
||||||
|
|
||||||
|
| # | Method | Path | Handler | Notes |
|
||||||
|
|---|--------|------|---------|-------|
|
||||||
|
| 11 | POST | `/api/stacks/{name}/deploy` | `handleDeploy` | Deploy page JS |
|
||||||
|
| 12 | POST | `/api/stacks/{name}/start` | `handleStart` | Dashboard buttons |
|
||||||
|
| 13 | POST | `/api/stacks/{name}/stop` | `handleStop` | Dashboard buttons |
|
||||||
|
| 14 | POST | `/api/stacks/{name}/restart` | `handleRestart` | Dashboard buttons |
|
||||||
|
| 15 | POST | `/api/stacks/{name}/update` | `handleUpdate` | Dashboard buttons |
|
||||||
|
| 16 | POST | `/api/stacks/{name}/optional-config` | `handleOptionalConfig` | App info page |
|
||||||
|
| 17 | POST | `/api/stacks/{name}/remove` | `handleRemove` | Remove modal |
|
||||||
|
| 18 | DELETE | `/api/stacks/{name}` | `handleDelete` | Delete modal |
|
||||||
|
| 19 | POST | `/api/stacks/{name}/cross-backup` | `handleCrossBackupAPI` | Deploy page |
|
||||||
|
| 20 | POST | `/api/stacks/{name}/cross-backup/run` | `handleCrossBackupRun` | Backup page |
|
||||||
|
| 21 | POST | `/api/sync` | `handleSync` | Dashboard button |
|
||||||
|
| 22 | POST | `/api/backup/run` | `handleBackupRun` | Backup page |
|
||||||
|
| 23 | POST | `/api/backup/cross-drive/run-all` | `handleCrossDriveRunAll` | Backup page |
|
||||||
|
| 24 | POST | `/api/stacks/rescan` | `handleRescan` | Internal |
|
||||||
|
| 25 | POST | `/api/storage/scan` | `handleStorageScan` | Init wizard |
|
||||||
|
| 26 | POST | `/api/storage/init` | `handleStorageInit` | Init wizard |
|
||||||
|
| 27 | POST | `/api/storage/attach/mount-raw` | `handleAttachMountRaw` | Attach wizard |
|
||||||
|
| 28 | POST | `/api/storage/attach/mkdir` | `handleAttachMkdir` | Attach wizard |
|
||||||
|
| 29 | POST | `/api/storage/attach` | `handleAttachFinalize` | Attach wizard |
|
||||||
|
| 30 | POST | `/api/storage/attach/cancel` | `handleAttachCancel` | Attach wizard |
|
||||||
|
| 31 | POST | `/api/storage/migrate` | `handleMigrate` | Migration page |
|
||||||
|
| 32 | POST | `/api/storage/stale-cleanup` | `handleStaleCleanup` | Deploy page |
|
||||||
|
| 33 | POST | `/api/storage/disconnect` | `handleDisconnect` | Settings |
|
||||||
|
| 34 | POST | `/api/storage/reconnect` | `handleReconnect` | Settings |
|
||||||
|
| 35 | POST | `/api/storage/restart-apps` | `handleRestartApps` | Settings |
|
||||||
|
| 36 | POST | `/api/storage/migrate-drive` | `handleMigrateDrive` | Drive migration |
|
||||||
|
| 37 | POST | `/api/storage/decommission/remove` | `handleDecommissionRemove` | Settings |
|
||||||
|
| 38 | POST | `/api/assets/sync` | `handleAssetSync` | Internal |
|
||||||
|
| 39 | POST | `/api/restore/all` | `handleRestoreAll` | DR page |
|
||||||
|
| 40 | POST | `/api/restore/skip` | `handleRestoreSkip` | DR page |
|
||||||
|
|
||||||
|
#### Bearer-token API POSTs (CSRF EXEMPT — not browser-initiated)
|
||||||
|
|
||||||
|
| # | Method | Path | Notes |
|
||||||
|
|---|--------|------|-------|
|
||||||
|
| — | POST | `/api/selfupdate/check` | Accepts Bearer token (hub/scripts) |
|
||||||
|
| — | POST | `/api/selfupdate/update` | Accepts Bearer token (hub/scripts) |
|
||||||
|
| — | POST | `/api/config/apply` | Accepts Bearer token (hub push) |
|
||||||
|
|
||||||
|
These three endpoints accept **both** session auth and Bearer auth. When accessed with a Bearer token, CSRF is not needed. When accessed via session cookie from the UI, they should require CSRF. The middleware handles this: if `Authorization: Bearer` header is present and valid, skip CSRF check.
|
||||||
|
|
||||||
|
#### GET-only / No-auth endpoints (no CSRF needed)
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| GET | `/api/health` | No auth, read-only |
|
||||||
|
| GET | `/api/stacks`, `/api/stacks/{name}`, etc. | Read-only |
|
||||||
|
| GET | `/api/backup/status`, `/api/backup/snapshots` | Read-only |
|
||||||
|
| GET | `/api/metrics/*`, `/api/system/info` | Read-only |
|
||||||
|
| GET | `/api/storage/*` (browse, status) | Read-only |
|
||||||
|
| GET | `/api/selfupdate/status` | Read-only |
|
||||||
|
| GET | `/api/config`, `/api/config/hash` | Read-only |
|
||||||
|
| GET | `/api/assets/status` | Read-only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.1 Add CSRF token to session struct
|
||||||
|
|
||||||
|
**File: `controller/internal/web/auth.go`**
|
||||||
|
|
||||||
|
Change the session struct to include a CSRF token:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type session struct {
|
||||||
|
expiresAt time.Time
|
||||||
|
csrfToken string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `createSession()` to generate a CSRF token alongside the session token:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) createSession() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
_, _ = 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),
|
||||||
|
csrfToken: csrfToken,
|
||||||
|
}
|
||||||
|
s.sessionsMu.Unlock()
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a method to retrieve the CSRF token for a session:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Create CSRF middleware
|
||||||
|
|
||||||
|
**File: `controller/internal/web/csrf.go`** (new file)
|
||||||
|
|
||||||
|
```go
|
||||||
|
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 — the token is made available
|
||||||
|
// to templates via the Server.csrfToken() helper.
|
||||||
|
//
|
||||||
|
// Exempt: requests with a valid Authorization: Bearer header (API key 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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expected := s.csrfTokenForSession(cookie.Value)
|
||||||
|
if expected == "" {
|
||||||
|
s.csrfReject(w, r, "invalid session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check form field first, then header (for fetch/AJAX)
|
||||||
|
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. For API requests returns JSON, for web requests returns HTML.
|
||||||
|
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.
|
||||||
|
// Call from handlers to embed in templates or return to JS.
|
||||||
|
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) + `">`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Wire CSRF middleware into the server
|
||||||
|
|
||||||
|
**File: `controller/internal/web/server.go`**
|
||||||
|
|
||||||
|
The controller uses a custom router in `ServeHTTP`. The CSRF middleware must wrap the handler AFTER auth but protect all POST routes. Find where `RequireAuth` wraps the server's handler and add `csrfProtect` inside it.
|
||||||
|
|
||||||
|
In `server.go`, locate the HTTP server setup. The pattern is typically:
|
||||||
|
```go
|
||||||
|
handler := s.RequireAuth(s)
|
||||||
|
```
|
||||||
|
|
||||||
|
Change to:
|
||||||
|
```go
|
||||||
|
handler := s.RequireAuth(s.csrfProtect(s))
|
||||||
|
```
|
||||||
|
|
||||||
|
This means: RequireAuth runs first (verifies session), then csrfProtect runs (verifies CSRF token on POST), then the actual handler runs.
|
||||||
|
|
||||||
|
**Important:** Check the exact wiring in `server.go`. The auth middleware likely wraps the mux in `Start()` or `ListenAndServe()`. Find that location and insert `csrfProtect` between auth and handler. If the `RequireAuth` is applied in `main.go`, adjust there instead.
|
||||||
|
|
||||||
|
### 1.4 Add CSRF token to all template data
|
||||||
|
|
||||||
|
**File: `controller/internal/web/handlers.go`** (and `storage_handlers.go`, `handler_restore.go`)
|
||||||
|
|
||||||
|
Every handler that renders an HTML page must include the CSRF token in its template data. The recommended approach:
|
||||||
|
|
||||||
|
**Add `CSRFField` and `CSRFToken` to every template data map.** In each handler that renders a page, add these two fields:
|
||||||
|
|
||||||
|
```go
|
||||||
|
data["CSRFField"] = s.csrfField(r)
|
||||||
|
data["CSRFToken"] = s.csrfToken(r)
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, create an `executeTemplate` wrapper method that auto-injects these:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// executeTemplate renders a template with per-request CSRF data injected.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace all `s.tmpl.ExecuteTemplate(w, "templateName", data)` calls with `s.executeTemplate(w, r, "templateName", data)`.
|
||||||
|
|
||||||
|
**Handlers that render pages (all need CSRFField/CSRFToken):**
|
||||||
|
- `handleDashboard` → dashboard template
|
||||||
|
- `handleStacks` → stacks template (if separate from dashboard)
|
||||||
|
- `handleDeploy` → deploy template
|
||||||
|
- `handleBackups` → backups template
|
||||||
|
- `handleMonitoring` → monitoring template
|
||||||
|
- `handleSettings` → settings template
|
||||||
|
- `handleAppInfo` → app_info template
|
||||||
|
- `handleLogs` → logs template
|
||||||
|
- `handleMigrate` → migrate template
|
||||||
|
- `handleMigrateDrive` → migrate_drive template
|
||||||
|
- `handleStorageInit` → init_disk template
|
||||||
|
- `handleStorageAttach` → attach_disk template
|
||||||
|
- `handleRestorePage` → restore template
|
||||||
|
- `renderLogin` → login template (**no CSRF needed** — no session yet)
|
||||||
|
|
||||||
|
### 1.5 Add hidden field to all HTML form templates
|
||||||
|
|
||||||
|
**File: `controller/internal/web/templates/*.html`**
|
||||||
|
|
||||||
|
For every `<form method="POST" ...>` in every template, add the CSRF hidden field right after the `<form>` tag:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<form method="POST" action="/settings/password">
|
||||||
|
{{.CSRFField}}
|
||||||
|
<!-- existing form fields -->
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
Templates to update (search for `method="POST"` or `method="post"` in all template files):
|
||||||
|
|
||||||
|
| # | Template | Form action(s) |
|
||||||
|
|---|----------|----------------|
|
||||||
|
| 1 | `settings.html` | `/settings/password`, `/settings/notifications`, `/settings/notifications/test`, `/settings/storage/add`, `/settings/storage/remove`, `/settings/storage/default`, `/settings/storage/schedulable`, `/settings/storage/label` |
|
||||||
|
| 2 | `deploy.html` | Cross-backup form (if any form POST exists) |
|
||||||
|
| 3 | `backups.html` | `/backup/restore` form, cross-backup save forms |
|
||||||
|
| 4 | `init_disk.html` | Storage init wizard (if form-based) |
|
||||||
|
| 5 | `attach_disk.html` | Attach wizard (if form-based) |
|
||||||
|
| 6 | `restore.html` | DR restore (if form-based) |
|
||||||
|
|
||||||
|
**Note:** Many operations on these pages use JavaScript `fetch()` instead of HTML form submissions. For those, see section 1.6.
|
||||||
|
|
||||||
|
### 1.6 Add CSRF token to JavaScript fetch() calls
|
||||||
|
|
||||||
|
For all JavaScript code that makes POST/DELETE requests via `fetch()`, include the CSRF token in the `X-CSRF-Token` header.
|
||||||
|
|
||||||
|
**Step A: Embed the token in a meta tag in the base layout.**
|
||||||
|
|
||||||
|
In the template that defines the `<head>` section (likely within `{{define "head"}}` or the shared HTML header), add:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<meta name="csrf-token" content="{{.CSRFToken}}">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step B: Create a helper function in the shared JS.**
|
||||||
|
|
||||||
|
At the top of the `<script>` block in the layout or in each template, add a reusable helper:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function csrfHeaders() {
|
||||||
|
var el = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
return el ? {'X-CSRF-Token': el.content} : {};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step C: Update every `fetch()` call to include CSRF headers.**
|
||||||
|
|
||||||
|
Example — before:
|
||||||
|
```javascript
|
||||||
|
fetch('/api/stacks/' + name + '/start', {method: 'POST'})
|
||||||
|
```
|
||||||
|
|
||||||
|
After:
|
||||||
|
```javascript
|
||||||
|
fetch('/api/stacks/' + name + '/start', {method: 'POST', headers: csrfHeaders()})
|
||||||
|
```
|
||||||
|
|
||||||
|
For fetch calls that already have headers (e.g., `Content-Type: application/json`), merge:
|
||||||
|
```javascript
|
||||||
|
fetch('/api/stacks/' + name + '/deploy', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comprehensive list of templates with fetch() POST calls to update:**
|
||||||
|
|
||||||
|
Search all `.html` template files for `fetch(` with `method:` containing `POST` or `DELETE`:
|
||||||
|
|
||||||
|
| Template | fetch() calls to update |
|
||||||
|
|----------|------------------------|
|
||||||
|
| `dashboard.html` | Stack start/stop/restart/update buttons |
|
||||||
|
| `deploy.html` | Deploy, optional-config, cross-backup save/run, stale-cleanup |
|
||||||
|
| `stacks.html` | (if any action buttons) |
|
||||||
|
| `backups.html` | Backup run, cross-drive run-all, cross-backup run |
|
||||||
|
| `settings.html` | Storage disconnect/reconnect/restart-apps, self-update check/update, storage label/schedulable (if AJAX) |
|
||||||
|
| `init_disk.html` | Storage scan, init, poll status |
|
||||||
|
| `attach_disk.html` | Mount-raw, mkdir, attach, cancel, poll status |
|
||||||
|
| `migrate.html` | Migrate start, poll status |
|
||||||
|
| `migrate_drive.html` | Drive migration start, poll status |
|
||||||
|
| `app_info.html` | Optional config save |
|
||||||
|
| `restore.html` | Restore all, skip |
|
||||||
|
|
||||||
|
### 1.7 Files changed summary (Controller)
|
||||||
|
|
||||||
|
| # | File | Action | Description |
|
||||||
|
|---|------|--------|-------------|
|
||||||
|
| 1 | `internal/web/auth.go` | Edit | Add `csrfToken` field to session struct, generate in `createSession()`, add `csrfTokenForSession()` |
|
||||||
|
| 2 | `internal/web/csrf.go` | **New** | CSRF middleware, helpers (`csrfProtect`, `csrfReject`, `csrfToken`, `csrfField`) |
|
||||||
|
| 3 | `internal/web/server.go` | Edit | Wire `csrfProtect` middleware after `RequireAuth`, optionally add `executeTemplate` wrapper |
|
||||||
|
| 4 | `internal/web/handlers.go` | Edit | Pass CSRFField/CSRFToken to every page template render |
|
||||||
|
| 5 | `internal/web/storage_handlers.go` | Edit | Pass CSRFField/CSRFToken to storage page renders |
|
||||||
|
| 6 | `internal/web/handler_restore.go` | Edit | Pass CSRFField/CSRFToken to restore page render |
|
||||||
|
| 7 | `internal/web/templates/settings.html` | Edit | Add `{{.CSRFField}}` to all `<form>` tags + CSRF meta tag + update JS |
|
||||||
|
| 8 | `internal/web/templates/deploy.html` | Edit | CSRF meta tag + update JS fetch calls |
|
||||||
|
| 9 | `internal/web/templates/backups.html` | Edit | Add `{{.CSRFField}}` to restore form + CSRF meta tag + update JS |
|
||||||
|
| 10 | `internal/web/templates/dashboard.html` | Edit | CSRF meta tag + update JS fetch calls |
|
||||||
|
| 11 | `internal/web/templates/init_disk.html` | Edit | CSRF meta tag + update JS fetch calls |
|
||||||
|
| 12 | `internal/web/templates/attach_disk.html` | Edit | CSRF meta tag + update JS fetch calls |
|
||||||
|
| 13 | `internal/web/templates/migrate.html` | Edit | CSRF meta tag + update JS fetch calls |
|
||||||
|
| 14 | `internal/web/templates/migrate_drive.html` | Edit | CSRF meta tag + update JS fetch calls |
|
||||||
|
| 15 | `internal/web/templates/app_info.html` | Edit | CSRF meta tag + update JS fetch calls |
|
||||||
|
| 16 | `internal/web/templates/restore.html` | Edit | CSRF meta tag + update JS fetch calls |
|
||||||
|
| 17 | `internal/web/templates/monitoring.html` | Edit | CSRF meta tag (if any POST actions exist) |
|
||||||
|
| 18 | `cmd/controller/main.go` | Edit | Bump Version to "0.23.0" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: Hub CSRF (v0.3.8)
|
||||||
|
|
||||||
|
### Endpoint Map: All State-Changing (POST) Endpoints
|
||||||
|
|
||||||
|
#### Web Form/JS POSTs (need CSRF — session-authenticated)
|
||||||
|
|
||||||
|
| # | Method | Path | Handler | Type |
|
||||||
|
|---|--------|------|---------|------|
|
||||||
|
| 1 | POST | `/configs/new` | `handleConfigCreate` | Form submit |
|
||||||
|
| 2 | POST | `/configs/{id}/edit` | `handleConfigUpdate` | Form submit |
|
||||||
|
| 3 | POST | `/configs/{id}/delete` | `handleConfigDelete` | JS fetch |
|
||||||
|
| 4 | POST | `/configs/{id}/regen-password` | `handleConfigRegenPassword` | JS fetch |
|
||||||
|
| 5 | POST | `/customers/{id}/trigger-update` | `handleTriggerUpdate` | JS fetch |
|
||||||
|
| 6 | POST | `/customers/{id}/block` | `handleBlockCustomer` | JS fetch |
|
||||||
|
| 7 | POST | `/customers/{id}/unblock` | `handleUnblockCustomer` | JS fetch |
|
||||||
|
| 8 | POST | `/customers/{id}/push-config` | `handlePushConfig` | JS fetch |
|
||||||
|
| 9 | POST | `/customers/{id}/pull-config` | `handlePullConfig` | JS fetch |
|
||||||
|
| 10 | POST | `/customers/{id}/create-config` | `handleCreateConfigFromReport` | JS fetch |
|
||||||
|
|
||||||
|
#### API POSTs (CSRF EXEMPT — Bearer-token authenticated, not browser-initiated)
|
||||||
|
|
||||||
|
| # | Method | Path | Notes |
|
||||||
|
|---|--------|------|-------|
|
||||||
|
| — | POST | `/api/v1/report` | Controller pushes report (Bearer) |
|
||||||
|
| — | POST | `/api/v1/event` | Controller pushes event (Bearer) |
|
||||||
|
| — | POST | `/api/v1/notify` | Legacy notification (Bearer) |
|
||||||
|
| — | POST | `/api/v1/preferences` | Preference sync (Bearer) |
|
||||||
|
| — | POST | `/api/v1/infra-backup` | Infra backup push (Bearer) |
|
||||||
|
|
||||||
|
These all live under `/api/v1/` and use Bearer token auth exclusively. The CSRF middleware should skip all paths starting with `/api/v1/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.1 Fix hub session model (PREREQUISITE)
|
||||||
|
|
||||||
|
**File: `hub/internal/web/server.go`**
|
||||||
|
|
||||||
|
The hub currently uses `hub_session=authenticated` as the cookie value — a literal string, not a random token. This must be fixed before CSRF can work (CSRF tokens must be tied to unpredictable sessions).
|
||||||
|
|
||||||
|
**A) Add session storage to the Server struct:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Server struct {
|
||||||
|
// ... existing fields ...
|
||||||
|
sessions map[string]*hubSession
|
||||||
|
sessionsMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type hubSession struct {
|
||||||
|
expiresAt time.Time
|
||||||
|
csrfToken string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialize in `New()`:
|
||||||
|
```go
|
||||||
|
sessions: make(map[string]*hubSession),
|
||||||
|
```
|
||||||
|
|
||||||
|
**B) Update `handleLogin` to create random session tokens:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
|
||||||
|
// Generate random session token
|
||||||
|
b := make([]byte, 32)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
sessionToken := hex.EncodeToString(b)
|
||||||
|
|
||||||
|
// Generate CSRF token
|
||||||
|
cb := make([]byte, 32)
|
||||||
|
_, _ = rand.Read(cb)
|
||||||
|
csrfToken := hex.EncodeToString(cb)
|
||||||
|
|
||||||
|
s.sessionsMu.Lock()
|
||||||
|
s.sessions[sessionToken] = &hubSession{
|
||||||
|
expiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||||
|
csrfToken: csrfToken,
|
||||||
|
}
|
||||||
|
s.sessionsMu.Unlock()
|
||||||
|
|
||||||
|
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "hub_session",
|
||||||
|
Value: sessionToken,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: isSecure,
|
||||||
|
MaxAge: 86400 * 7,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Render login page (keep existing minimal HTML or use template)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`<html><body><form method="post"><input type="password" name="password"><button>Login</button></form></body></html>`))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**C) Update `RequireAuth` to validate random tokens:**
|
||||||
|
|
||||||
|
Replace the cookie check:
|
||||||
|
```go
|
||||||
|
// OLD:
|
||||||
|
if cookie, err := r.Cookie("hub_session"); err == nil && cookie.Value == "authenticated" {
|
||||||
|
|
||||||
|
// NEW:
|
||||||
|
if cookie, err := r.Cookie("hub_session"); err == nil {
|
||||||
|
s.sessionsMu.RLock()
|
||||||
|
sess, ok := s.sessions[cookie.Value]
|
||||||
|
s.sessionsMu.RUnlock()
|
||||||
|
if ok && time.Now().Before(sess.expiresAt) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**D) Add session cleanup goroutine** (start from `main.go`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) CleanupSessions(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(15 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.sessionsMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for t, sess := range s.sessions {
|
||||||
|
if now.After(sess.expiresAt) {
|
||||||
|
delete(s.sessions, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.sessionsMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Start it in `main.go`: `go webServer.CleanupSessions(ctx)`
|
||||||
|
|
||||||
|
**E) Add imports** to `server.go`: `"crypto/rand"`, `"encoding/hex"`, `"sync"`
|
||||||
|
|
||||||
|
### 2.2 Add CSRF validation to hub
|
||||||
|
|
||||||
|
**File: `hub/internal/web/server.go`**
|
||||||
|
|
||||||
|
The simplest approach for the hub is to add CSRF validation directly in `ServeHTTP` at the top, before the routing switch. This avoids middleware composition complexity:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
|
||||||
|
// CSRF protection for all POST/PUT/DELETE requests (web routes only)
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
|
||||||
|
if path != "/login" && s.passwordHash != "" {
|
||||||
|
if !s.validateCSRF(r) {
|
||||||
|
s.logger.Printf("[WARN] CSRF rejected: %s %s from %s", r.Method, path, r.RemoteAddr)
|
||||||
|
http.Error(w, "CSRF token missing or invalid. Please reload the page.", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... existing switch/case routing ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a `validateCSRF` helper method:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) validateCSRF(r *http.Request) bool {
|
||||||
|
// If no session cookie present, this is a Basic Auth or non-browser request — skip CSRF
|
||||||
|
cookie, err := r.Cookie("hub_session")
|
||||||
|
if err != nil {
|
||||||
|
return true // No session cookie = not a browser CSRF attack
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sessionsMu.RLock()
|
||||||
|
sess, ok := s.sessions[cookie.Value]
|
||||||
|
s.sessionsMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
submitted := r.FormValue("_csrf")
|
||||||
|
if submitted == "" {
|
||||||
|
submitted = r.Header.Get("X-CSRF-Token")
|
||||||
|
}
|
||||||
|
return submitted != "" && subtle.ConstantTimeCompare([]byte(submitted), []byte(sess.csrfToken)) == 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add helper methods for templates:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// csrfToken returns the CSRF token for the current session.
|
||||||
|
func (s *Server) csrfToken(r *http.Request) string {
|
||||||
|
cookie, err := r.Cookie("hub_session")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s.sessionsMu.RLock()
|
||||||
|
sess, ok := s.sessions[cookie.Value]
|
||||||
|
s.sessionsMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return sess.csrfToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// csrfField returns an HTML hidden input for forms.
|
||||||
|
func (s *Server) csrfField(r *http.Request) template.HTML {
|
||||||
|
tok := s.csrfToken(r)
|
||||||
|
return template.HTML(`<input type="hidden" name="_csrf" value="` + template.HTMLEscapeString(tok) + `">`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add import: `"crypto/subtle"`
|
||||||
|
|
||||||
|
### 2.3 Add CSRF token to hub templates
|
||||||
|
|
||||||
|
**File: `hub/internal/web/templates/*.html`**
|
||||||
|
|
||||||
|
**A) Config form (`config_form.html`)** — used for both create and edit:
|
||||||
|
|
||||||
|
Add `{{.CSRFField}}` after each `<form>` opening tag.
|
||||||
|
|
||||||
|
**B) Customer unified page (`customer_unified.html`)** — has JS fetch() for trigger-update, block, unblock, push-config, pull-config, create-config:
|
||||||
|
|
||||||
|
Add CSRF meta tag in head:
|
||||||
|
```html
|
||||||
|
<meta name="csrf-token" content="{{.CSRFToken}}">
|
||||||
|
```
|
||||||
|
|
||||||
|
Update all `fetch()` calls to include `X-CSRF-Token` header. Add helper:
|
||||||
|
```javascript
|
||||||
|
function csrfHeaders() {
|
||||||
|
var el = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
return el ? {'X-CSRF-Token': el.content} : {};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**C) Configs list (`configs.html`)** — has delete button with JS fetch():
|
||||||
|
|
||||||
|
Add CSRF meta tag + update delete/regen-password fetch calls.
|
||||||
|
|
||||||
|
**D) Dashboard (`dashboard.html`)** — likely read-only, but check for any POST actions. Add meta tag if needed.
|
||||||
|
|
||||||
|
**Templates to update:**
|
||||||
|
|
||||||
|
| # | Template | Changes |
|
||||||
|
|---|----------|---------|
|
||||||
|
| 1 | `config_form.html` | Add `{{.CSRFField}}` inside `<form>` |
|
||||||
|
| 2 | `customer_unified.html` | Add meta tag + update all fetch() POST calls |
|
||||||
|
| 3 | `configs.html` | Add meta tag + update delete/regen-password fetch() calls |
|
||||||
|
| 4 | `dashboard.html` | Check for POST actions, add meta tag if needed |
|
||||||
|
|
||||||
|
**E) Update all handler functions** to pass `CSRFField` and `CSRFToken` in template data:
|
||||||
|
|
||||||
|
For every handler that renders a template, add to the data struct/map:
|
||||||
|
```go
|
||||||
|
CSRFField: s.csrfField(r),
|
||||||
|
CSRFToken: s.csrfToken(r),
|
||||||
|
```
|
||||||
|
|
||||||
|
Hub handlers that render templates:
|
||||||
|
- `handleDashboard` — pass CSRFToken (if any POST actions on page)
|
||||||
|
- `handleCustomerUnified` — pass CSRFField + CSRFToken
|
||||||
|
- `handleConfigList` — pass CSRFToken
|
||||||
|
- `handleConfigNewForm` — pass CSRFField + CSRFToken
|
||||||
|
- `handleConfigEditForm` — pass CSRFField + CSRFToken
|
||||||
|
|
||||||
|
### 2.4 Hub Basic Auth note
|
||||||
|
|
||||||
|
The hub allows HTTP Basic Auth as an alternative to session cookies. The `validateCSRF` method above handles this correctly: if no session cookie is present (pure Basic Auth request), CSRF validation is skipped. This is safe because:
|
||||||
|
|
||||||
|
1. Cross-site forms cannot inject Basic Auth headers
|
||||||
|
2. Browser-cached Basic Auth is only auto-sent to the same origin
|
||||||
|
3. The SameSite=Lax cookie prevents cross-site cookie-based attacks
|
||||||
|
|
||||||
|
### 2.5 Files changed summary (Hub)
|
||||||
|
|
||||||
|
| # | File | Action | Description |
|
||||||
|
|---|------|--------|-------------|
|
||||||
|
| 1 | `internal/web/server.go` | Edit | Add session map + mutex + hubSession struct, random session tokens, SameSite=Lax, Secure flag, session cleanup, CSRF validation in ServeHTTP, csrfToken/csrfField/validateCSRF helpers |
|
||||||
|
| 2 | `internal/web/configs.go` | Edit | Pass CSRFField/CSRFToken to template data in all config handlers |
|
||||||
|
| 3 | `internal/web/templates/config_form.html` | Edit | Add `{{.CSRFField}}` to form |
|
||||||
|
| 4 | `internal/web/templates/customer_unified.html` | Edit | Add CSRF meta tag + update all JS fetch() calls |
|
||||||
|
| 5 | `internal/web/templates/configs.html` | Edit | Add CSRF meta tag + update JS fetch() calls |
|
||||||
|
| 6 | `internal/web/templates/dashboard.html` | Edit | Add CSRF meta tag (if any POST actions) |
|
||||||
|
| 7 | `cmd/hub/main.go` | Edit | Start session cleanup goroutine |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Controller first** (v0.23.0) — larger surface area, more endpoints to protect
|
||||||
|
1. `auth.go` — Add csrfToken to session struct
|
||||||
|
2. `csrf.go` — New file with CSRF middleware + helpers
|
||||||
|
3. `server.go` — Wire middleware, add executeTemplate or inject CSRFField/CSRFToken
|
||||||
|
4. `handlers.go` — Pass CSRF data to all page renders
|
||||||
|
5. `storage_handlers.go` — Pass CSRF data to storage page renders
|
||||||
|
6. `handler_restore.go` — Pass CSRF data to restore page render
|
||||||
|
7. Templates — Add `{{.CSRFField}}` to forms + meta tags + update JS
|
||||||
|
8. Test: attempt POST without token → 403, with token → success
|
||||||
|
|
||||||
|
2. **Hub second** (v0.3.8) — fewer endpoints, session model fix is critical
|
||||||
|
1. `server.go` — Random session tokens, SameSite, Secure, cleanup
|
||||||
|
2. CSRF validation + helpers in server.go
|
||||||
|
3. `configs.go` — Pass CSRF data to template renders
|
||||||
|
4. Templates — Add hidden fields + meta tags + update JS
|
||||||
|
5. `main.go` — Start session cleanup goroutine
|
||||||
|
6. Test: attempt POST without token → 403, with token → success
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Controller
|
||||||
|
|
||||||
|
- [ ] Login still works (no CSRF on login — no session yet)
|
||||||
|
- [ ] All form submissions work with CSRF token (settings, notifications, storage, backup restore)
|
||||||
|
- [ ] All JS-driven actions work (deploy, start/stop/restart, update, remove, delete, sync, backup, storage wizards, migration)
|
||||||
|
- [ ] Bearer-token requests still work without CSRF (selfupdate, config/apply from hub)
|
||||||
|
- [ ] Cross-site POST without token returns 403
|
||||||
|
- [ ] Cross-site fetch() without X-CSRF-Token returns 403
|
||||||
|
- [ ] After session expires and re-login, new CSRF token works
|
||||||
|
- [ ] Open-access mode (no password) still works — CSRF skipped
|
||||||
|
|
||||||
|
### Hub
|
||||||
|
|
||||||
|
- [ ] Login creates random session token (not literal "authenticated")
|
||||||
|
- [ ] Session cookie has SameSite=Lax + Secure (when TLS)
|
||||||
|
- [ ] All form submissions work (config create/edit)
|
||||||
|
- [ ] All JS-driven actions work (delete, regen-password, trigger-update, block/unblock, push/pull config)
|
||||||
|
- [ ] API endpoints still work with Bearer token (report push, event push, etc.)
|
||||||
|
- [ ] Cross-site POST without token returns 403
|
||||||
|
- [ ] Session cleanup removes expired sessions
|
||||||
|
- [ ] Basic Auth requests (no session cookie) bypass CSRF correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Deploy
|
||||||
|
|
||||||
|
### Controller (v0.23.0)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SSH=/c/Windows/System32/OpenSSH/ssh.exe
|
||||||
|
|
||||||
|
# 1. Commit and push
|
||||||
|
cd /e/git/deploy-felhom-compose
|
||||||
|
git add -A && git commit -m "feat: CSRF protection on all POST endpoints (v0.23.0)" && git push
|
||||||
|
|
||||||
|
# 2. Build
|
||||||
|
$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-controller && git -C ~/git/deploy-felhom-compose pull && ./build.sh v0.23.0 --push"
|
||||||
|
|
||||||
|
# 3. Deploy
|
||||||
|
$SSH kisfenyo@192.168.0.162 "cd /opt/docker/felhom-controller && sudo docker pull gitea.dooplex.hu/admin/felhom-controller:v0.23.0 && sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:v0.23.0|' docker-compose.yml && sudo docker compose up -d"
|
||||||
|
|
||||||
|
# 4. Verify
|
||||||
|
$SSH kisfenyo@192.168.0.162 "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hub (v0.3.8)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Commit and push
|
||||||
|
cd /e/git/felhom.eu
|
||||||
|
git add -A && git commit -m "feat: CSRF protection + random session tokens (v0.3.8)" && git push
|
||||||
|
|
||||||
|
# 2. Build
|
||||||
|
$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-hub && git checkout -- hub/go.mod && git pull && cd ~/build/felhom-hub && ./build.sh v0.3.8 --push"
|
||||||
|
|
||||||
|
# 3. Deploy via ArgoCD — update manifests/hub.yaml image tag to v0.3.8, then:
|
||||||
|
$SSH kisfenyo@192.168.0.180 "cd ~/git/felhom.eu && git pull && kubectl apply -f manifests/hub.yaml"
|
||||||
|
|
||||||
|
# 4. Verify
|
||||||
|
$SSH kisfenyo@192.168.0.180 "kubectl get pods -n felhom-system -l app=hub && kubectl logs -n felhom-system -l app=hub --tail 10"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deploy order:** Either order works. Both components are backward-compatible — CSRF tokens are only checked on the component's own forms/JS, not cross-component communication (which uses Bearer tokens).
|
||||||
|
|||||||
+28
-3
@@ -757,6 +757,30 @@ self_update:
|
|||||||
- Session cleanup every 15 minutes
|
- Session cleanup every 15 minutes
|
||||||
- All sessions invalidated on password change
|
- All sessions invalidated on password change
|
||||||
- Conditional logout link (hidden when auth is disabled)
|
- 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`)
|
#### Settings Persistence (`internal/settings/settings.go`)
|
||||||
|
|
||||||
@@ -1033,8 +1057,9 @@ controller/
|
|||||||
│ │ └── templates/ # 7 wizard HTML templates (Hungarian)
|
│ │ └── templates/ # 7 wizard HTML templates (Hungarian)
|
||||||
│ ├── recovery/info.go # Recovery info file generator (recovery-info.txt)
|
│ ├── recovery/info.go # Recovery info file generator (recovery-info.txt)
|
||||||
│ └── web/
|
│ └── web/
|
||||||
│ ├── server.go # HTTP server, routing, static files
|
│ ├── server.go # HTTP server, routing, static files, executeTemplate wrapper
|
||||||
│ ├── auth.go # Session auth, login/logout, session cleanup
|
│ ├── 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.)
|
│ ├── handlers.go # Page handlers (dashboard, stacks, deploy, backups, etc.)
|
||||||
│ ├── handler_restore.go # DR: restore page handler + APIs (scan, restore all, skip)
|
│ ├── 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)
|
│ ├── 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)
|
- [ ] Update classification and auto-apply (optional/required/security markers)
|
||||||
- [ ] Docker volume backup (`/var/lib/docker/volumes:ro`)
|
- [ ] Docker volume backup (`/var/lib/docker/volumes:ro`)
|
||||||
- [ ] Raspberry Pi testing (pi-customer-1)
|
- [ ] Raspberry Pi testing (pi-customer-1)
|
||||||
- [ ] CSRF protection on POST endpoints
|
- [x] CSRF protection on POST endpoints (v0.23.0)
|
||||||
- [ ] Login rate limiting
|
- [ ] Login rate limiting
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -640,15 +640,16 @@ func main() {
|
|||||||
// API routes (no auth for health endpoint, auth for everything else)
|
// API routes (no auth for health endpoint, auth for everything else)
|
||||||
mux.HandleFunc("/api/health", apiRouter.HealthHandler)
|
mux.HandleFunc("/api/health", apiRouter.HealthHandler)
|
||||||
// Storage API routes handled by web server (longer prefix takes precedence over /api/)
|
// 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)
|
// 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)
|
// 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/config/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
|
||||||
mux.Handle("/api/", webServer.RequireAuth(http.HandlerFunc(apiRouter.ServeHTTP)))
|
mux.Handle("/api/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
|
||||||
|
|
||||||
// Web UI routes (auth required)
|
// 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 ---
|
// --- Start HTTP server ---
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
type session struct {
|
type session struct {
|
||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
|
csrfToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -141,13 +142,32 @@ func (s *Server) createSession() string {
|
|||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
token := hex.EncodeToString(b)
|
token := hex.EncodeToString(b)
|
||||||
|
|
||||||
|
csrfB := make([]byte, 32)
|
||||||
|
_, _ = rand.Read(csrfB)
|
||||||
|
csrfToken := hex.EncodeToString(csrfB)
|
||||||
|
|
||||||
s.sessionsMu.Lock()
|
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()
|
s.sessionsMu.Unlock()
|
||||||
|
|
||||||
return token
|
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 {
|
func (s *Server) isValidSession(token string) bool {
|
||||||
s.sessionsMu.RLock()
|
s.sessionsMu.RLock()
|
||||||
defer s.sessionsMu.RUnlock()
|
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,
|
"PlanStatus": status,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.render(w, "restore", data)
|
s.executeTemplate(w, r, "restore", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiRestoreStatus returns the current restore plan status as JSON.
|
// 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")
|
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) {
|
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
|
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) {
|
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 := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
|
||||||
data["Stack"] = stack
|
data["Stack"] = stack
|
||||||
data["Logs"] = logs
|
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) {
|
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
|
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) {
|
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["HasAppInfo"] = found.Meta.HasAppInfo()
|
||||||
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
|
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) {
|
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
|
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.
|
// 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
|
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.
|
// 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" {
|
if msg := r.URL.Query().Get("storage_msg"); msg == "success" {
|
||||||
data["StorageSuccess"] = r.URL.Query().Get("storage_detail")
|
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) {
|
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()
|
effectiveHash := s.effectivePasswordHash()
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
|
||||||
data["PasswordError"] = "Hibás jelenlegi jelszó"
|
data["PasswordError"] = "Hibás jelenlegi jelszó"
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate new password length
|
// Validate new password length
|
||||||
if len(newPassword) < 8 {
|
if len(newPassword) < 8 {
|
||||||
data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate passwords match
|
// Validate passwords match
|
||||||
if newPassword != confirmPassword {
|
if newPassword != confirmPassword {
|
||||||
data["PasswordError"] = "A két jelszó nem egyezik"
|
data["PasswordError"] = "A két jelszó nem egyezik"
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,7 +1055,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Printf("[ERROR] Failed to hash new password: %v", err)
|
s.logger.Printf("[ERROR] Failed to hash new password: %v", err)
|
||||||
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1063,7 +1063,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
|
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
|
||||||
s.logger.Printf("[ERROR] Failed to save password to settings.json: %v", err)
|
s.logger.Printf("[ERROR] Failed to save password to settings.json: %v", err)
|
||||||
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
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)
|
s.logger.Printf("[ERROR] Failed to save notification prefs: %v", err)
|
||||||
data := s.settingsData()
|
data := s.settingsData()
|
||||||
data["NotificationError"] = "Hiba a beállítások mentésekor"
|
data["NotificationError"] = "Hiba a beállítások mentésekor"
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1144,7 +1144,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
|
|||||||
} else {
|
} else {
|
||||||
data["NotificationSuccess"] = "Értesítési beállítások mentve."
|
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) {
|
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 {
|
if s.notifier == nil {
|
||||||
data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
|
data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,12 +1160,12 @@ func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Printf("[ERROR] Test notification failed: %v", err)
|
s.logger.Printf("[ERROR] Test notification failed: %v", err)
|
||||||
data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data["NotificationSuccess"] = "Teszt email elküldve."
|
data["NotificationSuccess"] = "Teszt email elküldve."
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Storage path management handlers ---
|
// --- Storage path management handlers ---
|
||||||
@@ -1279,21 +1279,21 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
fi, err := os.Stat(path)
|
fi, err := os.Stat(path)
|
||||||
if err != nil || !fi.IsDir() {
|
if err != nil || !fi.IsDir() {
|
||||||
data["StorageError"] = "Az útvonal nem létezik vagy nem mappa."
|
data["StorageError"] = "Az útvonal nem létezik vagy nem mappa."
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Is mount point
|
// 2. Is mount point
|
||||||
if !system.IsMountPoint(path) {
|
if !system.IsMountPoint(path) {
|
||||||
data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!"
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Writable
|
// 3. Writable
|
||||||
if !system.IsWritable(path) {
|
if !system.IsWritable(path) {
|
||||||
data["StorageError"] = "Az útvonal nem írható."
|
data["StorageError"] = "Az útvonal nem írható."
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1301,7 +1301,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
for _, existing := range s.settings.GetStoragePaths() {
|
for _, existing := range s.settings.GetStoragePaths() {
|
||||||
if system.PathsOverlap(path, existing.Path) {
|
if system.PathsOverlap(path, existing.Path) {
|
||||||
data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", 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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1322,7 +1322,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
if err := s.settings.AddStoragePath(sp); err != nil {
|
if err := s.settings.AddStoragePath(sp); err != nil {
|
||||||
s.logger.Printf("[ERROR] Failed to add storage path: %v", err)
|
s.logger.Printf("[ERROR] Failed to add storage path: %v", err)
|
||||||
data["StorageError"] = "Hiba a mentés során."
|
data["StorageError"] = "Hiba a mentés során."
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1341,7 +1341,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
|
|||||||
apps := s.appsUsingPath(path)
|
apps := s.appsUsingPath(path)
|
||||||
if len(apps) > 0 {
|
if len(apps) > 0 {
|
||||||
data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", "))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1349,7 +1349,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
|
|||||||
for _, sp := range s.settings.GetStoragePaths() {
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
if sp.Path == path && sp.IsDefault {
|
if sp.Path == path && sp.IsDefault {
|
||||||
data["StorageError"] = "Az alapértelmezett adattároló nem törölhető."
|
data["StorageError"] = "Az alapértelmezett adattároló nem törölhető."
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1357,13 +1357,13 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
|
|||||||
// Check: last path
|
// Check: last path
|
||||||
if len(s.settings.GetStoragePaths()) <= 1 {
|
if len(s.settings.GetStoragePaths()) <= 1 {
|
||||||
data["StorageError"] = "Az utolsó adattároló nem törölhető."
|
data["StorageError"] = "Az utolsó adattároló nem törölhető."
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.settings.RemoveStoragePath(path); err != nil {
|
if err := s.settings.RemoveStoragePath(path); err != nil {
|
||||||
data["StorageError"] = "Hiba a törlés során."
|
data["StorageError"] = "Hiba a törlés során."
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1406,7 +1406,7 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
|
|||||||
if label == "" || len(label) > 50 {
|
if label == "" || len(label) > 50 {
|
||||||
data := s.settingsData()
|
data := s.settingsData()
|
||||||
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
|
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
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)
|
s.logger.Printf("[ERROR] Failed to set storage label: %v", err)
|
||||||
data := s.settingsData()
|
data := s.settingsData()
|
||||||
data["StorageError"] = "Hiba a megnevezés mentésekor."
|
data["StorageError"] = "Hiba a megnevezés mentésekor."
|
||||||
s.render(w, "settings", data)
|
s.executeTemplate(w, r, "settings", data)
|
||||||
return
|
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 ---
|
// --- Static file / asset serving ---
|
||||||
|
|
||||||
func (s *Server) serveCSSHandler(w http.ResponseWriter, r *http.Request) {
|
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.
|
// storageInitHandler serves the storage init wizard page.
|
||||||
func (s *Server) storageInitHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) storageInitHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
data := s.baseData("settings", "Meghajtó inicializálása")
|
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.
|
// 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["CurrentLabel"] = currentLabel
|
||||||
data["OtherPaths"] = otherPaths
|
data["OtherPaths"] = otherPaths
|
||||||
data["DataSizeHuman"] = totalSizeHuman
|
data["DataSizeHuman"] = totalSizeHuman
|
||||||
s.render(w, "migrate", data)
|
s.executeTemplate(w, r, "migrate", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageMigrateAPIHandler handles POST /api/storage/migrate — starts migration job.
|
// 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.
|
// storageAttachHandler serves the attach wizard page.
|
||||||
func (s *Server) storageAttachHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) storageAttachHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
data := s.baseData("settings", "Meglévő meghajtó csatolása")
|
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.
|
// 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
|
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.
|
// driveMigrateAPIHandler handles POST /api/storage/migrate-drive — starts drive migration.
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ async function saveOptionalConfig(stackName) {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/stacks/' + stackName + '/optional-config', {
|
const resp = await fetch('/api/stacks/' + stackName + '/optional-config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify({values: values})
|
body: JSON.stringify({values: values})
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ function toggleTier(header) {
|
|||||||
function triggerCrossDriveBackup(stackName, btn) {
|
function triggerCrossDriveBackup(stackName, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Fut...';
|
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(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
@@ -602,7 +602,7 @@ function triggerCrossDriveBackup(stackName, btn) {
|
|||||||
function triggerAllCrossDrive(btn) {
|
function triggerAllCrossDrive(btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Indítás...';
|
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(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
@@ -625,7 +625,7 @@ function triggerBackupFromPage() {
|
|||||||
const btn = document.getElementById('backup-page-btn');
|
const btn = document.getElementById('backup-page-btn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Mentés indítása...';
|
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(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
@@ -782,6 +782,11 @@ function submitRestore() {
|
|||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = '/backup/restore';
|
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');
|
var f1 = document.createElement('input');
|
||||||
f1.type = 'hidden'; f1.name = 'stack_name'; f1.value = app;
|
f1.type = 'hidden'; f1.name = 'stack_name'; f1.value = app;
|
||||||
form.appendChild(f1);
|
form.appendChild(f1);
|
||||||
|
|||||||
@@ -118,6 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<form method="post" action="/settings/cross-backup/{{.Meta.Slug}}">
|
<form method="post" action="/settings/cross-backup/{{.Meta.Slug}}">
|
||||||
|
{{.CSRFField}}
|
||||||
<div class="settings-grid" style="margin-bottom:1rem">
|
<div class="settings-grid" style="margin-bottom:1rem">
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<span class="settings-label">Engedélyezve</span>
|
<span class="settings-label">Engedélyezve</span>
|
||||||
@@ -375,7 +376,7 @@ function onScheduleChange() {
|
|||||||
function triggerCrossDriveBackup(stackName, btn) {
|
function triggerCrossDriveBackup(stackName, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Mentés folyamatban...';
|
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(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
@@ -456,7 +457,7 @@ function deleteStaleData(stackName, stalePath, btn) {
|
|||||||
|
|
||||||
fetch('/api/storage/stale-cleanup', {
|
fetch('/api/storage/stale-cleanup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify({stack_name: stackName, stale_path: stalePath})
|
body: JSON.stringify({stack_name: stackName, stale_path: stalePath})
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
@@ -553,7 +554,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
|||||||
try {
|
try {
|
||||||
var resp = await fetch('/api/stacks/' + stackName + '/deploy', {
|
var resp = await fetch('/api/stacks/' + stackName + '/deploy', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify({values: values})
|
body: JSON.stringify({values: values})
|
||||||
});
|
});
|
||||||
var data = await resp.json();
|
var data = await resp.json();
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{.Title}} — Felhom.eu</title>
|
<title>{{.Title}} — Felhom.eu</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
@@ -75,7 +77,7 @@
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/sync', {
|
const resp = await fetch('/api/sync', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'}
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (toast) {
|
if (toast) {
|
||||||
@@ -108,7 +110,7 @@
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'}
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
@@ -174,7 +176,7 @@
|
|||||||
try {
|
try {
|
||||||
var resp = await fetch('/api/stacks/' + name, {
|
var resp = await fetch('/api/stacks/' + name, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify({remove_hdd_data: removeHDD})
|
body: JSON.stringify({remove_hdd_data: removeHDD})
|
||||||
});
|
});
|
||||||
var data = await resp.json();
|
var data = await resp.json();
|
||||||
@@ -286,7 +288,7 @@
|
|||||||
try {
|
try {
|
||||||
var resp = await fetch('/api/stacks/' + name + '/remove', {
|
var resp = await fetch('/api/stacks/' + name + '/remove', {
|
||||||
method: 'POST',
|
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})
|
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
|
||||||
});
|
});
|
||||||
var data = await resp.json();
|
var data = await resp.json();
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function startMigrate() {
|
|||||||
|
|
||||||
fetch('/api/storage/migrate', {
|
fetch('/api/storage/migrate', {
|
||||||
method: 'POST',
|
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})
|
body: JSON.stringify({stack_name: stackName, target_path: targetPath, auto_delete_stale: autoDelete})
|
||||||
})
|
})
|
||||||
.then(function(r){ return r.json(); })
|
.then(function(r){ return r.json(); })
|
||||||
@@ -236,7 +236,7 @@ function deleteOldMigrationData() {
|
|||||||
|
|
||||||
fetch('/api/storage/stale-cleanup', {
|
fetch('/api/storage/stale-cleanup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify({stack_name: stackName, stale_path: oldPath})
|
body: JSON.stringify({stack_name: stackName, stale_path: oldPath})
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function startDriveMigrate() {
|
|||||||
|
|
||||||
fetch('/api/storage/migrate-drive', {
|
fetch('/api/storage/migrate-drive', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify({source_path: sourcePath, dest_path: destPath})
|
body: JSON.stringify({source_path: sourcePath, dest_path: destPath})
|
||||||
})
|
})
|
||||||
.then(function(r){ return r.json(); })
|
.then(function(r){ return r.json(); })
|
||||||
|
|||||||
@@ -205,7 +205,7 @@
|
|||||||
btn.innerHTML = '<span class="spinner"></span> Visszaállítás indítása...';
|
btn.innerHTML = '<span class="spinner"></span> Visszaállítás indítása...';
|
||||||
if (skipBtn) skipBtn.style.display = 'none';
|
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(resp) { return resp.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
|
|
||||||
function skipRestore() {
|
function skipRestore() {
|
||||||
if (!confirm('Biztosan ki szeretné hagyni a visszaállítást? A vezérlőpult üres alkalmazáslistával fog elindulni.')) return;
|
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(resp) { return resp.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
|
|
||||||
function finishRestore(e) {
|
function finishRestore(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fetch('/api/restore/skip', { method: 'POST' })
|
fetch('/api/restore/skip', { method: 'POST', headers: csrfHeaders() })
|
||||||
.then(function() { window.location.href = '/'; })
|
.then(function() { window.location.href = '/'; })
|
||||||
.catch(function() { window.location.href = '/'; });
|
.catch(function() { window.location.href = '/'; });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ function checkUpdate() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Ellenőrzés...';
|
btn.textContent = 'Ellenőrzés...';
|
||||||
msg.style.display = 'none';
|
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(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
@@ -161,7 +161,7 @@ function triggerUpdate() {
|
|||||||
if (checkBtn) checkBtn.disabled = true;
|
if (checkBtn) checkBtn.disabled = true;
|
||||||
msg.textContent = 'Frissítés folyamatban...';
|
msg.textContent = 'Frissítés folyamatban...';
|
||||||
msg.style.display = 'inline';
|
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(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
@@ -248,6 +248,7 @@ function pollUntilBack() {
|
|||||||
<div class="storage-path-actions">
|
<div class="storage-path-actions">
|
||||||
<form method="POST" action="/settings/storage/remove" style="display:inline"
|
<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.')">
|
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}}">
|
<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>
|
<button type="submit" class="btn btn-xs btn-outline">Eltávolítás a rendszerből</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -300,18 +301,21 @@ function pollUntilBack() {
|
|||||||
<div class="storage-path-actions">
|
<div class="storage-path-actions">
|
||||||
{{if not .IsDefault}}
|
{{if not .IsDefault}}
|
||||||
<form method="POST" action="/settings/storage/default" style="display:inline">
|
<form method="POST" action="/settings/storage/default" style="display:inline">
|
||||||
|
{{$.CSRFField}}
|
||||||
<input type="hidden" name="storage_path" value="{{.Path}}">
|
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||||
<button type="submit" class="btn btn-xs btn-outline">Legyen alapértelmezett</button>
|
<button type="submit" class="btn btn-xs btn-outline">Legyen alapértelmezett</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Schedulable}}
|
{{if .Schedulable}}
|
||||||
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
|
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
|
||||||
|
{{$.CSRFField}}
|
||||||
<input type="hidden" name="storage_path" value="{{.Path}}">
|
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||||
<input type="hidden" name="schedulable" value="false">
|
<input type="hidden" name="schedulable" value="false">
|
||||||
<button type="submit" class="btn btn-xs btn-outline">Letiltás</button>
|
<button type="submit" class="btn btn-xs btn-outline">Letiltás</button>
|
||||||
</form>
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
|
<form method="POST" action="/settings/storage/schedulable" style="display:inline">
|
||||||
|
{{$.CSRFField}}
|
||||||
<input type="hidden" name="storage_path" value="{{.Path}}">
|
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||||
<input type="hidden" name="schedulable" value="true">
|
<input type="hidden" name="schedulable" value="true">
|
||||||
<button type="submit" class="btn btn-xs btn-outline">Engedélyezés</button>
|
<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)}}
|
{{if and (not .IsDefault) (eq .AppCount 0)}}
|
||||||
<form method="POST" action="/settings/storage/remove" style="display:inline"
|
<form method="POST" action="/settings/storage/remove" style="display:inline"
|
||||||
onsubmit="return confirm('Biztosan eltávolítja a(z) {{.Path}} adattárolót?')">
|
onsubmit="return confirm('Biztosan eltávolítja a(z) {{.Path}} adattárolót?')">
|
||||||
|
{{$.CSRFField}}
|
||||||
<input type="hidden" name="storage_path" value="{{.Path}}">
|
<input type="hidden" name="storage_path" value="{{.Path}}">
|
||||||
<button type="submit" class="btn btn-xs btn-danger-outline">Eltávolítás</button>
|
<button type="submit" class="btn btn-xs btn-danger-outline">Eltávolítás</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -349,6 +354,7 @@ function pollUntilBack() {
|
|||||||
<details class="storage-add-details">
|
<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>
|
<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">
|
<form method="POST" action="/settings/storage/add" class="storage-add-form">
|
||||||
|
{{.CSRFField}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="storage_path">Elérési út</label>
|
<label for="storage_path">Elérési út</label>
|
||||||
<input type="text" id="storage_path" name="storage_path" class="form-control"
|
<input type="text" id="storage_path" name="storage_path" class="form-control"
|
||||||
@@ -375,6 +381,7 @@ function pollUntilBack() {
|
|||||||
{{if .AuthEnabled}}
|
{{if .AuthEnabled}}
|
||||||
{{if .PasswordError}}<div class="alert alert-error">{{.PasswordError}}</div>{{end}}
|
{{if .PasswordError}}<div class="alert alert-error">{{.PasswordError}}</div>{{end}}
|
||||||
<form method="POST" action="/settings/password">
|
<form method="POST" action="/settings/password">
|
||||||
|
{{.CSRFField}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="current_password">Jelenlegi jelszó</label>
|
<label for="current_password">Jelenlegi jelszó</label>
|
||||||
<input type="password" id="current_password" name="current_password" required
|
<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 .NotificationSuccess}}<div class="alert alert-info">{{.NotificationSuccess}}</div>{{end}}
|
||||||
{{if .NotificationError}}<div class="alert alert-error">{{.NotificationError}}</div>{{end}}
|
{{if .NotificationError}}<div class="alert alert-error">{{.NotificationError}}</div>{{end}}
|
||||||
<form method="POST" action="/settings/notifications">
|
<form method="POST" action="/settings/notifications">
|
||||||
|
{{.CSRFField}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="notification_email">E-mail cím</label>
|
<label for="notification_email">E-mail cím</label>
|
||||||
<input type="email" id="notification_email" name="notification_email"
|
<input type="email" id="notification_email" name="notification_email"
|
||||||
@@ -531,7 +539,9 @@ function pollUntilBack() {
|
|||||||
function editStorageLabel(path, currentLabel) {
|
function editStorageLabel(path, currentLabel) {
|
||||||
var wrap = document.getElementById('label-wrap-' + path);
|
var wrap = document.getElementById('label-wrap-' + path);
|
||||||
if (!wrap) return;
|
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">' +
|
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="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">' +
|
'<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>' +
|
'<button type="submit" class="btn btn-xs btn-primary">OK</button>' +
|
||||||
@@ -546,7 +556,7 @@ function storageDisconnect(path, label, appCount) {
|
|||||||
if (!confirm(msg)) return;
|
if (!confirm(msg)) return;
|
||||||
fetch('/api/storage/disconnect', {
|
fetch('/api/storage/disconnect', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify({path: path})
|
body: JSON.stringify({path: path})
|
||||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
@@ -562,7 +572,7 @@ function storageReconnect(path) {
|
|||||||
if (actionsDiv) actionsDiv.innerHTML = '<span class="form-hint">Csatlakoztatás...</span>';
|
if (actionsDiv) actionsDiv.innerHTML = '<span class="form-hint">Csatlakoztatás...</span>';
|
||||||
fetch('/api/storage/reconnect', {
|
fetch('/api/storage/reconnect', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify({path: path})
|
body: JSON.stringify({path: path})
|
||||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
@@ -579,7 +589,7 @@ function storageReconnect(path) {
|
|||||||
function storageRestartApps(path) {
|
function storageRestartApps(path) {
|
||||||
fetch('/api/storage/restart-apps', {
|
fetch('/api/storage/restart-apps', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify({path: path})
|
body: JSON.stringify({path: path})
|
||||||
}).then(function(r) { return r.json(); }).then(function(data) {
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
|
|||||||
@@ -169,9 +169,9 @@ function scanDisks() {
|
|||||||
|
|
||||||
// Clean up any stale raw mounts from interrupted previous sessions first,
|
// Clean up any stale raw mounts from interrupted previous sessions first,
|
||||||
// so the device appears as available in the scan results.
|
// 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
|
.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(r){ return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
btn.textContent = '🔍 Meghajtók keresése';
|
btn.textContent = '🔍 Meghajtók keresése';
|
||||||
@@ -274,7 +274,7 @@ function mountRawAndBrowse(devicePath, fsType) {
|
|||||||
|
|
||||||
fetch('/api/storage/attach/mount-raw', {
|
fetch('/api/storage/attach/mount-raw', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify({device_path: devicePath})
|
body: JSON.stringify({device_path: devicePath})
|
||||||
}).then(function(r){ return r.json(); })
|
}).then(function(r){ return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
@@ -407,7 +407,7 @@ function createDir() {
|
|||||||
|
|
||||||
fetch('/api/storage/attach/mkdir', {
|
fetch('/api/storage/attach/mkdir', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify({path: currentBrowsePath, name: name})
|
body: JSON.stringify({path: currentBrowsePath, name: name})
|
||||||
}).then(function(r){ return r.json(); })
|
}).then(function(r){ return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
@@ -447,7 +447,7 @@ function backToBrowse() {
|
|||||||
|
|
||||||
function cancelAttach() {
|
function cancelAttach() {
|
||||||
// Cleanup raw mount
|
// 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';
|
window.location.href = '/settings';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +482,7 @@ document.getElementById('attach-form').addEventListener('submit', function(e) {
|
|||||||
|
|
||||||
fetch('/api/storage/attach', {
|
fetch('/api/storage/attach', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
}).then(function(r){ return r.json(); })
|
}).then(function(r){ return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
@@ -572,8 +572,8 @@ function escapeAttr(s) {
|
|||||||
// Cleanup on page unload (best-effort)
|
// Cleanup on page unload (best-effort)
|
||||||
window.addEventListener('beforeunload', function() {
|
window.addEventListener('beforeunload', function() {
|
||||||
if (rawMountPath && !document.getElementById('wizard-done').style.display !== 'none') {
|
if (rawMountPath && !document.getElementById('wizard-done').style.display !== 'none') {
|
||||||
// Best-effort cleanup via sendBeacon
|
// Best-effort cleanup via fetch (sendBeacon can't send CSRF headers)
|
||||||
navigator.sendBeacon('/api/storage/attach/cancel');
|
fetch('/api/storage/attach/cancel', {method:'POST', headers: csrfHeaders(), keepalive: true}).catch(function(){});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ function scanDisks() {
|
|||||||
errEl.style.display = 'none';
|
errEl.style.display = 'none';
|
||||||
resultEl.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(r){ return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
btn.textContent = '🔍 Meghajtók keresése';
|
btn.textContent = '🔍 Meghajtók keresése';
|
||||||
@@ -242,7 +242,7 @@ document.getElementById('init-form').addEventListener('submit', function(e) {
|
|||||||
|
|
||||||
fetch('/api/storage/init', {
|
fetch('/api/storage/init', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
}).then(function(r){ return r.json(); })
|
}).then(function(r){ return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
|
|||||||
Reference in New Issue
Block a user