Initial commit: DSH Web Manager
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user