Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions api/v1/steam/resolve.go
Original file line number Diff line number Diff line change
@@ -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})
}
15 changes: 15 additions & 0 deletions api/v1/steam/router.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions api/v1/v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
5 changes: 4 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
36 changes: 36 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"reverse-watch/domain/models/constants"
"reverse-watch/util"

"github.com/go-viper/mapstructure/v2"
"github.com/spf13/viper"
Expand Down Expand Up @@ -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 {
Expand All @@ -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(".", "_"))
Expand All @@ -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()
Expand Down
7 changes: 6 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"reverse-watch/repository/factory"
"reverse-watch/secret"
"reverse-watch/server"
steamservice "reverse-watch/service/steam"
)

func main() {
Expand All @@ -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)
}
Expand Down
20 changes: 20 additions & 0 deletions middleware/steam_service.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 4 additions & 2 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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}$")
Expand All @@ -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
}
}
Expand All @@ -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")
Expand Down
164 changes: 164 additions & 0 deletions service/steam/service.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Comment on lines +64 to +69
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why reparse again if the first one succeeded?


if !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") {
return nil, errors.New(errors.BadRequest, "url scheme must be http or https")
}
Comment on lines +71 to +73
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can just omit this check since the parsing succeeded.


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))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can use errors.Newf(...)

}

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)
}
Comment on lines +96 to +102
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really necessary to have this a separate function. Unlikely it will ever be called by something else.


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
}
Loading