mirror of
https://github.com/renorris/openfsd
synced 2026-03-22 23:05:36 +08:00
Changes:
- Implement bootstrapping library for managing several concurrent internal services
- Refactor concurrency model for connections/logical clients and their associated I/O
- Refactor server context singleton
- Refactor error handling
- Most errors are now gracefully sent to the FSD client directly encoded as an $ER packet,
enhancing visibility and debugging
- Most errors are now rightfully treated as non-fatal
- Refactor package/dependency graph
- Refactor calling conventions/interfaces for many packages
- Refactor database package
- Refactor post office
Features:
- Add VATSIM-esque HTTP/JSON "data feed"
- Add ephemeral in-memory database option
- Add user management REST API
- Add improved web interface
- Add MySQL support (drop SQLite support)
281 lines
7.8 KiB
Go
281 lines
7.8 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
auth2 "github.com/renorris/openfsd/auth"
|
|
"github.com/renorris/openfsd/database"
|
|
"github.com/renorris/openfsd/protocol"
|
|
"github.com/renorris/openfsd/servercontext"
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type APIV1UsersRequest struct {
|
|
CID int `json:"cid,omitempty"`
|
|
User database.FSDUserRecord `json:"user,omitempty"`
|
|
}
|
|
|
|
type APIV1UsersResponse struct {
|
|
StatusMessage string `json:"msg"`
|
|
User *database.FSDUserRecord `json:"user,omitempty"`
|
|
}
|
|
|
|
// APIV1UsersHandler handles all /api/v1/users calls
|
|
func APIV1UsersHandler(w http.ResponseWriter, r *http.Request, verifier auth2.JWTVerifier) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 8192)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
resp := APIV1UsersResponse{}
|
|
|
|
// Verify authorization
|
|
var tokenStr string
|
|
if tokenStr = r.Header.Get("Authorization"); tokenStr == "" {
|
|
resp.StatusMessage = "authorization header missing"
|
|
writeResponseError(w, http.StatusBadRequest, &resp)
|
|
return
|
|
}
|
|
|
|
if split := strings.Split(tokenStr, "Bearer "); len(split) != 2 {
|
|
resp.StatusMessage = "invalid authorization header format"
|
|
writeResponseError(w, http.StatusBadRequest, &resp)
|
|
return
|
|
} else {
|
|
tokenStr = split[1]
|
|
}
|
|
|
|
// Verify JWT signature and expiry times
|
|
var token *jwt.Token
|
|
var err error
|
|
if token, err = verifier.VerifyJWT(tokenStr); err != nil {
|
|
resp.StatusMessage = "invalid token"
|
|
writeResponseError(w, http.StatusForbidden, &resp)
|
|
return
|
|
}
|
|
|
|
// Verify JWT claims
|
|
claims := auth2.FSDJWTClaims{}
|
|
if err = claims.Parse(token); err != nil {
|
|
resp.StatusMessage = "invalid token claims"
|
|
writeResponseError(w, http.StatusForbidden, &resp)
|
|
return
|
|
}
|
|
|
|
if !slices.Contains(claims.Audience(), "dashboard") {
|
|
resp.StatusMessage = "invalid token audience"
|
|
writeResponseError(w, http.StatusForbidden, &resp)
|
|
return
|
|
}
|
|
|
|
// Read body
|
|
var body []byte
|
|
if body, err = io.ReadAll(r.Body); err != nil {
|
|
resp.StatusMessage = "error reading request body"
|
|
writeResponseError(w, http.StatusInternalServerError, &resp)
|
|
return
|
|
}
|
|
|
|
var status int
|
|
req := APIV1UsersRequest{}
|
|
// GET method doesn't have a body, handle it separately
|
|
if r.Method == "GET" {
|
|
if !strings.HasPrefix(r.URL.Path, "/api/v1/users/") {
|
|
resp.StatusMessage = "invalid request path"
|
|
writeResponseError(w, http.StatusBadRequest, &resp)
|
|
return
|
|
}
|
|
|
|
var cid int
|
|
if cid, err = strconv.Atoi(path.Base(r.URL.Path)); err != nil {
|
|
resp.StatusMessage = "invalid CID"
|
|
writeResponseError(w, http.StatusBadRequest, &resp)
|
|
return
|
|
}
|
|
req.CID = cid
|
|
status = getUserHandler(&claims, &req, &resp)
|
|
} else {
|
|
if err = json.Unmarshal(body, &req); err != nil {
|
|
resp.StatusMessage = "error parsing request body"
|
|
writeResponseError(w, http.StatusBadRequest, &resp)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case "POST":
|
|
status = createUserHandler(&claims, &req, &resp)
|
|
case "PUT":
|
|
status = updateUserHandler(&claims, &req, &resp)
|
|
case "DELETE":
|
|
status = deleteUserHandler(&claims, &req, &resp)
|
|
default:
|
|
resp.StatusMessage = "method not allowed"
|
|
writeResponseError(w, http.StatusMethodNotAllowed, &resp)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Serialize response
|
|
var resBody []byte
|
|
if resBody, err = json.Marshal(&resp); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
resp.StatusMessage = "error serializing response body"
|
|
if respBytes, err := json.Marshal(&resp); err == nil {
|
|
io.Copy(w, bytes.NewReader(respBytes))
|
|
}
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(status)
|
|
io.Copy(w, bytes.NewReader(resBody))
|
|
}
|
|
|
|
func createUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) {
|
|
// User must be an administrator, or a supervisor with the limitation of only creating users of lower rating
|
|
if claims.ControllerRating() != protocol.NetworkRatingADM {
|
|
if claims.ControllerRating() < protocol.NetworkRatingSUP {
|
|
res.StatusMessage = "must be at least Supervisor to create user"
|
|
return http.StatusForbidden
|
|
}
|
|
if req.User.NetworkRating >= protocol.NetworkRatingSUP {
|
|
res.StatusMessage = "created user must be below supervisor rating"
|
|
return http.StatusForbidden
|
|
}
|
|
}
|
|
|
|
var err error
|
|
var autoPassword, fsdAutoPassword bool
|
|
if req.User.Password == "" {
|
|
if req.User.Password, err = generateRandomPassword(); err != nil {
|
|
res.StatusMessage = "error generating random password"
|
|
return http.StatusInternalServerError
|
|
}
|
|
autoPassword = true
|
|
}
|
|
if req.User.FSDPassword == "" {
|
|
if req.User.FSDPassword, err = generateRandomPassword(); err != nil {
|
|
res.StatusMessage = "error generating random password"
|
|
return http.StatusInternalServerError
|
|
}
|
|
fsdAutoPassword = true
|
|
}
|
|
|
|
if req.User.CID, err = req.User.Insert(servercontext.DB()); err != nil {
|
|
res.StatusMessage = "error inserting user into database"
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
res.StatusMessage = "success"
|
|
|
|
// omit passwords for response if they weren't auto generated
|
|
if !autoPassword {
|
|
req.User.Password = ""
|
|
}
|
|
if !fsdAutoPassword {
|
|
req.User.FSDPassword = ""
|
|
}
|
|
|
|
// copy user into response
|
|
res.User = &req.User
|
|
|
|
return http.StatusOK
|
|
}
|
|
|
|
func getUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) {
|
|
if claims.ControllerRating() < protocol.NetworkRatingSUP {
|
|
res.StatusMessage = "must be at least Supervisor to read users"
|
|
return http.StatusForbidden
|
|
}
|
|
|
|
userRecord := database.FSDUserRecord{}
|
|
if err := userRecord.LoadByCID(servercontext.DB(), req.CID); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
res.StatusMessage = "no user found"
|
|
return http.StatusNotFound
|
|
}
|
|
|
|
res.StatusMessage = "error loading user from database"
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
// omit passwords
|
|
userRecord.Password = ""
|
|
userRecord.FSDPassword = ""
|
|
|
|
res.StatusMessage = "success"
|
|
|
|
// copy into response
|
|
res.User = &userRecord
|
|
|
|
return http.StatusOK
|
|
}
|
|
|
|
func updateUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) {
|
|
// User must be an administrator, or a supervisor with the limitation of only updating users of lower rating
|
|
if claims.ControllerRating() != protocol.NetworkRatingADM {
|
|
if claims.ControllerRating() < protocol.NetworkRatingSUP {
|
|
res.StatusMessage = "must be at least Supervisor to update user"
|
|
return http.StatusForbidden
|
|
}
|
|
if req.User.NetworkRating >= protocol.NetworkRatingSUP {
|
|
res.StatusMessage = "user to update must be below supervisor rating"
|
|
return http.StatusForbidden
|
|
}
|
|
}
|
|
|
|
var err error
|
|
if err = req.User.Update(servercontext.DB()); err != nil {
|
|
if errors.Is(err, database.NoRowsChangedError) {
|
|
res.StatusMessage = "user not found"
|
|
return http.StatusNotFound
|
|
}
|
|
res.StatusMessage = "error updating user"
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
if err = req.User.LoadByCID(servercontext.DB(), req.User.CID); err != nil {
|
|
res.StatusMessage = "error loading user for response"
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
res.StatusMessage = "success"
|
|
|
|
// omit passwords for response
|
|
req.User.Password = ""
|
|
req.User.FSDPassword = ""
|
|
|
|
// copy user into response
|
|
res.User = &req.User
|
|
|
|
return http.StatusOK
|
|
}
|
|
|
|
func deleteUserHandler(claims *auth2.FSDJWTClaims, req *APIV1UsersRequest, res *APIV1UsersResponse) (status int) {
|
|
// User must be an administrator
|
|
if claims.ControllerRating() != protocol.NetworkRatingADM {
|
|
res.StatusMessage = "must be at Administrator to delete user"
|
|
return http.StatusForbidden
|
|
}
|
|
|
|
var err error
|
|
if err = req.User.Delete(servercontext.DB(), req.CID); err != nil {
|
|
if errors.Is(err, database.NoRowsChangedError) {
|
|
res.StatusMessage = "user not found"
|
|
return http.StatusNotFound
|
|
}
|
|
res.StatusMessage = "error deleting user"
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
res.StatusMessage = "success"
|
|
res.User = nil
|
|
|
|
return http.StatusOK
|
|
}
|