213 lines
5.8 KiB
Go
213 lines
5.8 KiB
Go
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)
|
||
}
|
||
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
|
||
}
|
||
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)})
|
||
}
|
||
writeJSON(w, out)
|
||
}
|