package api import ( "database/sql" "encoding/json" "git.misaka.ren/m1saka/dsh-web/internal/store" "golang.org/x/crypto/bcrypt" "io" "net/http" "os" "regexp" "strconv" "time" ) var usernamePattern = regexp.MustCompile("^[a-zA-Z0-9_-]{3,40}$") func DefaultQuota() int64 { if n, err := strconv.ParseInt(os.Getenv("DEFAULT_QUOTA_BYTES"), 10, 64); err == nil && n > 0 { return n } return 10 << 30 } func (a *API) users(w http.ResponseWriter, r *http.Request) { u, _, err := a.user(r) if err != nil { fail(w, 401, "authentication required") return } if !u.Admin { fail(w, 403, "administrator required") return } switch r.Method { case "GET": rows, err := a.s.DB.Query("SELECT id,username,admin,quota,created FROM users ORDER BY created") if err != nil { fail(w, 500, "database error") return } defer rows.Close() out := []store.User{} for rows.Next() { var v store.User var created int64 if err := rows.Scan(&v.ID, &v.Username, &v.Admin, &v.Quota, &created); err != nil { fail(w, 500, "database error") return } v.Created = time.Unix(created, 0) out = append(out, v) } if err := rows.Err(); err != nil { fail(w, 500, "database error") return } writeJSON(w, out) case "POST": var in struct { Username string Password string Admin bool Quota int64 `json:"quota_bytes"` } if json.NewDecoder(r.Body).Decode(&in) != nil || !usernamePattern.MatchString(in.Username) || len(in.Password) < 12 || len(in.Password) > 72 || in.Quota < 0 { fail(w, 400, "username must be 3–40 letters/digits/_/-, password 12–72 bytes, quota nonnegative") return } if in.Quota == 0 { in.Quota = DefaultQuota() } h, e := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) if e != nil { fail(w, 500, "password error") return } v := store.User{ID: store.ID(), Username: in.Username, Admin: in.Admin, Quota: in.Quota, Created: time.Now()} if e = a.s.CreateUser(v, string(h)); e != nil { fail(w, 409, "username already exists or database unavailable") return } a.s.AddAudit(u.ID, "user.create", v.ID, "") writeJSON(w, v) case "PATCH": var in struct { ID string Quota int64 `json:"quota_bytes"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in) != nil || in.ID == "" || in.Quota <= 0 { fail(w, 400, "positive quota and user ID required") return } user := a.userLocks.Acquire(in.ID) defer user.Release() result, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id=?", in.Quota, in.ID) if err != nil { fail(w, 500, "quota update failed") return } count, _ := result.RowsAffected() if count != 1 { fail(w, 404, "user not found") return } a.s.AddAudit(u.ID, "user.quota", in.ID, strconv.FormatInt(in.Quota, 10)) writeJSON(w, map[string]bool{"ok": true}) default: fail(w, 405, "method not allowed") } } func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodPost { fail(w, 405, "method not allowed") return } u, _, err := a.user(r) if err != nil { fail(w, 401, "authentication required") return } if !u.Admin { fail(w, 403, "administrator required") return } var in struct{ ID, Password string } decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) if decoder.Decode(&in) != nil || decoder.Decode(new(any)) != io.EOF || in.ID == "" || len(in.Password) < 12 || len(in.Password) > 72 { fail(w, 400, "user ID and password 12–72 bytes required") return } hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) if err != nil { fail(w, 500, "password error") return } // Login must not issue a session using a hash read before this reset. a.authLoginMu.Lock() defer a.authLoginMu.Unlock() u, _, err = a.user(r) if err != nil { fail(w, 401, "authentication required") return } if !u.Admin { fail(w, 403, "administrator required") return } tx, err := a.s.DB.BeginTx(r.Context(), nil) if err != nil { fail(w, 500, "password reset failed") return } defer tx.Rollback() var username string err = tx.QueryRowContext(r.Context(), "SELECT username FROM users WHERE id=?", in.ID).Scan(&username) if err == sql.ErrNoRows { fail(w, 404, "user not found") return } if err == nil { _, err = tx.ExecContext(r.Context(), "UPDATE users SET pass_hash=? WHERE id=?", string(hash), in.ID) } if err == nil { _, err = tx.ExecContext(r.Context(), "DELETE FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE uid=?)", in.ID) } if err == nil { _, err = tx.ExecContext(r.Context(), "DELETE FROM sessions WHERE uid=?", in.ID) } if err == nil { _, err = tx.ExecContext(r.Context(), "DELETE FROM auth_failures WHERE username=?", username) } if err == nil { _, err = tx.ExecContext(r.Context(), "INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", u.ID, "user.reset_password", in.ID, "", time.Now().Unix()) } if err == nil { err = tx.Commit() } if err != nil { fail(w, 500, "password reset failed") return } writeJSON(w, map[string]bool{"ok": true, "reauthenticate": u.ID == in.ID}) } func (a *API) audit(w http.ResponseWriter, r *http.Request) { u, _, e := a.user(r) if e != nil { fail(w, 401, "authentication required") return } if !u.Admin { fail(w, 403, "administrator required") return } if r.Method != "GET" { fail(w, 405, "method not allowed") return } rows, e := a.s.DB.Query("SELECT id,uid,action,target,detail,at FROM audit ORDER BY id DESC LIMIT 500") if e != nil { fail(w, 500, "database error") return } defer rows.Close() out := []map[string]any{} for rows.Next() { var id, at int64 var uid, action, target, detail string if rows.Scan(&id, &uid, &action, &target, &detail, &at) != nil { fail(w, 500, "database error") return } out = append(out, map[string]any{"id": id, "user_id": uid, "action": action, "target": target, "detail": detail, "created_at": time.Unix(at, 0)}) } if err := rows.Err(); err != nil { fail(w, 500, "database error") return } writeJSON(w, out) }