Skip to content

Commit eae4452

Browse files
committed
Accept profile URLs and resolve to Steam64
1 parent df93823 commit eae4452

11 files changed

Lines changed: 476 additions & 41 deletions

File tree

api/api.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import (
66
"github.com/go-chi/chi/v5"
77
)
88

9-
func Router() chi.Router {
9+
func Router(steamWebAPIKey string) chi.Router {
1010
r := chi.NewRouter()
11-
r.Mount("/v1", v1.Router())
11+
r.Mount("/v1", v1.Router(steamWebAPIKey))
1212
return r
1313
}

api/v1/users/router.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import (
88
"github.com/go-chi/chi/v5"
99
)
1010

11-
func Router() chi.Router {
11+
func Router(steamWebAPIKey string) chi.Router {
1212
r := chi.NewRouter()
13-
r.With(ratelimit.ThrottleByIP(time.Minute, 100)).Get("/{steamId}", fetchUserStatus)
13+
r.With(ratelimit.ThrottleByIP(time.Minute, 100)).Get("/{steamId}", fetchUserStatus(steamWebAPIKey))
1414
return r
1515
}

api/v1/users/users.go

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package users
22

33
import (
4+
"context"
45
"net/http"
6+
"strings"
7+
"time"
58

69
"reverse-watch/domain/dto"
710
"reverse-watch/domain/models"
@@ -19,39 +22,54 @@ type fetchUserStatusResponse struct {
1922
LastReversalTimestamp *uint64 `json:"last_reversal_timestamp,omitempty"`
2023
}
2124

22-
func fetchUserStatus(w http.ResponseWriter, r *http.Request) {
23-
factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory)
25+
func fetchUserStatus(steamWebAPIKey string) http.HandlerFunc {
26+
return func(w http.ResponseWriter, r *http.Request) {
27+
factory := r.Context().Value(middleware.FactoryContextKey).(repository.Factory)
2428

25-
steamIdStr := chi.URLParam(r, "steamId")
26-
steamId, err := models.ToSteamID(steamIdStr)
27-
if err != nil {
28-
render.Errorf(w, r, errors.BadRequest, "invalid steam id")
29-
return
30-
}
29+
steamIdStr := chi.URLParam(r, "steamId")
30+
client := &http.Client{Timeout: 15 * time.Second}
31+
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
32+
defer cancel()
3133

32-
reversals, err := factory.Reversal().List(&dto.ReversalListOptions{
33-
SteamID: steamId,
34-
OrderParam: &dto.OrderParam{Column: "id", Direction: dto.DESC},
35-
})
36-
if err != nil {
37-
render.Errorf(w, r, errors.InternalServerError, "failed to list reversals for steam id %q", steamId)
38-
return
39-
}
34+
var steamId *models.SteamID
35+
var err error
36+
if strings.TrimSpace(steamWebAPIKey) != "" {
37+
steamId, err = models.ParseSteamUserInputWithOpts(ctx, client, steamIdStr, &models.SteamUserInputOpts{
38+
UseWebAPIForVanity: true,
39+
SteamWebAPIKey: steamWebAPIKey,
40+
})
41+
} else {
42+
steamId, err = models.ParseSteamUserInput(ctx, client, steamIdStr)
43+
}
44+
if err != nil {
45+
render.Errorf(w, r, errors.BadRequest, "invalid steam id")
46+
return
47+
}
4048

41-
data := &fetchUserStatusResponse{
42-
SteamID: *steamId,
43-
}
49+
reversals, err := factory.Reversal().List(&dto.ReversalListOptions{
50+
SteamID: steamId,
51+
OrderParam: &dto.OrderParam{Column: "id", Direction: dto.DESC},
52+
})
53+
if err != nil {
54+
render.Errorf(w, r, errors.InternalServerError, "failed to list reversals for steam id %q", steamId)
55+
return
56+
}
4457

45-
var lastReversalTime uint64
46-
for _, reversal := range reversals {
47-
lastReversalTime = max(lastReversalTime, reversal.ReversedAt)
48-
if reversal.ExpungedAt == nil {
49-
data.HasReversed = true
50-
break
58+
data := &fetchUserStatusResponse{
59+
SteamID: *steamId,
5160
}
61+
62+
var lastReversalTime uint64
63+
for _, reversal := range reversals {
64+
lastReversalTime = max(lastReversalTime, reversal.ReversedAt)
65+
if reversal.ExpungedAt == nil {
66+
data.HasReversed = true
67+
break
68+
}
69+
}
70+
if data.HasReversed {
71+
data.LastReversalTimestamp = &lastReversalTime
72+
}
73+
render.JSON(w, r, data)
5274
}
53-
if data.HasReversed {
54-
data.LastReversalTimestamp = &lastReversalTime
55-
}
56-
render.JSON(w, r, data)
5775
}

api/v1/users/users_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,7 @@ func TestFetchUserStatus(t *testing.T) {
444444
}
445445

446446
factoryMiddleware := middleware.FactoryMiddleware(f)
447-
handler := http.HandlerFunc(fetchUserStatus)
447+
handler := fetchUserStatus("")
448448

449449
finalHandler := factoryMiddleware(handler)
450450

api/v1/v1.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import (
1010
"github.com/go-chi/chi/v5"
1111
)
1212

13-
func Router() chi.Router {
13+
func Router(steamWebAPIKey string) chi.Router {
1414
r := chi.NewRouter()
1515
r.Mount("/health", health.Router())
1616
r.Mount("/marketplace", marketplace.Router())
1717
r.Mount("/reversals", reversals.Router())
18-
r.Mount("/users", users.Router())
18+
r.Mount("/users", users.Router(steamWebAPIKey))
1919
r.Mount("/admin", admin.Router())
2020
return r
2121
}

config.example.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,8 @@
1111
"Port": "3434",
1212
"AllowedOrigins": ["allowed-origin-1", "allowed-origin-2"]
1313
},
14-
"Environment": "development"
14+
"Environment": "development",
15+
"Steam": {
16+
"WebAPIKey": ""
17+
}
1518
}

config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ type Config struct {
4040
SecretKey string
4141
}
4242
}
43+
44+
// Steam.WebAPIKey is optional. If set, /api/v1/users resolves /id/{vanity} via the Web API
45+
// instead of the default ?xml=1 request to the community site.
46+
Steam struct {
47+
WebAPIKey string
48+
}
4349
}
4450

4551
func Load() Config {
@@ -85,6 +91,7 @@ func load() Config {
8591
// Need to register environment variables if defaults aren't set
8692
v.BindEnv("HTTP.AllowedOrigins")
8793
v.BindEnv("Ingestors.CSFloat.SecretKey")
94+
v.BindEnv("Steam.WebAPIKey")
8895

8996
// Try to find the root directory, but don't panic if it fails since go.mod doesn't exist in production
9097
dir, err := GetProjectRootDir()

domain/models/steamid_input.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package models
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"regexp"
11+
"strings"
12+
)
13+
14+
var steamID64XMLRe = regexp.MustCompile(`(?i)<steamID64>\s*(\d+)\s*</steamID64>`)
15+
16+
const maxVanityLen = 64
17+
const maxSteamXMLBytes = 1 << 20
18+
const maxSteamAPIBodyBytes = 64 << 10
19+
20+
// SteamUserInputOpts tweaks how vanity URLs (/id/{name}) are turned into a SteamID.
21+
// Zero value: same as ParseSteamUserInput — uses the community profile ?xml=1 fetch.
22+
type SteamUserInputOpts struct {
23+
// UseWebAPIForVanity calls ISteamUser/ResolveVanityURL instead of scraping ?xml=1.
24+
UseWebAPIForVanity bool
25+
// SteamWebAPIKey from https://steamcommunity.com/dev/apikey — required when UseWebAPIForVanity is true.
26+
SteamWebAPIKey string
27+
}
28+
29+
// ParseSteamUserInput resolves a SteamID from a decimal 64-bit ID string, a
30+
// steamcommunity.com /profiles/{id} URL, or /id/{vanity} URL (via Valve's ?xml=1 profile feed).
31+
func ParseSteamUserInput(ctx context.Context, httpClient *http.Client, raw string) (*SteamID, error) {
32+
return ParseSteamUserInputWithOpts(ctx, httpClient, raw, nil)
33+
}
34+
35+
// ParseSteamUserInputWithOpts is like ParseSteamUserInput but can resolve custom URLs via the Steam Web API.
36+
func ParseSteamUserInputWithOpts(ctx context.Context, httpClient *http.Client, raw string, opts *SteamUserInputOpts) (*SteamID, error) {
37+
if httpClient == nil {
38+
httpClient = http.DefaultClient
39+
}
40+
s := strings.TrimSpace(raw)
41+
if s == "" {
42+
return nil, fmt.Errorf("empty steam identifier")
43+
}
44+
if id, err := ToSteamID(s); err == nil {
45+
return id, nil
46+
}
47+
normalized := normalizeSteamProfileURL(s)
48+
u, err := url.Parse(normalized)
49+
if err != nil {
50+
return nil, fmt.Errorf("parse url: %w", err)
51+
}
52+
host := strings.ToLower(strings.TrimPrefix(u.Hostname(), "www."))
53+
if host != "steamcommunity.com" {
54+
return nil, fmt.Errorf("not a steam community url")
55+
}
56+
path := strings.Trim(u.Path, "/")
57+
segments := strings.Split(path, "/")
58+
if len(segments) >= 2 && strings.EqualFold(segments[0], "profiles") {
59+
idStr := segments[1]
60+
if idStr == "" {
61+
return nil, fmt.Errorf("missing profile id")
62+
}
63+
return ToSteamID(idStr)
64+
}
65+
if len(segments) >= 2 && strings.EqualFold(segments[0], "id") {
66+
vanity := segments[1]
67+
if vanity == "" {
68+
return nil, fmt.Errorf("missing vanity url")
69+
}
70+
if len(vanity) > maxVanityLen {
71+
return nil, fmt.Errorf("vanity too long")
72+
}
73+
if opts != nil && opts.UseWebAPIForVanity {
74+
key := strings.TrimSpace(opts.SteamWebAPIKey)
75+
if key == "" {
76+
return nil, fmt.Errorf("steam web api key is required for web api vanity resolution")
77+
}
78+
return ResolveVanitySteamWebAPI(ctx, httpClient, key, vanity)
79+
}
80+
return resolveSteamVanityXML(ctx, httpClient, vanity)
81+
}
82+
return nil, fmt.Errorf("unrecognized steam profile path")
83+
}
84+
85+
func normalizeSteamProfileURL(s string) string {
86+
s = strings.TrimSpace(s)
87+
lower := strings.ToLower(s)
88+
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
89+
return s
90+
}
91+
if strings.HasPrefix(lower, "steamcommunity.com") || strings.HasPrefix(lower, "www.steamcommunity.com") {
92+
return "https://" + s
93+
}
94+
return s
95+
}
96+
97+
// resolveSteamVanityXML hits steamcommunity.com/id/{vanity}?xml=1 and pulls the 64-bit ID
98+
// from the response. Fine for the odd manual lookup; don’t use this for bulk scraping—Steam
99+
// will rate-limit or block you. For high volume, cache results, throttle requests, or use
100+
// the Web API ResolveVanityURL instead.
101+
func resolveSteamVanityXML(ctx context.Context, client *http.Client, vanity string) (*SteamID, error) {
102+
reqURL := "https://steamcommunity.com/id/" + url.PathEscape(vanity) + "?xml=1"
103+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
104+
if err != nil {
105+
return nil, err
106+
}
107+
req.Header.Set("User-Agent", "reverse-watch/1.0")
108+
resp, err := client.Do(req)
109+
if err != nil {
110+
return nil, err
111+
}
112+
defer resp.Body.Close()
113+
if resp.StatusCode != http.StatusOK {
114+
return nil, fmt.Errorf("steam profile returned status %d", resp.StatusCode)
115+
}
116+
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSteamXMLBytes))
117+
if err != nil {
118+
return nil, err
119+
}
120+
m := steamID64XMLRe.FindSubmatch(body)
121+
if m == nil {
122+
return nil, fmt.Errorf("steam id not found in profile response")
123+
}
124+
return ToSteamID(string(m[1]))
125+
}
126+
127+
// ResolveVanitySteamWebAPI turns a custom profile slug into SteamID64 using
128+
// ISteamUser/ResolveVanityURL. You need an API key from https://steamcommunity.com/dev/apikey.
129+
func ResolveVanitySteamWebAPI(ctx context.Context, client *http.Client, apiKey, vanity string) (*SteamID, error) {
130+
if client == nil {
131+
client = http.DefaultClient
132+
}
133+
apiKey = strings.TrimSpace(apiKey)
134+
if apiKey == "" {
135+
return nil, fmt.Errorf("empty steam web api key")
136+
}
137+
vanity = strings.TrimSpace(vanity)
138+
if vanity == "" {
139+
return nil, fmt.Errorf("empty vanity")
140+
}
141+
142+
q := url.Values{}
143+
q.Set("key", apiKey)
144+
q.Set("vanityurl", vanity)
145+
q.Set("url_type", "1")
146+
reqURL := "https://api.steampowered.com/ISteamUser/ResolveVanityURL/v1/?" + q.Encode()
147+
148+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
149+
if err != nil {
150+
return nil, err
151+
}
152+
req.Header.Set("User-Agent", "reverse-watch/1.0")
153+
154+
resp, err := client.Do(req)
155+
if err != nil {
156+
return nil, err
157+
}
158+
defer resp.Body.Close()
159+
160+
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSteamAPIBodyBytes))
161+
if err != nil {
162+
return nil, err
163+
}
164+
if resp.StatusCode != http.StatusOK {
165+
return nil, fmt.Errorf("steam api returned status %d", resp.StatusCode)
166+
}
167+
168+
var envelope struct {
169+
Response struct {
170+
Success int `json:"success"`
171+
SteamID string `json:"steamid"`
172+
Message string `json:"message"`
173+
} `json:"response"`
174+
}
175+
if err := json.Unmarshal(body, &envelope); err != nil {
176+
return nil, fmt.Errorf("decode steam api json: %w", err)
177+
}
178+
// success 1 = OK; 42 is the usual "no match" code.
179+
if envelope.Response.Success != 1 {
180+
msg := strings.TrimSpace(envelope.Response.Message)
181+
if msg == "" {
182+
return nil, fmt.Errorf("steam api could not resolve vanity (success=%d)", envelope.Response.Success)
183+
}
184+
return nil, fmt.Errorf("steam api: %s", msg)
185+
}
186+
if envelope.Response.SteamID == "" {
187+
return nil, fmt.Errorf("steam api returned empty steamid")
188+
}
189+
return ToSteamID(envelope.Response.SteamID)
190+
}

0 commit comments

Comments
 (0)