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 errorDisplaycan include the request URL, which contains the key as a query param). - A persistent daily call quota (
steam.daily_limitin config, default 90,000 — a buffer under Valve's default 100,000/day cap) is tracked instate.jsonand survives process restarts. Once hit, the app returns429withRetry-Afterinstead 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-Tokenheader). 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 ofsteamapi.7778777.onlinedirectly (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-Originto the exact matching origin (not*) on both the actual response and theOPTIONSpreflight, - allows the
GETmethod and theX-Api-Tokenrequest 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 headerX-Api-Token: <token>.400if{steamid}isn't a 17-digit SteamID64.401if the token is missing/wrong.429(+Retry-After) if the daily quota is exhausted.502if the Steam API call itself fails.200with{ 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 asgame_count: 0, games: []rather than an error.
Deployment layout #
Two separate locations, on purpose:
/home/wrath/steamapi(this directory) — the dev tree. Owned bywrath.cargo build, edit source, git, etc. here as normal./opt/steamapi— the runtime deployment. Owned by a dedicatedsteamapisystem user, contains only the built binary and the realconfig.toml/state.json. Runs assteamapi.serviceunder 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