API: Add baninfo endpoint
Some checks failed
Build CI / build (push) Has been cancelled
golangci-lint / lint (push) Has been cancelled

This commit is contained in:
Palapeli 2026-04-22 13:34:21 -04:00
parent 9005b4795d
commit 4d92f7bf0c
No known key found for this signature in database
GPG Key ID: 1FFE8F556A474925
6 changed files with 296 additions and 64 deletions

View File

@ -1,10 +1,7 @@
package api
import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"
"wwfc/gpcm"
"wwfc/logging"
@ -12,46 +9,8 @@ import (
"github.com/logrusorgru/aurora/v3"
)
func HandleBan(w http.ResponseWriter, r *http.Request) {
var success bool
var err string
var statusCode int
switch r.Method {
case http.MethodPost:
success, err, statusCode = handleBanImpl(r)
case http.MethodOptions:
statusCode = http.StatusNoContent
w.Header().Set("Access-Control-Allow-Methods", "POST")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
default:
err = "incorrect request. POST only."
statusCode = http.StatusMethodNotAllowed
w.Header().Set("Allow", "POST")
}
w.Header().Set("Access-Control-Allow-Origin", "*")
var jsonData []byte
if statusCode != http.StatusNoContent {
w.Header().Set("Content-Type", "application/json")
if success {
jsonData, _ = json.Marshal(map[string]string{"success": "true"})
} else {
jsonData, _ = json.Marshal(map[string]string{"error": err})
}
}
w.Header().Set("Content-Length", strconv.Itoa(len(jsonData)))
w.WriteHeader(statusCode)
_, _ = w.Write(jsonData)
}
type BanRequestSpec struct {
Secret string `json:"secret"`
AuthInfo
ProfileID uint32 `json:"pid"`
Days uint64 `json:"days"`
Hours uint64 `json:"hours"`
@ -62,30 +21,21 @@ type BanRequestSpec struct {
Moderator string `json:"moderator"`
}
func handleBanImpl(r *http.Request) (bool, string, int) {
// TODO: Actual authentication rather than a fixed secret
body, err := io.ReadAll(r.Body)
func HandleBan(w http.ResponseWriter, r *http.Request) {
req := BanRequestSpec{}
err := parsePost(r, w, &req, RoleModerator)
if err != nil {
return false, "Unable to read request body", http.StatusBadRequest
}
var req BanRequestSpec
err = json.Unmarshal(body, &req)
if err != nil {
return false, err.Error(), http.StatusBadRequest
}
if apiSecret == "" || req.Secret != apiSecret {
return false, "Invalid API secret in request", http.StatusUnauthorized
return
}
if req.ProfileID == 0 {
return false, "Profile ID missing or 0 in request", http.StatusBadRequest
replyError(w, http.StatusBadRequest, APIErrorInvalidProfileID)
return
}
if req.Reason == "" {
return false, "Missing ban reason in request", http.StatusBadRequest
replyError(w, http.StatusBadRequest, APIErrorInvalidBanReason)
return
}
moderator := req.Moderator
@ -95,7 +45,8 @@ func handleBanImpl(r *http.Request) (bool, string, int) {
minutes := req.Days*24*60 + req.Hours*60 + req.Minutes
if minutes == 0 {
return false, "Ban length missing or 0", http.StatusBadRequest
replyError(w, http.StatusBadRequest, APIErrorInvalidBanLength)
return
}
length := time.Duration(minutes) * time.Minute
@ -103,9 +54,12 @@ func handleBanImpl(r *http.Request) (bool, string, int) {
logging.Notice("API:"+moderator, "Ban profile:", aurora.Cyan(req.ProfileID), "TOS:", aurora.Cyan(req.Tos), "Length:", aurora.Cyan(length), "Reason:", aurora.BrightCyan(req.Reason), "Reason (Hidden):", aurora.BrightCyan(req.ReasonHidden))
if !db.BanUser(req.ProfileID, req.Tos, length, req.Reason, req.ReasonHidden, moderator) {
return false, "Failed to ban user", http.StatusInternalServerError
replyError(w, http.StatusInternalServerError, APIErrorBanFailed)
return
}
replyOK(w, nil)
gpcm.KickPlayerCustomMessage(req.ProfileID, req.Reason, gpcm.WWFCMsgProfileRestrictedCustom)
logging.Event("profile_banned", map[string]any{
@ -116,6 +70,4 @@ func handleBanImpl(r *http.Request) (bool, string, int) {
"reason_hidden": req.ReasonHidden,
"moderator": moderator,
})
return true, "", http.StatusOK
}

75
api/baninfo.go Normal file
View File

@ -0,0 +1,75 @@
package api
import (
"net/http"
"strconv"
"strings"
"time"
"wwfc/common"
)
type BanInfoResponseSpec struct {
ProfileID uint32 `json:"pid"`
FriendCode string `json:"fc,omitempty"`
InGameName string `json:"name,omitempty"`
Reason string `json:"reason,omitempty"`
TOS bool `json:"tos"`
Issued time.Time `json:"issued"`
Expires time.Time `json:"expires"`
}
func HandleBanInfo(w http.ResponseWriter, r *http.Request) {
query, err := parseGet(r, w, RoleNone)
if err != nil {
return
}
search := query.Get("q")
if search == "" {
replyError(w, http.StatusBadRequest, APIErrorInvalidBanQuery)
return
}
search = strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(search, " ", ""), "-", ""))
profileId := uint32(0)
ngDeviceId := uint32(0)
if strings.HasPrefix(search, "NG") {
ngId, err := strconv.ParseUint(search[2:], 16, 32)
if err != nil {
replyError(w, http.StatusBadRequest, APIErrorInvalidBanQuery)
return
}
ngDeviceId = uint32(ngId)
} else {
pId, err := strconv.ParseUint(search, 10, 64)
if err != nil {
replyError(w, http.StatusBadRequest, APIErrorInvalidBanQuery)
return
}
// Truncate to 32 bits as that's how friend codes work
profileId = uint32(pId)
}
tos, issued, expires, reason, bannedProfileId, gsbrCode, inGameName, err := db.SearchUserBan(profileId, ngDeviceId, "", "")
if err != nil {
replyError(w, http.StatusOK, APIErrorBanNotFound)
return
}
if bannedProfileId == 0 {
replyError(w, http.StatusOK, APIErrorBanNotFound)
return
}
fc := common.CalcFriendCodeString(bannedProfileId, gsbrCode)
replyOK(w, BanInfoResponseSpec{
ProfileID: bannedProfileId,
FriendCode: fc,
InGameName: inGameName,
Reason: reason,
TOS: tos,
Issued: issued,
Expires: expires,
})
}

View File

@ -38,4 +38,5 @@ func RegisterHandlers(mux *http.ServeMux) {
mux.HandleFunc("/api/ban", HandleBan)
mux.HandleFunc("/api/unban", HandleUnban)
mux.HandleFunc("/api/kick", HandleKick)
mux.HandleFunc("/api/baninfo", HandleBanInfo)
}

168
api/util.go Normal file
View File

@ -0,0 +1,168 @@
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"reflect"
"strconv"
)
type APIErrorString string
const (
APIErrorAuthenticationFailed APIErrorString = "AuthenticationFailed"
APIErrorInvalidQuery APIErrorString = "InvalidQuery"
APIErrorInvalidProfileID APIErrorString = "InvalidProfileID"
APIErrorInvalidBanReason APIErrorString = "InvalidBanReason"
APIErrorInvalidBanLength APIErrorString = "InvalidBanLength"
APIErrorBanFailed APIErrorString = "BanFailed"
APIErrorInvalidBanQuery APIErrorString = "InvalidBanQuery"
APIErrorBanNotFound APIErrorString = "BanNotFound"
)
type APIError struct {
Error string `json:"error"`
}
type Role string
// Values currently just made up
const (
RoleNone Role = "none" // Not signed in
RoleUser Role = "user"
RoleAdmin Role = "admin"
RoleModerator Role = "moderator"
)
type AuthInfo struct {
Secret string `json:"secret"`
}
var (
errOptionsRequest = errors.New("OPTIONS request")
errIncorrectMethod = errors.New("incorrect HTTP method")
errNoAuthInfo = errors.New("request struct does not contain AuthInfo fields")
errAuthFailed = errors.New("authentication failed")
)
func parseGet(r *http.Request, w http.ResponseWriter, requiredRole Role) (query url.Values, err error) {
w.Header().Set("Access-Control-Allow-Origin", "*")
switch {
case r.Method == http.MethodGet:
break
case r.Method == http.MethodOptions:
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusNoContent)
return nil, errOptionsRequest
default:
w.Header().Set("Allow", "GET, OPTIONS")
w.WriteHeader(http.StatusMethodNotAllowed)
return nil, errIncorrectMethod
}
query, err = url.ParseQuery(r.URL.RawQuery)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return nil, err
}
if requiredRole == RoleNone {
return query, nil
}
authInfo := makeAuthInfo(query)
if !authenticate(authInfo, requiredRole) {
replyError(w, http.StatusUnauthorized, APIErrorAuthenticationFailed)
return nil, errAuthFailed
}
return query, nil
}
func parsePost(r *http.Request, w http.ResponseWriter, parsed any, requiredRole Role) error {
w.Header().Set("Access-Control-Allow-Origin", "*")
switch {
case r.Method == http.MethodPost:
break
case r.Method == http.MethodOptions:
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusNoContent)
return errOptionsRequest
default:
w.Header().Set("Allow", "POST, OPTIONS")
w.WriteHeader(http.StatusMethodNotAllowed)
return errIncorrectMethod
}
jsonData, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return err
}
err = json.Unmarshal(jsonData, parsed)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return err
}
if requiredRole == RoleNone {
return nil
}
authInfo, ok := reflect.ValueOf(parsed).Elem().FieldByName("AuthInfo").Interface().(AuthInfo)
if !ok {
w.WriteHeader(http.StatusInternalServerError)
return errNoAuthInfo
}
if !authenticate(authInfo, requiredRole) {
replyError(w, http.StatusUnauthorized, APIErrorAuthenticationFailed)
return errAuthFailed
}
return nil
}
func makeAuthInfo(query url.Values) AuthInfo {
return AuthInfo{
Secret: query.Get("secret"),
}
}
func authenticate(authInfo AuthInfo, requiredRole Role) bool {
return requiredRole == RoleNone || authInfo.Secret == apiSecret
}
func replyError(w http.ResponseWriter, statusCode int, errMsg APIErrorString) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
jsonData := []byte(`{"error":"` + string(errMsg) + `"}`)
w.Header().Set("Content-Length", strconv.Itoa(len(jsonData)))
_, _ = w.Write(jsonData)
}
func replyOK(w http.ResponseWriter, data any) {
if data == nil {
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "application/json")
jsonData, err := json.Marshal(data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(jsonData)))
_, _ = w.Write(jsonData)
}

