Files
vpsmgr/src/internal/panel/handlers.go
T
bluebluesodaandCommandCodeBot c2009ddc69 chore(copy): tighten 1.8 wording, drop the "was X (not Y)" asides
- admin CPU card: the "what saving does depends on the checkbox" footnote is
  gone; the state line says it instead, and the state/description text is
  shorter
- user panel: the reinstall share field is just "或从分享的检查点安装" and its
  note "将从该检查点恢复,不安装系统。"; the share warning no longer parenthesises
  the contents ("拿到分享码的人可以读取该检查点内的全部数据,包括容器里的凭据。"),
  the second confirmation is "确认删除 / 更早的 N 个检查点会被删除,无法恢复。",
  the revoke prompt drops the repeated question, and the restore/reset prompts
  lose their "…,确认?"/"(重置后无法恢复)" tails
- the share toast only mentions removed checkpoints when there were some
  (previously "share code created (0 older checkpoints removed)")
- expiry: "账户已到期,机器已关机并锁定。" and "该账户已到期,仅可延期。";the
  "new deadline = max(now, current) + duration" footnote and "(0 = permanent)"
  parentheticals are gone
- knowledge editor: label is a plain "Markdown: headings, lists, …" legend and
  the empty preview is a dash instead of an instruction
- docs: the 1.8 sections (expiry, snapshot sharing, refquota, sessions, CPU
  rule, knowledge base) are cut roughly in half

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-16 07:12:06 +00:00

755 lines
24 KiB
Go

