Skip to content

Commit 4d92f7b

Browse files
committed
API: Add baninfo endpoint
1 parent 9005b47 commit 4d92f7b

6 files changed

Lines changed: 296 additions & 64 deletions

File tree

api/ban.go

Lines changed: 15 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,16 @@
11
package api
22

33
import (
4-
"encoding/json"
5-
"io"
64
"net/http"
7-
"strconv"
85
"time"
96
"wwfc/gpcm"
107
"wwfc/logging"
118

129
"github.com/logrusorgru/aurora/v3"
1310
)
1411

15-
func HandleBan(w http.ResponseWriter, r *http.Request) {
16-
var success bool
17-
var err string
18-
var statusCode int
19-
20-
switch r.Method {
21-
case http.MethodPost:
22-
success, err, statusCode = handleBanImpl(r)
23-
case http.MethodOptions:
24-
statusCode = http.StatusNoContent
25-
w.Header().Set("Access-Control-Allow-Methods", "POST")
26-
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
27-
default:
28-
err = "incorrect request. POST only."
29-
statusCode = http.StatusMethodNotAllowed
30-
w.Header().Set("Allow", "POST")
31-
}
32-
33-
w.Header().Set("Access-Control-Allow-Origin", "*")
34-
35-
var jsonData []byte
36-
37-
if statusCode != http.StatusNoContent {
38-
w.Header().Set("Content-Type", "application/json")
39-
40-
if success {
41-
jsonData, _ = json.Marshal(map[string]string{"success": "true"})
42-
} else {
43-
jsonData, _ = json.Marshal(map[string]string{"error": err})
44-
}
45-
}
46-
47-
w.Header().Set("Content-Length", strconv.Itoa(len(jsonData)))
48-
49-
w.WriteHeader(statusCode)
50-
_, _ = w.Write(jsonData)
51-
}
52-
5312
type BanRequestSpec struct {
54-
Secret string `json:"secret"`
13+
AuthInfo
5514
ProfileID uint32 `json:"pid"`
5615
Days uint64 `json:"days"`
5716
Hours uint64 `json:"hours"`
@@ -62,30 +21,21 @@ type BanRequestSpec struct {
6221
Moderator string `json:"moderator"`
6322
}
6423

65-
func handleBanImpl(r *http.Request) (bool, string, int) {
66-
// TODO: Actual authentication rather than a fixed secret
67-
68-
body, err := io.ReadAll(r.Body)
69-
if err != nil {
70-
return false, "Unable to read request body", http.StatusBadRequest
71-
}
72-
73-
var req BanRequestSpec
74-
err = json.Unmarshal(body, &req)
24+
func HandleBan(w http.ResponseWriter, r *http.Request) {
25+
req := BanRequestSpec{}
26+
err := parsePost(r, w, &req, RoleModerator)
7527
if err != nil {
76-
return false, err.Error(), http.StatusBadRequest
77-
}
78-
79-
if apiSecret == "" || req.Secret != apiSecret {
80-
return false, "Invalid API secret in request", http.StatusUnauthorized
28+
return
8129
}
8230

8331
if req.ProfileID == 0 {
84-
return false, "Profile ID missing or 0 in request", http.StatusBadRequest
32+
replyError(w, http.StatusBadRequest, APIErrorInvalidProfileID)
33+
return
8534
}
8635

8736
if req.Reason == "" {
88-
return false, "Missing ban reason in request", http.StatusBadRequest
37+
replyError(w, http.StatusBadRequest, APIErrorInvalidBanReason)
38+
return
8939
}
9040

9141
moderator := req.Moderator
@@ -95,17 +45,21 @@ func handleBanImpl(r *http.Request) (bool, string, int) {
9545

9646
minutes := req.Days*24*60 + req.Hours*60 + req.Minutes
9747
if minutes == 0 {
98-
return false, "Ban length missing or 0", http.StatusBadRequest
48+
replyError(w, http.StatusBadRequest, APIErrorInvalidBanLength)
49+
return
9950
}
10051

10152
length := time.Duration(minutes) * time.Minute
10253

10354
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))
10455

10556
if !db.BanUser(req.ProfileID, req.Tos, length, req.Reason, req.ReasonHidden, moderator) {
106-
return false, "Failed to ban user", http.StatusInternalServerError
57+
replyError(w, http.StatusInternalServerError, APIErrorBanFailed)
58+
return
10759
}
10860

61+
replyOK(w, nil)
62+
10963
gpcm.KickPlayerCustomMessage(req.ProfileID, req.Reason, gpcm.WWFCMsgProfileRestrictedCustom)
11064

11165
logging.Event("profile_banned", map[string]any{
@@ -116,6 +70,4 @@ func handleBanImpl(r *http.Request) (bool, string, int) {
11670
"reason_hidden": req.ReasonHidden,
11771
"moderator": moderator,
11872
})
119-
120-
return true, "", http.StatusOK
12173
}

api/baninfo.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"strconv"
6+
"strings"
7+
"time"
8+
"wwfc/common"
9+
)
10+
11+
type BanInfoResponseSpec struct {
12+
ProfileID uint32 `json:"pid"`
13+
FriendCode string `json:"fc,omitempty"`
14+
InGameName string `json:"name,omitempty"`
15+
Reason string `json:"reason,omitempty"`
16+
TOS bool `json:"tos"`
17+
Issued time.Time `json:"issued"`
18+
Expires time.Time `json:"expires"`
19+
}
20+
21+
func HandleBanInfo(w http.ResponseWriter, r *http.Request) {
22+
query, err := parseGet(r, w, RoleNone)
23+
if err != nil {
24+
return
25+
}
26+
27+
search := query.Get("q")
28+
if search == "" {
29+
replyError(w, http.StatusBadRequest, APIErrorInvalidBanQuery)
30+
return
31+
}
32+
33+
search = strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(search, " ", ""), "-", ""))
34+
35+
profileId := uint32(0)
36+
ngDeviceId := uint32(0)
37+
if strings.HasPrefix(search, "NG") {
38+
ngId, err := strconv.ParseUint(search[2:], 16, 32)
39+
if err != nil {
40+
replyError(w, http.StatusBadRequest, APIErrorInvalidBanQuery)
41+
return
42+
}
43+
ngDeviceId = uint32(ngId)
44+
} else {
45+
pId, err := strconv.ParseUint(search, 10, 64)
46+
if err != nil {
47+
replyError(w, http.StatusBadRequest, APIErrorInvalidBanQuery)
48+
return
49+
}
50+
// Truncate to 32 bits as that's how friend codes work
51+
profileId = uint32(pId)
52+
}
53+
54+
tos, issued, expires, reason, bannedProfileId, gsbrCode, inGameName, err := db.SearchUserBan(profileId, ngDeviceId, "", "")
55+
if err != nil {
56+
replyError(w, http.StatusOK, APIErrorBanNotFound)
57+
return
58+
}
59+
60+
if bannedProfileId == 0 {
61+
replyError(w, http.StatusOK, APIErrorBanNotFound)
62+
return
63+
}
64+
65+
fc := common.CalcFriendCodeString(bannedProfileId, gsbrCode)
66+
replyOK(w, BanInfoResponseSpec{
67+
ProfileID: bannedProfileId,
68+
FriendCode: fc,
69+
InGameName: inGameName,
70+
Reason: reason,
71+
TOS: tos,
72+
Issued: issued,
73+
Expires: expires,
74+
})
75+
}

