mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-22 13:02:26 +00:00
176 lines
5.2 KiB
Go
176 lines
5.2 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"mime"
|
|
"net/http"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// AccountsRouter must be mounted inside the authenticated admin subtree.
|
|
func AccountsRouter(store api.AccountStore) http.Handler {
|
|
if store == nil {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { respondError(w, 503, "account storage is unavailable") })
|
|
}
|
|
r := chi.NewRouter()
|
|
r.Get("/", listAccounts(store))
|
|
r.Post("/", createAccount(store))
|
|
r.Get("/{id}", getAccount(store))
|
|
r.Delete("/{id}", deactivateAccount(store))
|
|
return r
|
|
}
|
|
|
|
// listAccounts godoc
|
|
// @Summary List operator accounts
|
|
// @Description Includes active and inactive accounts, newest first. These records are not login users.
|
|
// @Tags Admin
|
|
// @Produce json
|
|
// @Security AdminKey
|
|
// @Success 200 {object} api.AccountList
|
|
// @Failure 401,503,500 {object} map[string]APIError
|
|
// @Router /admin/accounts [get]
|
|
func listAccounts(store api.AccountStore) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
items, err := store.ListAccounts(r.Context())
|
|
if err != nil {
|
|
accountError(w, err)
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []api.Account{}
|
|
}
|
|
respond(w, 200, api.AccountList{Items: items})
|
|
}
|
|
}
|
|
|
|
// createAccount godoc
|
|
// @Summary Create an operator account
|
|
// @Description Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created.
|
|
// @Tags Admin
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security AdminKey
|
|
// @Param account body api.CreateAccountRequest true "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB"
|
|
// @Success 201 {object} api.Account
|
|
// @Failure 400,401,409,413,415,503,500 {object} map[string]APIError
|
|
// @Router /admin/accounts [post]
|
|
func createAccount(store api.AccountStore) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
|
if err != nil || mediaType != "application/json" {
|
|
respondError(w, 415, "Content-Type must be application/json")
|
|
return
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
|
decoder := json.NewDecoder(r.Body)
|
|
decoder.DisallowUnknownFields()
|
|
var input api.CreateAccountRequest
|
|
err = decoder.Decode(&input)
|
|
if err == nil {
|
|
var extra any
|
|
err = decoder.Decode(&extra)
|
|
if errors.Is(err, io.EOF) {
|
|
err = nil
|
|
} else if err == nil {
|
|
err = errors.New("multiple JSON values")
|
|
}
|
|
}
|
|
if err != nil {
|
|
var sizeError *http.MaxBytesError
|
|
if errors.As(err, &sizeError) {
|
|
respondError(w, 413, "request body exceeds 4 KiB")
|
|
} else {
|
|
respondError(w, 400, "invalid account JSON")
|
|
}
|
|
return
|
|
}
|
|
name, err := api.NormalizeAccountName(input.Name)
|
|
if err != nil {
|
|
accountError(w, err)
|
|
return
|
|
}
|
|
account, err := store.CreateAccount(r.Context(), name)
|
|
if err != nil {
|
|
accountError(w, err)
|
|
return
|
|
}
|
|
respond(w, 201, account)
|
|
}
|
|
}
|
|
|
|
// getAccount godoc
|
|
// @Summary Get an operator account
|
|
// @Description Returns active or inactive account records.
|
|
// @Tags Admin
|
|
// @Produce json
|
|
// @Security AdminKey
|
|
// @Param id path string true "Account UUID" format(uuid)
|
|
// @Success 200 {object} api.Account
|
|
// @Failure 400,401,404,503,500 {object} map[string]APIError
|
|
// @Router /admin/accounts/{id} [get]
|
|
func getAccount(store api.AccountStore) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
respondError(w, 400, "invalid account ID")
|
|
return
|
|
}
|
|
account, err := store.GetAccount(r.Context(), id)
|
|
if err != nil {
|
|
accountError(w, err)
|
|
return
|
|
}
|
|
respond(w, 200, account)
|
|
}
|
|
}
|
|
|
|
// deactivateAccount godoc
|
|
// @Summary Deactivate an operator account
|
|
// @Description Soft deactivation preserves the record. Its name may be reused by a new account.
|
|
// @Tags Admin
|
|
// @Produce json
|
|
// @Security AdminKey
|
|
// @Param id path string true "Account UUID" format(uuid)
|
|
// @Success 204 "Deactivated"
|
|
// @Failure 400,401,404,409,503,500 {object} map[string]APIError
|
|
// @Router /admin/accounts/{id} [delete]
|
|
func deactivateAccount(store api.AccountStore) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
respondError(w, 400, "invalid account ID")
|
|
return
|
|
}
|
|
if err := store.DeactivateAccount(r.Context(), id); err != nil {
|
|
accountError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(204)
|
|
}
|
|
}
|
|
|
|
func accountError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, api.ErrAccountNameInvalid):
|
|
respondError(w, 400, api.ErrAccountNameInvalid.Error())
|
|
case errors.Is(err, api.ErrAccountNameConflict):
|
|
respondError(w, 409, api.ErrAccountNameConflict.Error())
|
|
case errors.Is(err, api.ErrAccountNotFound):
|
|
respondError(w, 404, api.ErrAccountNotFound.Error())
|
|
case errors.Is(err, api.ErrAccountInactive):
|
|
respondError(w, 409, api.ErrAccountInactive.Error())
|
|
default:
|
|
slog.Error("account request failed", "component", "api", "error", err)
|
|
respondError(w, 500, "internal server error")
|
|
}
|
|
}
|