Users, with their own settings, and a list of where each is signed in
The login is a user name and a password. The old single password becomes the account named after the OS user on first boot, along with its sessions and the machine-wide look. Settings gains Users (add, remove, reset a password — your own password confirms) and Sessions (device, IP, last seen; revoke one or sign out everywhere else). Theme, accents and the dashboard layout follow the account; the refresh rate, graph history and alerts stay the machine's. Pairing a machine takes a user on it. Log lines name the actor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
db214ebc2b
commit
2b54a06821
+196
-64
@@ -90,12 +90,21 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
if s.user == "" {
|
||||
s.user = "?"
|
||||
}
|
||||
s.migrateUsers()
|
||||
s.sensors.Add(newStatusSensors(s))
|
||||
s.alerts = newAlertEngine(s)
|
||||
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
||||
var raw json.RawMessage
|
||||
var histSec int
|
||||
st.View(func(x *store.State) { raw, histSec = x.Dashboard, x.HistorySec })
|
||||
st.View(func(x *store.State) {
|
||||
raw, histSec = x.Dashboard, x.HistorySec
|
||||
if len(raw) == 0 {
|
||||
for _, u := range x.Users {
|
||||
raw = u.Dashboard
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
s.applyPrefs(raw)
|
||||
if histSec > 0 {
|
||||
col.SetWindow(time.Duration(histSec) * time.Second)
|
||||
@@ -106,7 +115,15 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
// happened to open its Dashboard.
|
||||
var samplerMs int
|
||||
var uiRaw json.RawMessage
|
||||
st.View(func(x *store.State) { samplerMs, uiRaw = x.SamplerMs, x.UI })
|
||||
st.View(func(x *store.State) {
|
||||
samplerMs, uiRaw = x.SamplerMs, x.UI
|
||||
if len(uiRaw) == 0 && len(x.Users) > 0 { // any account's saved rate, before the machine had one of its own
|
||||
for _, u := range x.Users {
|
||||
uiRaw = u.UI
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
if samplerMs > 0 {
|
||||
col.SetInterval(time.Duration(samplerMs) * time.Millisecond)
|
||||
}
|
||||
@@ -114,7 +131,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
var ui struct {
|
||||
RefreshMs float64 `json:"refreshMs"`
|
||||
}
|
||||
if json.Unmarshal(uiRaw, &ui) == nil && ui.RefreshMs >= 100 {
|
||||
if samplerMs > 0 {
|
||||
go s.setSampler(samplerMs, false)
|
||||
} else if json.Unmarshal(uiRaw, &ui) == nil && ui.RefreshMs >= 100 {
|
||||
go s.setSampler(int(ui.RefreshMs), false)
|
||||
}
|
||||
go s.keepFleetInStep()
|
||||
@@ -233,6 +252,13 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
m.HandleFunc("GET /api/procs", s.auth(s.procList))
|
||||
m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal))
|
||||
m.HandleFunc("POST /api/password", s.auth(s.changePassword))
|
||||
m.HandleFunc("GET /api/users", s.auth(s.listUsers))
|
||||
m.HandleFunc("POST /api/users", s.auth(s.addUser))
|
||||
m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser))
|
||||
m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword))
|
||||
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
|
||||
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
|
||||
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
|
||||
m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs))
|
||||
m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs))
|
||||
// Go runtime profiles, behind the login, for chasing memory/CPU (bug 14).
|
||||
@@ -320,21 +346,24 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m
|
||||
// auth accepts the browser session cookie or a bearer API token issued to a hub.
|
||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) {
|
||||
next(w, r)
|
||||
return
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if ses, ok := s.sessions.Lookup(c.Value, clientKey(r)); ok {
|
||||
next(w, withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value}))
|
||||
return
|
||||
}
|
||||
}
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
var hashes []string
|
||||
var hashes, names []string
|
||||
s.cfg.View(func(c *config.Config) {
|
||||
for _, t := range c.Tokens {
|
||||
if t.Scope == "" { // a scoped token is not a key to the machine
|
||||
hashes = append(hashes, t.Hash)
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
if auth.TokenMatches(strings.TrimPrefix(h, "Bearer "), hashes) {
|
||||
next(w, r)
|
||||
if i := auth.TokenIndex(strings.TrimPrefix(h, "Bearer "), hashes); i >= 0 {
|
||||
next(w, withActor(r, actor{Token: names[i]}))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -355,9 +384,13 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ok, trusted := false, false
|
||||
var patterns []string
|
||||
if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) {
|
||||
ok, trusted = true, true
|
||||
} else {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid {
|
||||
ok, trusted = true, true
|
||||
r = withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value})
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
|
||||
if presented == "" {
|
||||
presented, fromURL = r.URL.Query().Get("token"), true
|
||||
@@ -477,7 +510,7 @@ func (s *Server) issueReadToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now(), Scope: config.ScopeSensorsRead, Sensors: pats})
|
||||
c.Tokens = append(c.Tokens, config.Token{Name: name, Hash: hash, Created: time.Now(), Scope: config.ScopeSensorsRead, Sensors: pats, IssuedBy: actorOf(r).User})
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -500,6 +533,7 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
@@ -507,13 +541,19 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "password and name required")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
|
||||
user, ok := s.checkLogin(body.Username, body.Password)
|
||||
if !ok {
|
||||
s.limiter.Fail(clientKey(r))
|
||||
log.Printf("token request failed from %s", clientIP(r))
|
||||
jsonErr(w, 401, "wrong password")
|
||||
log.Printf("token request failed for %q from %s", body.Username, clientIP(r))
|
||||
if body.Username == "" {
|
||||
jsonErr(w, 401, "wrong password, or this machine has several users: give a user name")
|
||||
return
|
||||
}
|
||||
jsonErr(w, 401, "wrong user name or password")
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(clientKey(r))
|
||||
s.touchUser(user)
|
||||
tok, hash, err := auth.NewToken()
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "token error")
|
||||
@@ -527,12 +567,12 @@ func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
c.Tokens = append(kept, config.Token{Name: name, Hash: hash, Created: time.Now()})
|
||||
c.Tokens = append(kept, config.Token{Name: name, Hash: hash, Created: time.Now(), IssuedBy: user})
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("API token %q issued to %s", name, clientIP(r))
|
||||
log.Printf("API token %q issued to %s by %s", name, clientIP(r), user)
|
||||
jsonOK(w, 200, map[string]string{"token": tok, "name": name})
|
||||
}
|
||||
|
||||
@@ -540,7 +580,7 @@ func (s *Server) listTokens(w http.ResponseWriter, _ *http.Request) {
|
||||
var out []map[string]any
|
||||
s.cfg.View(func(c *config.Config) {
|
||||
for _, t := range c.Tokens {
|
||||
out = append(out, map[string]any{"name": t.Name, "created": t.Created, "scope": t.Scope, "sensors": t.Sensors})
|
||||
out = append(out, map[string]any{"name": t.Name, "created": t.Created, "scope": t.Scope, "sensors": t.Sensors, "issued_by": t.IssuedBy})
|
||||
}
|
||||
})
|
||||
if out == nil {
|
||||
@@ -578,12 +618,12 @@ func (s *Server) listNodes(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) addNode(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Name, URL, Password string }
|
||||
var body struct{ Name, URL, Username, Password string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
st, err := s.nodes.Add(r.Context(), strings.TrimSpace(body.Name), body.URL, body.Password)
|
||||
st, err := s.nodes.Add(r.Context(), strings.TrimSpace(body.Name), body.URL, strings.TrimSpace(body.Username), body.Password)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
@@ -746,27 +786,101 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
|
||||
name, ok := s.checkLogin(body.Username, body.Password)
|
||||
if !ok {
|
||||
s.limiter.Fail(clientKey(r))
|
||||
log.Printf("login failed from %s", clientIP(r))
|
||||
jsonErr(w, 401, "wrong password")
|
||||
log.Printf("login failed for %q from %s", body.Username, clientIP(r))
|
||||
jsonErr(w, 401, "wrong user name or password")
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(clientKey(r))
|
||||
tok, err := s.sessions.Create()
|
||||
tok, err := s.sessions.Create(name, clientKey(r), r.UserAgent())
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "session error")
|
||||
return
|
||||
}
|
||||
s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds()))
|
||||
log.Printf("login ok from %s", clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
s.touchUser(name)
|
||||
log.Printf("login ok: %s from %s", name, clientIP(r))
|
||||
jsonOK(w, 200, map[string]string{"user": name})
|
||||
}
|
||||
|
||||
// checkLogin verifies a user name and password, returning the account. An empty user
|
||||
// name is allowed only while there is exactly one account — what a client from before
|
||||
// user names sends, and what a hub pairing with a one-user node may leave out.
|
||||
func (s *Server) checkLogin(username, password string) (string, bool) {
|
||||
var users []config.User
|
||||
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) })
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" && len(users) == 1 {
|
||||
username = users[0].Name
|
||||
}
|
||||
if username == "" {
|
||||
auth.CheckUser(nil, "", password) // take as long as a real check
|
||||
return "", false
|
||||
}
|
||||
return username, auth.CheckUser(users, username, password)
|
||||
}
|
||||
|
||||
// touchUser notes that an account was just used.
|
||||
func (s *Server) touchUser(name string) {
|
||||
_ = s.cfg.Update(func(c *config.Config) {
|
||||
for i := range c.Users {
|
||||
if c.Users[i].Name == name {
|
||||
c.Users[i].LastSeen = time.Now()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// migrateUsers is the one-time move from a single shared password to accounts: the old
|
||||
// hash becomes the account named after the OS user the console runs as, the sessions that
|
||||
// were signed in with it become that account's, and the machine-wide look becomes theirs.
|
||||
func (s *Server) migrateUsers() {
|
||||
first := ""
|
||||
changed := false
|
||||
if err := s.cfg.Update(func(c *config.Config) {
|
||||
changed = c.MigrateUsers(s.user)
|
||||
if len(c.Users) > 0 {
|
||||
first = c.Users[0].Name
|
||||
}
|
||||
}); err != nil {
|
||||
log.Printf("users: %v", err)
|
||||
}
|
||||
if changed {
|
||||
log.Printf("users: the login password is now the account %q", first)
|
||||
}
|
||||
if first == "" {
|
||||
return
|
||||
}
|
||||
_ = s.store.Update(func(st *store.State) {
|
||||
for tok, ses := range st.Sessions {
|
||||
if ses.User == "" {
|
||||
ses.User = first
|
||||
if ses.ID == "" {
|
||||
ses.ID = tok[:12] // an old session has no ID of its own; a slice of the token is not usable as one
|
||||
}
|
||||
st.Sessions[tok] = ses
|
||||
}
|
||||
}
|
||||
if len(st.UI) > 0 || len(st.Dashboard) > 0 {
|
||||
u := st.UserState(first)
|
||||
if len(u.UI) == 0 {
|
||||
u.UI = st.UI
|
||||
}
|
||||
if len(u.Dashboard) == 0 {
|
||||
u.Dashboard = st.Dashboard
|
||||
}
|
||||
st.UI, st.Dashboard = nil, nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -790,11 +904,16 @@ func clientKey(r *http.Request) string {
|
||||
return host
|
||||
}
|
||||
|
||||
// clientIP names the caller for a log line: who (once signed in) and from where.
|
||||
func clientIP(r *http.Request) string {
|
||||
ip := r.RemoteAddr
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
|
||||
ip = strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
|
||||
}
|
||||
return r.RemoteAddr
|
||||
if who := actorOf(r).String(); who != "" {
|
||||
return who + "@" + ip
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// ---- me ----
|
||||
@@ -818,7 +937,7 @@ func (s *Server) hostName() string {
|
||||
return s.host
|
||||
}
|
||||
|
||||
func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
load := ""
|
||||
if b, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
f := strings.Fields(string(b))
|
||||
@@ -841,7 +960,7 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
})
|
||||
jsonOK(w, 200, map[string]any{
|
||||
"host": s.hostName(), "user": s.user, "version": s.version,
|
||||
"host": s.hostName(), "user": s.user, "account": actorOf(r).User, "version": s.version,
|
||||
"load": load, "uptime": uptime, "service_started": s.started,
|
||||
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "demo_url": demoURL, "addr": addr,
|
||||
})
|
||||
@@ -1876,38 +1995,49 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Current) {
|
||||
jsonErr(w, 403, "current password is wrong")
|
||||
if !s.confirmSelf(w, r, body.Current) {
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 12 {
|
||||
if len(body.Password) < auth.MinPasswordLen {
|
||||
jsonErr(w, 400, "password must be at least 12 characters")
|
||||
return
|
||||
}
|
||||
h, err := auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
if !s.setPassword(w, actorOf(r).User, body.Password) {
|
||||
return
|
||||
}
|
||||
if err := s.cfg.Update(func(c *config.Config) { c.PasswordHash = h }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("password changed (from %s)", clientIP(r))
|
||||
log.Printf("password changed (by %s)", clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// ---- ui prefs (theme) ----
|
||||
|
||||
func (s *Server) getUIPrefs(w http.ResponseWriter, _ *http.Request) {
|
||||
var raw json.RawMessage
|
||||
s.store.View(func(st *store.State) { raw = st.UI })
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage("{}")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// The look is the account's: theme and accents follow whoever is signed in. The refresh
|
||||
// rate rides in the same object for the client's convenience but is the machine's — it is
|
||||
// how often every machine samples — so it is kept once, machine-wide, and folded in here.
|
||||
func (s *Server) getUIPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
prefs := s.userPrefs(r, func(u *store.UserState) json.RawMessage { return u.UI })
|
||||
prefs["refreshMs"] = s.metrics.Interval().Milliseconds()
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(raw)
|
||||
jsonOK(w, 200, prefs)
|
||||
}
|
||||
|
||||
// userPrefs reads one of the caller's pref blobs as a map (empty when none, or for a token).
|
||||
func (s *Server) userPrefs(r *http.Request, pick func(*store.UserState) json.RawMessage) map[string]any {
|
||||
out := map[string]any{}
|
||||
user := actorOf(r).User
|
||||
if user == "" {
|
||||
return out
|
||||
}
|
||||
var raw json.RawMessage
|
||||
s.store.View(func(st *store.State) {
|
||||
if u := st.Users[user]; u != nil {
|
||||
raw = pick(u)
|
||||
}
|
||||
})
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1929,9 +2059,15 @@ func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "theme must be a short identifier")
|
||||
return
|
||||
}
|
||||
if err := s.store.Update(func(st *store.State) { st.UI = json.RawMessage(b) }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
if user := actorOf(r).User; user != "" {
|
||||
var m map[string]json.RawMessage
|
||||
_ = json.Unmarshal(b, &m)
|
||||
delete(m, "refreshMs")
|
||||
kept, _ := json.Marshal(m)
|
||||
if err := s.store.Update(func(st *store.State) { st.UserState(user).UI = kept }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if p.RefreshMs != nil { // the refresh rate is also how often every machine samples
|
||||
go s.setSampler(int(*p.RefreshMs), true)
|
||||
@@ -2087,15 +2223,9 @@ func (s *Server) pushHistoryWindow(seconds int) {
|
||||
|
||||
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
|
||||
|
||||
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
|
||||
var raw json.RawMessage
|
||||
s.store.View(func(st *store.State) { raw = st.Dashboard })
|
||||
if len(raw) == 0 {
|
||||
raw = json.RawMessage("{}")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
func (s *Server) getDashPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(raw)
|
||||
jsonOK(w, 200, s.userPrefs(r, func(u *store.UserState) json.RawMessage { return u.Dashboard }))
|
||||
}
|
||||
|
||||
func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -2104,9 +2234,11 @@ func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "expected a JSON object")
|
||||
return
|
||||
}
|
||||
if err := s.store.Update(func(st *store.State) { st.Dashboard = json.RawMessage(b) }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
if user := actorOf(r).User; user != "" {
|
||||
if err := s.store.Update(func(st *store.State) { st.UserState(user).Dashboard = json.RawMessage(b) }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
s.applyPrefs(b)
|
||||
w.WriteHeader(204)
|
||||
|
||||
Reference in New Issue
Block a user