hub v0.3.8 — CSRF protection + secure session model
- server.go: replace literal hub_session=authenticated with random 64-char hex
session tokens stored server-side (hubSession map + sync.RWMutex); per-session
CSRF tokens; CleanupSessions goroutine; SameSite=Lax+Secure cookie; CSRF
validation in ServeHTTP; csrfToken/csrfField helpers
- configs.go: add html/template import; pass CSRFField/CSRFToken to all template
renders; renderConfigForm gains r *http.Request parameter
- config_form.html: {{.CSRFField}} in form
- customer_unified.html: meta csrf-token + csrfHeaders() JS; {{.CSRFField}} in
all 5 POST forms; csrfHeaders() on 3 fetch calls
- main.go: start CleanupSessions goroutine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -158,6 +158,24 @@ The asset manager (`internal/assets/`) scans the assets directory on startup, bu
|
||||
|
||||
Protected by bcrypt password + session cookie (7-day expiry).
|
||||
|
||||
### Authentication & Session Model (`internal/web/server.go`)
|
||||
|
||||
- Login generates a **cryptographically random 64-char hex session token** stored server-side in a `map[string]*hubSession` (+ `sync.RWMutex`). The old literal `hub_session=authenticated` cookie is gone.
|
||||
- Each session also stores a **per-session CSRF token** (separate 64-char hex random value).
|
||||
- Cookie attributes: `SameSite=Lax`, `Secure` (when TLS), `HttpOnly`, 7-day `Max-Age`.
|
||||
- `RequireAuth` middleware validates the session token with `subtle.ConstantTimeCompare` and redirects to `/login` on failure.
|
||||
- `CleanupSessions(ctx)` goroutine runs hourly to purge expired sessions.
|
||||
|
||||
### CSRF Protection (`internal/web/server.go`)
|
||||
|
||||
Synchronizer-token CSRF protection on all browser POST/DELETE/PATCH operations:
|
||||
|
||||
- CSRF validation block runs at the top of `ServeHTTP` before routing.
|
||||
- Skipped when: no session cookie present (API/Basic-Auth path); or safe methods (GET/HEAD/OPTIONS).
|
||||
- Token read from `_csrf` form field or `X-CSRF-Token` request header.
|
||||
- On failure: JSON `{"ok":false,"error":"CSRF token missing or invalid"}` for `/api/` paths; HTTP 403 text otherwise.
|
||||
- Template delivery: `csrfToken(r)` and `csrfField(r)` helpers inject `CSRFToken` and `CSRFField` into every render data struct via `configs.go`. Templates use `{{.CSRFField}}` in forms and `csrfHeaders()` JS helper for fetch calls.
|
||||
|
||||
### Pages
|
||||
|
||||
- **Dashboard (`/`)** — Fleet overview table showing all customers with live status and event count badges (error+warning in last 24h). Config-only customers (no reports yet) appear as "PENDING" with gray badge. Blocked customers are hidden. Auto-refreshes every 60 seconds.
|
||||
|
||||
@@ -192,6 +192,9 @@ func main() {
|
||||
}
|
||||
webServer.SetVersionChecker(versionChecker)
|
||||
|
||||
// Session cleanup — removes expired sessions every hour
|
||||
go webServer.CleanupSessions(ctx)
|
||||
|
||||
// Prune on startup, then daily at configured time (default 04:30)
|
||||
if cfg.Retention.MaxDays > 0 {
|
||||
pruneAll(dataStore, cfg.Retention.MaxDays, logger)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -106,10 +107,12 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
|
||||
Customers []customerListEntry
|
||||
ActiveNav string
|
||||
Flash string
|
||||
CSRFToken string
|
||||
}{
|
||||
Customers: entries,
|
||||
ActiveNav: "configs",
|
||||
Flash: r.URL.Query().Get("flash"),
|
||||
CSRFToken: s.csrfToken(r),
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "configs.html", data)
|
||||
}
|
||||
@@ -276,6 +279,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
||||
|
||||
Flash string
|
||||
ActiveNav string
|
||||
CSRFField template.HTML
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
data := pageData{
|
||||
@@ -312,6 +317,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
||||
|
||||
Flash: r.URL.Query().Get("flash"),
|
||||
ActiveNav: "configs",
|
||||
CSRFField: s.csrfField(r),
|
||||
CSRFToken: s.csrfToken(r),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -328,11 +335,13 @@ func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
Overrides map[string]interface{}
|
||||
ActiveNav string
|
||||
Error string
|
||||
CSRFField template.HTML
|
||||
}{
|
||||
IsNew: true,
|
||||
Config: &store.CustomerConfig{},
|
||||
Overrides: make(map[string]interface{}),
|
||||
ActiveNav: "configs",
|
||||
CSRFField: s.csrfField(r),
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "config_form.html", data)
|
||||
}
|
||||
@@ -346,7 +355,7 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
if customerID == "" || !validCustomerID.MatchString(customerID) {
|
||||
s.renderConfigForm(w, true, &store.CustomerConfig{
|
||||
s.renderConfigForm(w, r, true, &store.CustomerConfig{
|
||||
CustomerName: r.FormValue("customer_name"),
|
||||
Domain: r.FormValue("domain"),
|
||||
Email: r.FormValue("email"),
|
||||
@@ -357,7 +366,7 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
||||
// Check for duplicates
|
||||
existing, _ := s.store.GetCustomerConfig(customerID)
|
||||
if existing != nil {
|
||||
s.renderConfigForm(w, true, &store.CustomerConfig{
|
||||
s.renderConfigForm(w, r, true, &store.CustomerConfig{
|
||||
CustomerID: customerID,
|
||||
CustomerName: r.FormValue("customer_name"),
|
||||
Domain: r.FormValue("domain"),
|
||||
@@ -418,11 +427,13 @@ func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, cu
|
||||
Overrides map[string]interface{}
|
||||
ActiveNav string
|
||||
Error string
|
||||
CSRFField template.HTML
|
||||
}{
|
||||
IsNew: false,
|
||||
Config: cfg,
|
||||
Overrides: overrides,
|
||||
ActiveNav: "configs",
|
||||
CSRFField: s.csrfField(r),
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "config_form.html", data)
|
||||
}
|
||||
@@ -655,7 +666,7 @@ func (s *Server) handleCreateConfigFromReport(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
// renderConfigForm is a helper to re-render the form with an error.
|
||||
func (s *Server) renderConfigForm(w http.ResponseWriter, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) {
|
||||
func (s *Server) renderConfigForm(w http.ResponseWriter, r *http.Request, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) {
|
||||
if overrides == nil {
|
||||
overrides = make(map[string]interface{})
|
||||
}
|
||||
@@ -665,12 +676,14 @@ func (s *Server) renderConfigForm(w http.ResponseWriter, isNew bool, cfg *store.
|
||||
Overrides map[string]interface{}
|
||||
ActiveNav string
|
||||
Error string
|
||||
CSRFField template.HTML
|
||||
}{
|
||||
IsNew: isNew,
|
||||
Config: cfg,
|
||||
Overrides: overrides,
|
||||
ActiveNav: "configs",
|
||||
Error: errMsg,
|
||||
CSRFField: s.csrfField(r),
|
||||
}
|
||||
s.templates.ExecuteTemplate(w, "config_form.html", data)
|
||||
}
|
||||
|
||||
+139
-15
@@ -1,6 +1,10 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
@@ -10,12 +14,19 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// hubSession holds per-session auth and CSRF data.
|
||||
type hubSession struct {
|
||||
expiresAt time.Time
|
||||
csrfToken string
|
||||
}
|
||||
|
||||
// Server handles the dashboard web UI.
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
@@ -27,6 +38,9 @@ type Server struct {
|
||||
staleThreshold time.Duration
|
||||
versionChecker *VersionChecker
|
||||
templateFetcher *TemplateFetcher
|
||||
|
||||
sessions map[string]*hubSession
|
||||
sessionsMu sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates a new web server.
|
||||
@@ -61,6 +75,28 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
|
||||
logger: logger,
|
||||
templates: tmpl,
|
||||
staleThreshold: staleThreshold,
|
||||
sessions: make(map[string]*hubSession),
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupSessions removes expired sessions. Call with: go s.CleanupSessions(ctx).
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +114,18 @@ func (s *Server) SetTemplateFetcher(tf *TemplateFetcher) {
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
// CSRF protection for all state-changing requests (web routes only).
|
||||
// API routes (/api/v1/) are Bearer-token authenticated and exempt.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case path == "/":
|
||||
s.handleDashboard(w, r)
|
||||
@@ -190,7 +238,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth wraps a handler with basic authentication.
|
||||
// RequireAuth wraps a handler with session or basic authentication.
|
||||
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth if no password configured
|
||||
@@ -199,49 +247,125 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Check session cookie
|
||||
if cookie, err := r.Cookie("hub_session"); err == nil && cookie.Value == "authenticated" {
|
||||
// Always allow the login page through (GET and POST)
|
||||
if r.URL.Path == "/login" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check basic auth
|
||||
// Check session cookie (random token stored server-side)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Check basic auth (for programmatic/CLI access)
|
||||
_, password, ok := r.BasicAuth()
|
||||
if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Show login page for browser requests
|
||||
if r.URL.Path == "/login" && r.Method == http.MethodPost {
|
||||
s.handleLogin(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect browsers to login page; send 401 for API-like requests
|
||||
if r.Header.Get("Accept") == "application/json" || r.Header.Get("X-Requested-With") != "" {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
if s.passwordHash != "" && 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: "authenticated",
|
||||
Value: sessionToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: 86400 * 7, // 7 days
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: isSecure,
|
||||
MaxAge: 86400 * 7,
|
||||
})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Invalid password", http.StatusUnauthorized)
|
||||
// Render login with error
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><p style="color:red">Hibás jelszó</p><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0088cc;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<html><body><form method="post"><input type="password" name="password"><button>Login</button></form></body></html>`))
|
||||
w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0088cc;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
|
||||
}
|
||||
|
||||
// validateCSRF checks the CSRF token for a session-based request.
|
||||
// Returns true if CSRF is valid or if no session cookie is present (Basic Auth path).
|
||||
func (s *Server) validateCSRF(r *http.Request) bool {
|
||||
cookie, err := r.Cookie("hub_session")
|
||||
if err != nil {
|
||||
// No session cookie — likely Basic Auth or programmatic access; skip CSRF
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 embedding in 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) + `">`)
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="{{if .IsNew}}/configs/new{{else}}/configs/{{.Config.CustomerID}}/edit{{end}}" class="config-form">
|
||||
{{.CSRFField}}
|
||||
<div class="card">
|
||||
<h2>Customer Identity</h2>
|
||||
<div class="form-grid">
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<title>{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} — Felhom Hub</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
{{if .HasReports}}<meta http-equiv="refresh" content="60">{{end}}
|
||||
<meta name="csrf-token" content="{{.CSRFToken}}">
|
||||
<script>function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -52,20 +54,24 @@
|
||||
<a href="/configs/{{.CustomerID}}/edit" class="btn btn-outline btn-sm">Edit</a>
|
||||
{{if .IsBlocked}}
|
||||
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-sm">Unblock</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/customers/{{.CustomerID}}/block" style="display:inline"
|
||||
onsubmit="return confirm('Block this customer? They will be hidden from the Dashboard.')">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-outline btn-sm">Block</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Delete configuration for {{.CustomerID}}? This cannot be undone.')">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/customers/{{.CustomerID}}/create-config" style="display:inline">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-sm">Create Config</button>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -301,6 +307,7 @@
|
||||
</div>
|
||||
<form method="POST" action="/configs/{{.CustomerID}}/regen-password" style="margin-top: 0.5rem;"
|
||||
onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-outline btn-sm">Regenerate</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -560,7 +567,7 @@
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Triggering...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/customers/' + customerID + '/trigger-update', {method: 'POST'})
|
||||
fetch('/customers/' + customerID + '/trigger-update', {method: 'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
@@ -591,7 +598,7 @@
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Pushing...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/customers/' + customerID + '/push-config', {method: 'POST'})
|
||||
fetch('/customers/' + customerID + '/push-config', {method: 'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
@@ -622,7 +629,7 @@
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Pulling...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/customers/' + customerID + '/pull-config', {method: 'POST'})
|
||||
fetch('/customers/' + customerID + '/pull-config', {method: 'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
|
||||
Reference in New Issue
Block a user