diff --git a/api/v1/steam/resolve.go b/api/v1/steam/resolve.go new file mode 100644 index 00000000..57f0a97c --- /dev/null +++ b/api/v1/steam/resolve.go @@ -0,0 +1,38 @@ +package steam + +import ( + "net/http" + "strings" + + "reverse-watch/domain/models" + "reverse-watch/errors" + "reverse-watch/middleware" + "reverse-watch/render" + steamservice "reverse-watch/service/steam" +) + +type resolveSteamIDResponse struct { + SteamID models.SteamID `json:"steam_id"` +} + +func resolveSteamID(w http.ResponseWriter, r *http.Request) { + steamSvc, ok := r.Context().Value(middleware.SteamServiceContextKey).(*steamservice.Service) + if !ok || steamSvc == nil { + render.Errorf(w, r, errors.InternalServerError, "steam service not configured") + return + } + + raw := strings.TrimSpace(r.URL.Query().Get("vanityUrl")) + if raw == "" { + render.Errorf(w, r, errors.BadRequest, "missing vanityUrl") + return + } + + id, err := steamSvc.ResolveSteamID(r.Context(), raw) + if err != nil { + render.Error(w, r, err) + return + } + + render.JSON(w, r, &resolveSteamIDResponse{SteamID: *id}) +} diff --git a/api/v1/steam/router.go b/api/v1/steam/router.go new file mode 100644 index 00000000..f40dce6e --- /dev/null +++ b/api/v1/steam/router.go @@ -0,0 +1,15 @@ +package steam + +import ( + "time" + + "reverse-watch/ratelimit" + + "github.com/go-chi/chi/v5" +) + +func Router() chi.Router { + r := chi.NewRouter() + r.With(ratelimit.ThrottleByIP(time.Minute, 100)).Get("/resolve-vanity", resolveSteamID) + return r +} diff --git a/api/v1/v1.go b/api/v1/v1.go index e4ffe1b3..db419bca 100644 --- a/api/v1/v1.go +++ b/api/v1/v1.go @@ -5,6 +5,7 @@ import ( "reverse-watch/api/v1/health" "reverse-watch/api/v1/marketplace" "reverse-watch/api/v1/reversals" + "reverse-watch/api/v1/steam" "reverse-watch/api/v1/users" "github.com/go-chi/chi/v5" @@ -16,6 +17,7 @@ func Router() chi.Router { r.Mount("/marketplace", marketplace.Router()) r.Mount("/reversals", reversals.Router()) r.Mount("/users", users.Router()) + r.Mount("/steam", steam.Router()) r.Mount("/admin", admin.Router()) return r } diff --git a/config.example.json b/config.example.json index 5ada50ee..2b281233 100644 --- a/config.example.json +++ b/config.example.json @@ -11,5 +11,8 @@ "Port": "3434", "AllowedOrigins": ["allowed-origin-1", "allowed-origin-2"] }, - "Environment": "development" + "Environment": "development", + "Steam": { + "WebAPIKeys": ["api-key-1", "api-key-2"] + } } \ No newline at end of file diff --git a/config/config.go b/config/config.go index f1e0a35f..27c64fee 100644 --- a/config/config.go +++ b/config/config.go @@ -8,6 +8,7 @@ import ( "strings" "reverse-watch/domain/models/constants" + "reverse-watch/util" "github.com/go-viper/mapstructure/v2" "github.com/spf13/viper" @@ -40,6 +41,12 @@ type Config struct { SecretKey string } } + + // Steam.WebAPIKeys is optional. If set, /api/v1/steam/resolve-vanity can use the Steam Web API + // (keys are rotated) instead of any fallback resolution. + Steam struct { + WebAPIKeys *util.Ring[string] + } } func Load() Config { @@ -63,6 +70,34 @@ func load() Config { return nil, fmt.Errorf("invalid environment") } }, + func(from reflect.Value, to reflect.Value) (interface{}, error) { + ringType := reflect.TypeOf(&util.Ring[string]{}) + if to.Type() != ringType { + return from.Interface(), nil + } + + keys := make([]string, 0) + switch from.Kind() { + case reflect.String: + for _, part := range strings.Split(from.String(), ",") { + part = strings.TrimSpace(part) + if part != "" { + keys = append(keys, part) + } + } + case reflect.Slice, reflect.Array: + for i := 0; i < from.Len(); i++ { + val := strings.TrimSpace(fmt.Sprint(from.Index(i).Interface())) + if val != "" { + keys = append(keys, val) + } + } + default: + return util.NewRing[string](nil), nil + } + + return util.NewRing(keys), nil + }, )) v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) @@ -85,6 +120,7 @@ func load() Config { // Need to register environment variables if defaults aren't set v.BindEnv("HTTP.AllowedOrigins") v.BindEnv("Ingestors.CSFloat.SecretKey") + v.BindEnv("Steam.WebAPIKeys") // Try to find the root directory, but don't panic if it fails since go.mod doesn't exist in production dir, err := GetProjectRootDir() diff --git a/main.go b/main.go index 5a87a715..1d59799e 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "reverse-watch/repository/factory" "reverse-watch/secret" "reverse-watch/server" + steamservice "reverse-watch/service/steam" ) func main() { @@ -36,7 +37,11 @@ func main() { done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGTERM) - srv, err := server.New(cfg, f) + steamSvc := steamservice.New(steamservice.Options{ + WebAPIKeys: cfg.Steam.WebAPIKeys, + }) + + srv, err := server.New(cfg, f, steamSvc) if err != nil { panic(err) } diff --git a/middleware/steam_service.go b/middleware/steam_service.go new file mode 100644 index 00000000..9b9a8b1a --- /dev/null +++ b/middleware/steam_service.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "context" + "net/http" + + steamservice "reverse-watch/service/steam" +) + +const SteamServiceContextKey ContextKey = "steamService" + +func SteamServiceMiddleware(svc *steamservice.Service) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), SteamServiceContextKey, svc) + next.ServeHTTP(w, r.WithContext(ctx)) + } + return http.HandlerFunc(fn) + } +} diff --git a/server/server.go b/server/server.go index 5f5a85bc..83c844a2 100644 --- a/server/server.go +++ b/server/server.go @@ -9,6 +9,7 @@ import ( "reverse-watch/config" "reverse-watch/domain/repository" rwmiddleware "reverse-watch/middleware" + steamservice "reverse-watch/service/steam" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -19,7 +20,7 @@ type Server struct { r chi.Router } -func New(cfg config.Config, factory repository.Factory) (*Server, error) { +func New(cfg config.Config, factory repository.Factory, steamSvc *steamservice.Service) (*Server, error) { r := chi.NewRouter() firefoxExtensionOrigin := regexp.MustCompile("^moz-extension://[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") @@ -36,7 +37,7 @@ func New(cfg config.Config, factory repository.Factory) (*Server, error) { // Firefox extension IDs are randomly generated for each user. // Therefore, we're scoping requests made from Firefox extensions to specific endpoints only. if firefoxExtensionOrigin.MatchString(origin) { - if strings.HasPrefix(r.RequestURI, "/api/v1/users/") { + if strings.HasPrefix(r.RequestURI, "/api/v1/users/") || strings.HasPrefix(r.RequestURI, "/api/v1/steam/resolve-vanity") { return true } } @@ -59,6 +60,7 @@ func New(cfg config.Config, factory repository.Factory) (*Server, error) { } r.Use(rwmiddleware.FactoryMiddleware(factory)) + r.Use(rwmiddleware.SteamServiceMiddleware(steamSvc)) r.Get("/", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "static/index.html") diff --git a/service/steam/service.go b/service/steam/service.go new file mode 100644 index 00000000..4f34b6a7 --- /dev/null +++ b/service/steam/service.go @@ -0,0 +1,164 @@ +package steam + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "reverse-watch/domain/models" + "reverse-watch/errors" + "reverse-watch/util" +) + +const maxVanityLen = 64 +const maxSteamAPIBodyBytes = 64 << 10 + +type Service struct { + client *http.Client + keys *util.Ring[string] +} + +type Options struct { + HTTPClient *http.Client + WebAPIKeys *util.Ring[string] +} + +func New(opts Options) *Service { + client := opts.HTTPClient + if client == nil { + client = http.DefaultClient + } + + keys := opts.WebAPIKeys + if keys == nil { + keys = util.NewRing[string](nil) + } + + return &Service{ + client: client, + keys: keys, + } +} + +func (s *Service) ResolveSteamID(ctx context.Context, raw string) (*models.SteamID, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New(errors.BadRequest, "missing vanityUrl") + } + + // This endpoint should accept a vanity URL, not a raw numeric SteamID64. + if _, err := models.ToSteamID(raw); err == nil { + return nil, errors.New(errors.BadRequest, "expected vanityUrl, got steam id") + } + + u, err := url.Parse(raw) + if err != nil { + return nil, errors.New(errors.BadRequest, "invalid vanityUrl", err) + } + + // If the scheme is missing, default to https so the host/path parsing works. + if u.Scheme == "" { + u, err = url.Parse("https://" + raw) + if err != nil { + return nil, errors.New(errors.BadRequest, "invalid vanityUrl", err) + } + } + + if !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { + return nil, errors.New(errors.BadRequest, "url scheme must be http or https") + } + + host := strings.ToLower(strings.TrimPrefix(u.Hostname(), "www.")) + if host != "steamcommunity.com" { + return nil, errors.New(errors.BadRequest, fmt.Sprintf("expected steamcommunity.com, got %q", host)) + } + + path := strings.Trim(u.Path, "/") + segments := strings.Split(path, "/") + if len(segments) < 2 || !strings.EqualFold(segments[0], "id") { + return nil, errors.New(errors.BadRequest, "expected steam vanity URL path /id/{vanity}") + } + + vanity := segments[1] + if vanity == "" { + return nil, errors.New(errors.BadRequest, "missing vanity url") + } + if len(vanity) > maxVanityLen { + return nil, errors.New(errors.BadRequest, "vanity too long") + } + return s.resolveVanity(ctx, vanity) +} + +func (s *Service) resolveVanity(ctx context.Context, vanity string) (*models.SteamID, error) { + key, ok := s.keys.Next() + if !ok { + return nil, errors.New(errors.BadRequest, "steam web api keys not configured") + } + return resolveVanityWebAPI(ctx, s.client, key, vanity) +} + +func resolveVanityWebAPI(ctx context.Context, client *http.Client, apiKey, vanity string) (*models.SteamID, error) { + apiKey = strings.TrimSpace(apiKey) + if apiKey == "" { + return nil, errors.New(errors.BadRequest, "empty steam web api key") + } + vanity = strings.TrimSpace(vanity) + if vanity == "" { + return nil, errors.New(errors.BadRequest, "empty vanity") + } + + q := url.Values{} + q.Set("key", apiKey) + q.Set("vanityurl", vanity) + q.Set("url_type", "1") + reqURL := "https://api.steampowered.com/ISteamUser/ResolveVanityURL/v1/?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, errors.New(errors.InternalServerError, "failed to create request", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, errors.New(errors.InternalServerError, "steam web api request failed", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New(errors.BadRequest, fmt.Sprintf("steam api returned status %d", resp.StatusCode)) + } + + // Decode directly from the response body (still bounded to avoid large reads). + var envelope struct { + Response struct { + Success int `json:"success"` + SteamID string `json:"steamid"` + Message string `json:"message"` + } `json:"response"` + } + + if err := json.NewDecoder(io.LimitReader(resp.Body, maxSteamAPIBodyBytes)).Decode(&envelope); err != nil { + return nil, errors.New(errors.JSONDecode, "failed to decode steam api json", err) + } + + if envelope.Response.Success != 1 { + msg := strings.TrimSpace(envelope.Response.Message) + if msg == "" { + return nil, errors.New(errors.BadRequest, fmt.Sprintf("steam api could not resolve vanity (success=%d)", envelope.Response.Success)) + } + return nil, errors.New(errors.BadRequest, "steam api: "+msg) + } + if envelope.Response.SteamID == "" { + return nil, errors.New(errors.BadRequest, "steam api returned empty steamid") + } + + id, err := models.ToSteamID(envelope.Response.SteamID) + if err != nil { + return nil, errors.New(errors.BadRequest, "steam api returned invalid steamid", err) + } + return id, nil +} diff --git a/service/steam/service_test.go b/service/steam/service_test.go new file mode 100644 index 00000000..e1e7768b --- /dev/null +++ b/service/steam/service_test.go @@ -0,0 +1,177 @@ +package steam + +import ( + "context" + "encoding/json" + stderrors "errors" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + reverrors "reverse-watch/errors" + "reverse-watch/util" +) + +type rewriteTransport struct { + base http.RoundTripper + communityBase *url.URL + apiBase *url.URL +} + +func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + switch clone.URL.Hostname() { + case "steamcommunity.com", "www.steamcommunity.com": + clone.URL.Scheme = t.communityBase.Scheme + clone.URL.Host = t.communityBase.Host + case "api.steampowered.com": + clone.URL.Scheme = t.apiBase.Scheme + clone.URL.Host = t.apiBase.Host + } + return t.base.RoundTrip(clone) +} + +func mustParseURL(t *testing.T, s string) *url.URL { + t.Helper() + u, err := url.Parse(s) + if err != nil { + t.Fatalf("parse url: %v", err) + } + return u +} + +func TestResolveSteamID_Number(t *testing.T) { + svc := New(Options{}) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + _, err := svc.ResolveSteamID(ctx, "76561198000000000") + if err == nil { + t.Fatalf("expected error for raw steam id") + } + var e *reverrors.Error + if !stderrors.As(err, &e) { + t.Fatalf("expected errors.Error, got %T: %v", err, err) + } + if e.Code != reverrors.BadRequest.Code { + t.Fatalf("expected BadRequest code=%d, got code=%d", reverrors.BadRequest.Code, e.Code) + } +} + +func TestResolveSteamID_ProfileURL(t *testing.T) { + svc := New(Options{}) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + _, err := svc.ResolveSteamID(ctx, "steamcommunity.com/profiles/76561198000000000") + if err == nil { + t.Fatalf("expected error for /profiles URL") + } +} + +func TestResolveSteamID_Vanity_WebAPI(t *testing.T) { + community := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "should not call community xml fallback", http.StatusBadRequest) + })) + defer community.Close() + + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ISteamUser/ResolveVanityURL/v1/" { + http.Error(w, "unexpected request path", http.StatusBadRequest) + return + } + if got := r.URL.Query().Get("vanityurl"); got != "testuser" { + http.Error(w, "unexpected vanityurl", http.StatusBadRequest) + return + } + if got := r.URL.Query().Get("key"); got != "k1" { + http.Error(w, "unexpected key", http.StatusBadRequest) + return + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "success": 1, + "steamid": "76561198000000000", + }, + }) + })) + defer api.Close() + + client := &http.Client{ + Transport: &rewriteTransport{ + base: http.DefaultTransport, + communityBase: mustParseURL(t, community.URL), + apiBase: mustParseURL(t, api.URL), + }, + } + svc := New(Options{HTTPClient: client, WebAPIKeys: util.NewRing([]string{"k1"})}) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + id, err := svc.ResolveSteamID(ctx, "https://steamcommunity.com/id/testuser") + if err != nil { + t.Fatalf("ResolveSteamID: %v", err) + } + if got := id.String(); got != "76561198000000000" { + t.Fatalf("unexpected steamid: %s", got) + } +} + +func TestResolveSteamID_Vanity_WebAPI_RotatesKeys(t *testing.T) { + var ( + mu sync.Mutex + keys []string + ) + + community := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "should not call xml", http.StatusBadRequest) + })) + defer community.Close() + + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + keys = append(keys, r.URL.Query().Get("key")) + mu.Unlock() + + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "success": 1, + "steamid": "76561198000000000", + }, + }) + })) + defer api.Close() + + client := &http.Client{ + Transport: &rewriteTransport{ + base: http.DefaultTransport, + communityBase: mustParseURL(t, community.URL), + apiBase: mustParseURL(t, api.URL), + }, + } + svc := New(Options{HTTPClient: client, WebAPIKeys: util.NewRing([]string{"k1", "k2"})}) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if _, err := svc.ResolveSteamID(ctx, "steamcommunity.com/id/testuser"); err != nil { + t.Fatalf("ResolveSteamID #1: %v", err) + } + if _, err := svc.ResolveSteamID(ctx, "steamcommunity.com/id/testuser"); err != nil { + t.Fatalf("ResolveSteamID #2: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(keys) != 2 { + t.Fatalf("expected 2 api calls, got %d", len(keys)) + } + if keys[0] != "k1" || keys[1] != "k2" { + t.Fatalf("expected key rotation k1,k2 got %v", keys) + } +} diff --git a/static/index.html b/static/index.html index 95bfedd0..a9790074 100644 --- a/static/index.html +++ b/static/index.html @@ -388,13 +388,13 @@ -

