From 4d92f7bf0c545870780ea6453e515ab749b8a1f6 Mon Sep 17 00:00:00 2001 From: Palapeli <26661008+mkwcat@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:34:21 -0400 Subject: [PATCH] API: Add baninfo endpoint --- api/ban.go | 78 +++++---------------- api/baninfo.go | 75 +++++++++++++++++++++ api/main.go | 1 + api/util.go | 168 ++++++++++++++++++++++++++++++++++++++++++++++ database/login.go | 23 ++++++- database/user.go | 15 +++++ 6 files changed, 296 insertions(+), 64 deletions(-) create mode 100644 api/baninfo.go create mode 100644 api/util.go diff --git a/api/ban.go b/api/ban.go index 751c2b0..32dfe02 100644 --- a/api/ban.go +++ b/api/ban.go @@ -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 } diff --git a/api/baninfo.go b/api/baninfo.go new file mode 100644 index 0000000..67444b5 --- /dev/null +++ b/api/baninfo.go @@ -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, + }) +} diff --git a/api/main.go b/api/main.go index 132b1ff..6605bd6 100644 --- a/api/main.go +++ b/api/main.go @@ -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) } diff --git a/api/util.go b/api/util.go new file mode 100644 index 0000000..7148f32 --- /dev/null +++ b/api/util.go @@ -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) +} diff --git a/database/login.go b/database/login.go index fa61571..520895a 100644 --- a/database/login.go +++ b/database/login.go @@ -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 ( diff --git a/database/user.go b/database/user.go index ae65fd6..d4b94dc 100644 --- a/database/user.go +++ b/database/user.go @@ -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 +}