mirror of
https://github.com/WiiLink24/wfc-server.git
synced 2026-08-05 18:01:11 -05:00
API: Add baninfo endpoint
This commit is contained in:
parent
9005b4795d
commit
4d92f7bf0c
78
api/ban.go
78
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
|
||||
}
|
||||
|
|
|
|||
75
api/baninfo.go
Normal file
75
api/baninfo.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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
168
api/util.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user