Enter a 64-bit Steam ID to check their reversal history

+

Paste a 64-bit Steam ID or a Steam profile link

@@ -448,7 +448,8 @@ searchBtn.innerHTML = ''; try { - const response = await fetch(`/api/v1/users/${steamId}`); + const resolvedSteamId = await resolveSteamId(steamId); + const response = await fetch(`/api/v1/users/${encodeURIComponent(resolvedSteamId)}`); if (!response.ok) { const errorData = await response.json(); @@ -469,6 +470,34 @@ } }); + async function resolveSteamId(input) { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error('missing input'); + } + + // If it already looks like a SteamID64, skip the resolve endpoint. + if (/^\d{16,20}$/.test(trimmed)) { + return trimmed; + } + + const res = await fetch(`/api/v1/steam/resolve-vanity?vanityUrl=${encodeURIComponent(trimmed)}`); + if (!res.ok) { + let msg = 'could not resolve steam id'; + try { + const errorData = await res.json(); + msg = `${errorData.code} - ${errorData.message}` + (errorData.details ? ` (${errorData.details})` : ''); + } catch (_) {} + throw new Error(msg); + } + + const data = await res.json(); + if (!data || !data.steam_id) { + throw new Error('invalid response from resolve endpoint'); + } + return data.steam_id; + } + function displayResult(data) { const statusIcon = document.getElementById('statusIcon'); const resultTitle = document.getElementById('resultTitle'); diff --git a/util/ring.go b/util/ring.go new file mode 100644 index 00000000..3733ad9f --- /dev/null +++ b/util/ring.go @@ -0,0 +1,46 @@ +package util + +import "sync/atomic" + +// Ring rotates through items in a thread-safe way. +// +// It's intended for small fixed slices that are read concurrently. +type Ring[T any] struct { + items []T + idx uint32 +} + +func NewRing[T any](items []T) *Ring[T] { + // Copy to avoid unexpected mutation by callers. + cp := append([]T(nil), items...) + return &Ring[T]{items: cp} +} + +func (r *Ring[T]) Next() (T, bool) { + if r == nil { + var zero T + return zero, false + } + + n := len(r.items) + if n == 0 { + var zero T + return zero, false + } + + for { + old := atomic.LoadUint32(&r.idx) + i := old % uint32(n) + next := (old + 1) % uint32(n) + if atomic.CompareAndSwapUint32(&r.idx, old, next) { + return r.items[i], true + } + } +} + +func (r *Ring[T]) Len() int { + if r == nil { + return 0 + } + return len(r.items) +}