api/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ func RegisterHandlers(mux *http.ServeMux) {
3838
mux.HandleFunc("/api/ban", HandleBan)
3939
mux.HandleFunc("/api/unban", HandleUnban)
4040
mux.HandleFunc("/api/kick", HandleKick)
41+
mux.HandleFunc("/api/baninfo", HandleBanInfo)
4142
}

api/util.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"reflect"
10+
"strconv"
11+
)
12+
13+
type APIErrorString string
14+
15+
const (
16+
APIErrorAuthenticationFailed APIErrorString = "AuthenticationFailed"
17+
APIErrorInvalidQuery APIErrorString = "InvalidQuery"
18+
APIErrorInvalidProfileID APIErrorString = "InvalidProfileID"
19+
APIErrorInvalidBanReason APIErrorString = "InvalidBanReason"
20+
APIErrorInvalidBanLength APIErrorString = "InvalidBanLength"
21+
APIErrorBanFailed APIErrorString = "BanFailed"
22+
APIErrorInvalidBanQuery APIErrorString = "InvalidBanQuery"
23+
APIErrorBanNotFound APIErrorString = "BanNotFound"
24+
)
25+
26+
type APIError struct {
27+
Error string `json:"error"`
28+
}
29+
30+
type Role string
31+
32+
// Values currently just made up
33+
const (
34+
RoleNone Role = "none" // Not signed in
35+
RoleUser Role = "user"
36+
RoleAdmin Role = "admin"
37+
RoleModerator Role = "moderator"
38+
)
39+
40+
type AuthInfo struct {
41+
Secret string `json:"secret"`
42+
}
43+
44+
var (
45+
errOptionsRequest = errors.New("OPTIONS request")
46+
errIncorrectMethod = errors.New("incorrect HTTP method")
47+
errNoAuthInfo = errors.New("request struct does not contain AuthInfo fields")
48+
errAuthFailed = errors.New("authentication failed")
49+
)
50+
51+
func parseGet(r *http.Request, w http.ResponseWriter, requiredRole Role) (query url.Values, err error) {
52+
w.Header().Set("Access-Control-Allow-Origin", "*")
53+
54+
switch {
55+
case r.Method == http.MethodGet:
56+
break
57+
58+
case r.Method == http.MethodOptions:
59+
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
60+
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
61+
w.WriteHeader(http.StatusNoContent)
62+
return nil, errOptionsRequest
63+
64+
default:
65+
w.Header().Set("Allow", "GET, OPTIONS")
66+
w.WriteHeader(http.StatusMethodNotAllowed)
67+
return nil, errIncorrectMethod
68+
}
69+
70+
query, err = url.ParseQuery(r.URL.RawQuery)
71+
if err != nil {
72+
w.WriteHeader(http.StatusBadRequest)
73+
return nil, err
74+
}
75+
76+
if requiredRole == RoleNone {
77+
return query, nil
78+
}
79+
80+
authInfo := makeAuthInfo(query)
81+
if !authenticate(authInfo, requiredRole) {
82+
replyError(w, http.StatusUnauthorized, APIErrorAuthenticationFailed)
83+
return nil, errAuthFailed
84+
}
85+
return query, nil
86+
}
87+
88+
func parsePost(r *http.Request, w http.ResponseWriter, parsed any, requiredRole Role) error {
89+
w.Header().Set("Access-Control-Allow-Origin", "*")
90+
91+
switch {
92+
case r.Method == http.MethodPost:
93+
break
94+
95+
case r.Method == http.MethodOptions:
96+
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
97+
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
98+
w.WriteHeader(http.StatusNoContent)
99+
return errOptionsRequest
100+
101+
default:
102+
w.Header().Set("Allow", "POST, OPTIONS")
103+
w.WriteHeader(http.StatusMethodNotAllowed)
104+
return errIncorrectMethod
105+
}
106+
107+
jsonData, err := io.ReadAll(r.Body)
108+
if err != nil {
109+
w.WriteHeader(http.StatusBadRequest)
110+
return err
111+
}
112+
113+
err = json.Unmarshal(jsonData, parsed)
114+
if err != nil {
115+
w.WriteHeader(http.StatusBadRequest)
116+
return err
117+
}
118+
119+
if requiredRole == RoleNone {
120+
return nil
121+
}
122+
123+
authInfo, ok := reflect.ValueOf(parsed).Elem().FieldByName("AuthInfo").Interface().(AuthInfo)
124+
if !ok {
125+
w.WriteHeader(http.StatusInternalServerError)
126+
return errNoAuthInfo
127+
}
128+
if !authenticate(authInfo, requiredRole) {
129+
replyError(w, http.StatusUnauthorized, APIErrorAuthenticationFailed)
130+
return errAuthFailed
131+
}
132+
return nil
133+
}
134+
135+
func makeAuthInfo(query url.Values) AuthInfo {
136+
return AuthInfo{
137+
Secret: query.Get("secret"),
138+
}
139+
}
140+
141+
func authenticate(authInfo AuthInfo, requiredRole Role) bool {
142+
return requiredRole == RoleNone || authInfo.Secret == apiSecret
143+
}
144+
145+
func replyError(w http.ResponseWriter, statusCode int, errMsg APIErrorString) {
146+
w.Header().Set("Content-Type", "application/json")
147+
w.WriteHeader(statusCode)
148+
149+
jsonData := []byte(`{"error":"` + string(errMsg) + `"}`)
150+
w.Header().Set("Content-Length", strconv.Itoa(len(jsonData)))
151+
_, _ = w.Write(jsonData)
152+
}
153+
154+
func replyOK(w http.ResponseWriter, data any) {
155+
if data == nil {
156+
w.WriteHeader(http.StatusNoContent)
157+
return
158+
}
159+
160+
w.Header().Set("Content-Type", "application/json")
161+
jsonData, err := json.Marshal(data)
162+
if err != nil {
163+
w.WriteHeader(http.StatusInternalServerError)
164+
return
165+
}
166+
w.Header().Set("Content-Length", strconv.Itoa(len(jsonData)))
167+
_, _ = w.Write(jsonData)
168+
}

0 commit comments

Comments
 (0)