currently proxies requests for owned games, since that is hidden behind a steam developer key and not entirely public
Rust 93%
Shell 7%

README.md

steamapi #

Small Rust web service that, given a SteamID64, returns that account's owned games via the Steam Web API (IPlayerService/GetOwnedGames/v1). Talks to Steam directly over HTTP (ureq) — deliberately no steam-related crates, as they tend to be thin wrappers with poor assumptions (e.g. requiring the API key via an env var).

Public endpoint: https://steamapi.7778777.online, called directly from browser JS at https://steamtags.bisks.net (see CORS below).

Steam Web API Terms compliance #

This app's design exists specifically to stay within Valve's API Terms of Use and the dev page guidance:

  • The API key never leaves the server. It's read from a config file at startup, only used server-side to call Steam, and is never logged, never echoed in error responses, and errors from the underlying HTTP client are deliberately generalized before logging/returning (ureq's own error Display can include the request URL, which contains the key as a query param).
  • A persistent daily call quota (steam.daily_limit in config, default 90,000 — a buffer under Valve's default 100,000/day cap) is tracked in state.json and survives process restarts. Once hit, the app returns 429 with Retry-After instead of calling Steam.
  • A 5-minute in-memory response cache per SteamID avoids burning quota on repeated lookups.
  • The endpoint is gated by a shared-secret token (X-Api-Token header). Since this API is called directly from browser JS on an allowed origin (see CORS below), the token is visible to anyone who opens devtools on that site — it's not a hard secret. It still stops low-effort scraping of steamapi.7778777.online directly (the token has to be pulled out of the frontend first), and can be rotated by editing config and restarting if it leaks/gets abused.
  • Identifiable User-Agent on outbound Steam calls, configurable via steam.user_agent (see below) rather than hardcoded, so this source tree can be shared/published without baking in a particular deployment's identity or contact info.

Config file #

The app takes a config path as its only CLI arg (defaults to ./config.toml if omitted). See config.toml.example for the full schema with comments. Summary:

[server]
bind_addr = "127.0.0.1:8095"

[steam]
api_key = "..."       # https://steamcommunity.com/dev/apikey
daily_limit = 90000
user_agent = "..."    # sent to Steam, not to callers of this API

[auth]
token = "..."          # required as the X-Api-Token header by callers

[quota]
state_file = "state.json"   # relative to the working dir; persists the daily tally

[cors]
allowed_origins = ["https://steamtags.bisks.net"]   # exact origin(s) allowed to call this cross-origin

The real config.toml holds secrets (API key, auth token) and is .gitignored — never commit it.

CORS #

The API is meant to be called directly from browser JS on https://steamtags.bisks.net, so cors.allowed_origins in config drives a tower-http CorsLayer that:

  • sets Access-Control-Allow-Origin to the exact matching origin (not *) on both the actual response and the OPTIONS preflight,
  • allows the GET method and the X-Api-Token request header,
  • omits CORS headers entirely for any other Origin, so the browser blocks the response for origins not in the list (the server still processes the request — this is a browser-enforced restriction, not a server-side allowlist of who can reach the endpoint at all).

To add another allowed frontend origin later, append it to cors.allowed_origins in /opt/steamapi/config.toml and restart the service.

API #

  • GET /healthz — 200 OK, no auth. Liveness check.
  • GET /owned-games/{steamid} — requires header X-Api-Token: <token>.
    • 400 if {steamid} isn't a 17-digit SteamID64.
    • 401 if the token is missing/wrong.
    • 429 (+ Retry-After) if the daily quota is exhausted.
    • 502 if the Steam API call itself fails.
    • 200 with { steamid, game_count, games: [{ appid, name, playtime_forever, img_icon_url }] }. A private/hidden game-details profile comes back from Steam as an empty payload, which this app surfaces as game_count: 0, games: [] rather than an error.

Deployment layout #

Two separate locations, on purpose:

  • /home/wrath/steamapi (this directory) — the dev tree. Owned by wrath. cargo build, edit source, git, etc. here as normal.
  • /opt/steamapi — the runtime deployment. Owned by a dedicated steamapi system user, contains only the built binary and the real config.toml/state.json. Runs as steamapi.service under systemd.

They're split because /home/wrath is 750 (not world-traversable), so a separate system user can't reach anything under it regardless of individual file permissions — the runtime binary/config have to live somewhere the steamapi user can actually reach, hence /opt/steamapi. config.toml itself only exists in /opt/steamapi (chmod 600, owned by steamapi) — there's no copy of the real secrets in the dev tree, only the config.toml.example template.

nginx (/etc/nginx/sites-available/steamapi) terminates TLS (Let's Encrypt, via certbot webroot) and reverse-proxies steamapi.7778777.online to 127.0.0.1:8095, where the app listens. The steamapi.service unit is sandboxed (ProtectSystem=strict, NoNewPrivileges=true, ReadWritePaths=/opt/steamapi only).

Deploying changes #

./deploy.sh

Builds the release binary, atomically replaces /opt/steamapi/steamapi (copy-then-rename, so it doesn't race with the currently-running process), and restarts steamapi.service. Requires sudo. This never touches /opt/steamapi/config.toml or state.json.

To change the API key, auth token, daily limit, or allowed CORS origins: edit /opt/steamapi/config.toml directly (sudo, since it's owned by steamapi), then sudo systemctl restart steamapi.

Operating #

sudo systemctl status steamapi
sudo systemctl restart steamapi
sudo journalctl -u steamapi -f