package panel
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"vpsmgr/internal/cfg"
"vpsmgr/internal/db"
"vpsmgr/internal/mgr"
"vpsmgr/internal/pw"
)
func itoa(n int) string { return strconv.Itoa(n) }
type ctxKey int
const (
userKey ctxKey = 0
// impersonatedKey marks a session created by the operator ("log in as
// user"). When set, audit events are attributed "000+<user>" and the panel
// shows a "logged in as" banner.
impersonatedKey ctxKey = 1
// expiredKey marks a session whose quota validity has passed. An expired
// account is locked to read-only; only the admin can extend or delete it.
expiredKey ctxKey = 2
)
// loginDummyHash is compared against when the username is unknown so that a
// login attempt takes the same time whether or not the account exists,
// defeating username enumeration via response timing.
var loginDummyHash = func() string { h, _ := pw.Hash("vpsmgr-timing-pad"); return h }()
// storeFlash persists a one-shot banner for the request's session. An empty
// msg clears any pending banner (e.g. on logout). Banners live in a short-lived
// in-memory store and are fetched by the frontend, so they never leak into
// URLs, cookies or browser history.
func (s *Server) storeFlash(r *http.Request, msg, kind string) {
if c, err := r.Cookie("vpsmgr_session"); err == nil {
if msg == "" {
s.flash.Clear(c.Value)
return
}
s.flash.Set(c.Value, msg, kind)
}
}
func (s *Server) handleFlash(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("vpsmgr_session")
if err != nil {
http.Error(w, "", http.StatusUnauthorized)
return
}
msg, kind, _ := s.flash.Pop(c.Value)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Msg string `json:"msg"`
Kind string `json:"kind"`
}{msg, kind})
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("vpsmgr_session")
if err != nil {
s.redirect(w, r, s.p("/login"), "")
return
}
u, imp, err := s.db.SessionWithFlag(c.Value)
if err != nil {
s.clearSessionCookie(w)
s.redirect(w, r, s.p("/login"), "")
return
}
ctx := context.WithValue(r.Context(), userKey, u)
ctx = context.WithValue(ctx, impersonatedKey, imp)
ctx = context.WithValue(ctx, expiredKey, mgr.IsExpired(u.ExpiresAt, time.Now().UTC()))
next(w, r.WithContext(ctx))
}
}
// isExpired reports whether the current account's quota validity has passed.
func (s *Server) isExpired(r *http.Request) bool {
v, _ := r.Context().Value(expiredKey).(bool)
return v
}
// requireActive rejects state-changing requests from an expired account. An
// expired container is locked to read-only (the admin can still extend or
// delete it); read-only GET routes stay reachable so the user sees the reason.
func (s *Server) requireActive(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.isExpired(r) {
s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_account_expired"))
return
}
next(w, r)
}
}
// isImpersonated reports whether the current session was created by the
// operator ("log in as user").
func (s *Server) isImpersonated(r *http.Request) bool {
v, _ := r.Context().Value(impersonatedKey).(bool)
return v
}
// auditActor returns the actor name for an audit event: the plain username for
// a user's own action, or "000+<username>" when the operator is acting as that
// user (impersonation). Mirrors the admin panel's "000+" convention.
func (s *Server) auditActor(r *http.Request, name string) string {
if s.isImpersonated(r) {
return "000+" + name
}
return name
}
// requirePost rejects everything but POST so no state-changing action can be
// triggered by a top-level GET navigation (CSRF via SameSite=Lax). A wrong
// method gets the same bare 404 as any other wrong path — never a 405, which
// would advertise that a POST-only endpoint exists here.
//
// It also caps the request body: every panel form is small (the largest,
// init script, is bounded to 64 KiB at the manager layer), so a 1 MiB limit
// rejects oversized submissions early — before ParseForm allocates anything.
func (s *Server) requirePost(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
featureless404(w)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
next(w, r)
}
}
func (s *Server) currentUser(r *http.Request) *db.User {
u, _ := r.Context().Value(userKey).(*db.User)
return u
}
func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: "vpsmgr_session",
Value: token,
Path: s.prefix(),
MaxAge: s.cfg.Panel.SessionDays * 86400,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "vpsmgr_session",
Value: "",
MaxAge: -1,
Path: s.prefix(),
HttpOnly: true,
Secure: true,
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
ip := clientIP(r)
s.limiter.prune()
if !s.limiter.allowed(ip) {
s.renderStatus(w, r, http.StatusTooManyRequests, "login.html",
pageData{Title: "Login", Prefix: s.prefix(), Err: s.t(r, "err_too_many")})
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
name := r.FormValue("username")
pass := r.FormValue("password")
u, err := s.db.GetUserByName(name)
var ok bool
if err == nil {
ok = pw.Verify(u.PassHash, pass)
} else {
// Burn the same bcrypt time as a real check so unknown usernames
// are not distinguishable by timing.
pw.Verify(loginDummyHash, pass)
}
if ok {
sess, err := s.db.CreateSession(u.ID, s.cfg.Panel.SessionDays)
if err == nil {
s.setSessionCookie(w, sess.Token)
_ = s.db.AddAuditLog(u.Name, "session.login")
s.redirect(w, r, s.p(""), "")
return
}
}
s.render(w, r, "login.html", pageData{Title: "Login", Prefix: s.prefix(), Err: s.t(r, "err_bad_login")})
return
}
s.render(w, r, "login.html", pageData{Title: "Login", Prefix: s.prefix()})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("vpsmgr_session"); err == nil {
s.db.DeleteSession(c.Value)
}
s.clearSessionCookie(w)
s.redirect(w, r, s.p("/login"), "")
}
func (s *Server) handleSwitchUser(w http.ResponseWriter, r *http.Request) {
current := s.currentUser(r)
targetName := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
members, err := s.mgr.UsersInGroup(current.Name)
if err != nil {
s.redirect(w, r, s.p("/"), "error: unable to switch account")
return
}
var target *db.User
for _, member := range members {
if member.Name == targetName {
target = member
break
}
}
if target == nil {
s.redirect(w, r, s.p("/"), "error: account is not in this user group")
return
}
oldToken := ""
if cookie, err := r.Cookie("vpsmgr_session"); err == nil {
oldToken = cookie.Value
}
var sess *db.Session
if s.isImpersonated(r) {
sess, err = s.db.CreateImpersonatedSession(target.ID, s.cfg.Panel.SessionDays)
} else {
sess, err = s.db.CreateSession(target.ID, s.cfg.Panel.SessionDays)
}
if err != nil {
s.redirect(w, r, s.p("/"), "error: unable to switch account")
return
}
if oldToken != "" {
if err := s.db.DeleteSession(oldToken); err != nil {
s.db.DeleteSession(sess.Token)
s.redirect(w, r, s.p("/"), "error: unable to switch account")
return
}
}
s.setSessionCookie(w, sess.Token)
_ = s.db.AddAuditLog(s.auditActor(r, current.Name), "session.switch."+target.Name)
http.Redirect(w, r, s.p("/"), http.StatusFound)
}
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
d := s.buildData(u, "", "")
d.Impersonated = s.isImpersonated(r)
d.AdminPrefix = "/" + s.cfg.Panel.AdminPath
// The per-user accent color exists so an impersonating operator can tell
// users apart at a glance. Users logging in with their own password get the
// default theme instead of their assigned color.
if !d.Impersonated {
d.ThemeColor = ""
}
s.render(w, r, "overview.html", d)
}
func (s *Server) handlePower(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
action := r.FormValue("action")
var msg string
if err := s.mgr.Power(u.Name, action); err != nil {
msg = "error: " + err.Error()
} else {
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "power."+action)
msg = "ok: " + action
}
s.redirect(w, r, s.p(""), msg)
}
func (s *Server) handleReinstall(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
if r.FormValue("confirm") != "1" {
s.redirect(w, r, s.p(""), "error: please confirm reinstall")
return
}
// "Install from a shared checkpoint": the code replaces the image choice.
// A code pointing at this very container is handled as a restore and yields
// no new root password.
if share := strings.TrimSpace(r.FormValue("share")); share != "" {
// Resolve first so the audit row records whose checkpoint was installed.
owner, _, _ := s.mgr.ResolveShare(share)
pass, err := s.mgr.ReinstallFromShare(u.Name, share)
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
action := "reinstall.share"
if owner != "" {
action += "." + owner
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), action)
if pass == "" {
s.redirect(w, r, s.p(""), s.t(r, "snapshot_restored"))
return
}
s.redirectModal(w, r, s.p(""), s.t(r, "reinstall_done", pass))
return
}
pass, err := s.mgr.Reinstall(u.Name, r.FormValue("image"))
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "reinstall")
s.redirectModal(w, r, s.p(""), s.t(r, "reinstall_done", pass))
}
// handlePanelPassword changes only the panel login password (must be >= 10
// chars with an uppercase letter, a lowercase letter and a digit) and kicks
// all other sessions. Container root password is untouched.
func (s *Server) handlePanelPassword(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
pass := r.FormValue("new_password")
confirm := r.FormValue("confirm_password")
if pass != confirm {
s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_pass_mismatch"))
return
}
switch reason := pw.Validate(pass); reason {
case pw.RejectTooShort:
s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_pass_short"))
return
case pw.RejectWeak:
s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_pass_weak"))
return
}
token := ""
if c, err := r.Cookie("vpsmgr_session"); err == nil {
token = c.Value
}
if err := s.mgr.ChangePanelPassword(u.Name, pass, token); err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "passwd.change")
s.redirect(w, r, s.p(""), "ok: panel password changed")
}
// handleRootReset regenerates the container root password and shows it once
// in a modal.
func (s *Server) handleRootReset(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
pass, err := s.mgr.ResetRootPassword(u.Name)
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "reset_root_password")
s.redirectModal(w, r, s.p(""), s.t(r, "new_root_password", pass))
}
func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
proxyProtocol := r.FormValue("proxy_protocol") == "1"
if err := s.mgr.AddDomain(u.Name, r.FormValue("domain"), proxyProtocol); err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "domain.add")
s.redirect(w, r, s.p(""), "ok: domain added")
}
// handleDomainUpdate applies the batch PROXY protocol toggle: the form posts
// one `proto` checkbox per domain (only the checked ones), the handler diffs
// them against the DB and applies the changed ones, each atomically.
func (s *Server) handleDomainUpdate(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
checked := map[string]bool{}
for _, d := range r.Form["proto"] {
checked[d] = true
}
domains, err := s.db.ListDomains(u.ID)
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
changed := 0
for _, dmn := range domains {
on := checked[dmn.Domain]
if on != dmn.ProxyProtocol {
if err := s.mgr.SetDomainProtocol(u.Name, dmn.Domain, on); err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
changed++
}
}
if changed == 0 {
s.redirect(w, r, s.p(""), "ok: no changes")
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "domain.update")
s.redirect(w, r, s.p(""), "ok: domain settings saved")
}
func (s *Server) handleDomainDel(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
if err := s.mgr.DelDomain(u.Name, r.FormValue("domain")); err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "domain.delete")
s.redirect(w, r, s.p(""), "ok: domain removed")
}
// handleInitScript saves the current user's custom init script (run inside
// their container after a reinstall). It always operates on the session's own
// user — the form carries no name, so no one can edit another user's script.
// The form POST (page reload) is kept for compatibility; the machine panel
// uses a JSON POST that answers with {ok,error} so the modal can stay open.
func (s *Server) handleInitScript(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
jsonReq := strings.HasPrefix(r.Header.Get("Content-Type"), "application/json")
var script string
if jsonReq {
var req struct {
Script string `json:"script"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeInitScriptJSON(w, false, "bad request")
return
}
script = req.Script
} else {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
script = r.FormValue("script")
}
if err := s.mgr.SetInitScript(u.Name, script); err != nil {
if jsonReq {
writeInitScriptJSON(w, false, err.Error())
} else {
s.redirect(w, r, s.p(""), "error: "+err.Error())
}
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "init_script")
if jsonReq {
writeInitScriptJSON(w, true, "")
} else {
s.redirect(w, r, s.p(""), "ok: init script saved")
}
}
func writeInitScriptJSON(w http.ResponseWriter, ok bool, errMsg string) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}{ok, errMsg})
}
// handleImages returns the OS images available for reinstall. It is fetched
// lazily when the user opens the reinstall dialog, not on every page load, so
// an Incus image listing only happens when actually needed. Default is the
// configured image when it exists, else the first offered one, so the picker
// always has a checked entry.
func (s *Server) handleImages(w http.ResponseWriter, r *http.Request) {
imgs := s.mgr.Images()
def := s.cfg.Incus.Image
if len(imgs) > 0 && !containsAlias(imgs, def) {
def = imgs[0].Alias
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Images []mgr.ManagedImage `json:"images"`
Default string `json:"default"`
}{imgs, def})
}
func containsAlias(imgs []mgr.ManagedImage, alias string) bool {
for _, im := range imgs {
if im.Alias == alias {
return true
}
}
return false
}
// handleStats returns the last 24h of per-minute resource history for the
// current user, for the usage charts on the overview page. Pure DB read — no
// Incus call. Points are [{m, c, y, r, t}] with minute-floored unix seconds,
// CPU percent (-1 = unknown), memory MiB (-1 = unknown) and per-minute rx/tx
// bytes derived from the cumulative counter deltas.
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
points, err := s.mgr.ChartHistory(u.ID, time.Now().Add(-24*time.Hour))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Points []mgr.ChartPoint `json:"points"`
}{points})
}
// handleSnapshot creates a disk-only snapshot of the user's container. The
// snapshot name is auto-generated, so the form carries no user input.
func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
var msg string
if err := s.mgr.SnapshotCreate(u.Name); err != nil {
msg = "error: " + err.Error()
} else {
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.create")
msg = "ok: checkpoint created"
}
s.redirect(w, r, s.p(""), msg)
}
// handleSnapshotDel deletes a snapshot by name.
func (s *Server) handleSnapshotDel(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
name := r.FormValue("name")
var msg string
if err := s.mgr.SnapshotDelete(u.Name, name); err != nil {
msg = "error: " + err.Error()
} else {
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.delete")
msg = "ok: checkpoint deleted"
}
s.redirect(w, r, s.p(""), msg)
}
// handleSnapshotRestore restores the container disk from a snapshot. The
// container is stopped first if running, then started again afterwards.
func (s *Server) handleSnapshotRestore(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
name := r.FormValue("name")
var msg string
if err := s.mgr.SnapshotRestore(u.Name, name); err != nil {
msg = "error: " + err.Error()
} else {
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.restore")
msg = "ok: checkpoint restored"
}
s.redirect(w, r, s.p(""), msg)
}
// handleSnapshotShare publishes a checkpoint behind a random code so other
// users can install from it. Older checkpoints are deleted in the process: once
// the snapshot is cloned it can no longer be rolled back past, so they would
// only linger as dead entries. The panel warns before submitting.
func (s *Server) handleSnapshotShare(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
name := r.FormValue("name")
_, removed, err := s.mgr.ShareSnapshot(u.Name, name)
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.share."+name)
msg := "snapshot_shared"
if len(removed) > 0 {
msg = "snapshot_shared_removed"
}
s.redirect(w, r, s.p(""), s.t(r, msg, len(removed)))
}
// handleSnapshotUnshare revokes the user's share code.
func (s *Server) handleSnapshotUnshare(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := s.mgr.RevokeSnapshotShare(u.Name); err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.unshare")
s.redirect(w, r, s.p(""), s.t(r, "snapshot_unshared"))
}
// handleSSHKeys reconciles the user's SSH-key set from the management panel
// AND which of the operator's admin keys the user has activated. The body is a
// JSON object: `keys` is the array of user key rows (ID > 0 updates, ID == 0
// adds, missing rows are deleted), `adminKeys` the list of checked admin-key
// IDs. Returns the fresh user-key list plus the granted admin-key IDs so the
// panel can re-render without a page reload.
func (s *Server) handleSSHKeys(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
var req struct {
Keys []mgr.SSHKeyInput `json:"keys"`
AdminKeys []int64 `json:"adminKeys"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSSHKeys(w, false, "", "", nil, nil)
return
}
keys, err := s.mgr.SaveSSHKeys(u.Name, req.Keys)
if err != nil {
writeSSHKeys(w, false, err.Error(), "", nil, nil)
return
}
granted, err := s.mgr.SaveAdminKeyGrants(u.Name, req.AdminKeys)
if err != nil {
writeSSHKeys(w, false, err.Error(), "", nil, nil)
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "ssh_keys")
// Apply the selection immediately. Both the user's own keys and the
// operator's keys are fully synced by marker (activated ones written with
// their marker, stale/deactivated ones removed), so this runs even when
// nothing is active — deactivating a key must purge it from the machine.
// Keys the user added by hand (no marker) are never touched. Best-effort:
// keys persist in the DB either way, so a save must not fail just because
// the container is stopped or unreachable.
warn := ""
var userActive []string
for _, k := range keys {
if k.Active {
userActive = append(userActive, k.Key)
}
}
if err := s.mgr.ApplySSHKeys(u.Name, userActive, granted); err != nil {
warn = s.t(r, "ssh_keys_apply_warn")
}
ids := make([]int64, 0, len(granted))
for _, k := range granted {
ids = append(ids, k.ID)
}
writeSSHKeys(w, true, "", warn, keys, ids)
}
func writeSSHKeys(w http.ResponseWriter, ok bool, errMsg, warn string, keys []db.SSHKey, adminIDs []int64) {
rows := make([]sshKeyRow, 0, len(keys))
for _, k := range keys {
rows = append(rows, sshKeyRow{ID: k.ID, Name: k.Name, Key: k.Key, Active: k.Active})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Warning string `json:"warning,omitempty"`
Keys []sshKeyRow `json:"keys"`
AdminKeys []int64 `json:"adminKeys"`
}{ok, errMsg, warn, rows, adminIDs})
}
// handleNotes serves the user's sticky-notes blob. GET returns the stored
// encrypted envelope (empty = never enabled or reset); POST stores a new
// envelope. The payload is opaque to the server — encryption happens in the
// browser — so the only validation is the size cap.
func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if r.Method == http.MethodGet {
data, _ := s.db.GetStickyNotes(u.ID)
writeNotesJSON(w, true, "", data != "", data)
return
}
// Expired accounts are locked: reads stay open (above), writes do not.
if s.isExpired(r) {
writeNotesJSON(w, false, "account expired", true, "")
return
}
var req struct {
Data string `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeNotesJSON(w, false, "bad request", false, "")
return
}
if len(req.Data) > cfg.MaxNotesBlobBytes {
writeNotesJSON(w, false, "notes_full", true, "")
return
}
if err := s.db.SetStickyNotes(u.ID, req.Data); err != nil {
writeNotesJSON(w, false, "storage error", true, "")
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "notes.update")
writeNotesJSON(w, true, "", true, req.Data)
}
// handleNotesReset clears the sticky-notes blob, returning the account to the
// never-enabled state (used by the "forgot password" flow after the user
// confirms — the encrypted content becomes unrecoverable).
func (s *Server) handleNotesReset(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if err := s.db.SetStickyNotes(u.ID, ""); err != nil {
writeNotesJSON(w, false, "storage error", true, "")
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "notes_reset")
writeNotesJSON(w, true, "", false, "")
}
func writeNotesJSON(w http.ResponseWriter, ok bool, errMsg string, has bool, data string) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Has bool `json:"has"`
Data string `json:"data"`
}{ok, errMsg, has, data})
}