View File

@ -29,10 +29,31 @@ const (
WHERE has_ban = true
AND (profile_id = $2
OR (allow_default_keys = FALSE AND ng_device_id && (SELECT * FROM known_ng_device_ids))
OR last_ip_address = $3
OR ($3 != '' AND last_ip_address = $3)
OR ($4 != '' AND last_ip_address = $4))
AND (ban_expires IS NULL OR ban_expires > $5)
ORDER BY ban_tos DESC LIMIT 1`
SearchUserBanInfo = `
WITH known_ng_device_ids AS (
WITH RECURSIVE device_tree AS (
SELECT unnest(ng_device_id) AS device_id
FROM users
WHERE allow_default_keys = FALSE AND $1 != 0 AND ng_device_id && array[$1]::bigint[]
UNION
SELECT unnest(ng_device_id)
FROM users
JOIN device_tree dt
ON allow_default_keys = FALSE AND ng_device_id && array[dt.device_id]
) SELECT array_agg(DISTINCT device_id) FROM device_tree
) SELECT has_ban, ban_tos, ban_issued, ban_expires, ban_reason, ng_device_id, profile_id, gsbrcd, last_ingamesn
FROM users
WHERE has_ban = true
AND (profile_id = $2
OR (allow_default_keys = FALSE AND ng_device_id && (SELECT * FROM known_ng_device_ids))
OR ($3 != '' AND last_ip_address = $3)
OR ($4 != '' AND last_ip_address = $4))
ORDER BY ban_expires DESC LIMIT 1`
)
var (

View File

@ -155,3 +155,18 @@ func (c *Connection) UnbanUser(profileId uint32) bool {
_, err := c.pool.Exec(c.ctx, DisableUserBan, profileId)
return err == nil
}
func (c *Connection) SearchUserBan(profileId uint32, ngDeviceId uint32, ipAddress string, lastIpAddress string) (
tos bool, issued time.Time, expires time.Time, reason string, bannedProfileId uint32, gsbrCode string, inGameName string, err error) {
row := c.pool.QueryRow(c.ctx, SearchUserBanInfo, ngDeviceId, profileId, ipAddress, lastIpAddress)
var hasBan bool
var bannedNgDeviceId []uint32
err = row.Scan(&hasBan, &tos, &issued, &expires, &reason, &bannedNgDeviceId, &bannedProfileId, &gsbrCode, &inGameName)
if err == nil && !hasBan {
err = errors.New("no ban found")
}
if len(gsbrCode) > 4 {
gsbrCode = gsbrCode[:4]
}
return tos, issued, expires, reason, bannedProfileId, gsbrCode, inGameName, err
}