diff --git a/.dockerignore b/.dockerignore index be1d862..f5cb557 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,4 @@ data .gitea .claude .ruby-lsp +.env diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 502ca8a..6358b14 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -52,9 +52,11 @@ jobs: git checkout -q FETCH_HEAD # Clears out everything in the target except data/ (uploaded GPX - # files, offline.pmtiles — never part of the git checkout in the - # first place, since it's gitignored) and copies the fresh checkout - # over, dropping .git afterwards. + # files, offline.pmtiles) and copies the fresh checkout over, dropping + # .git afterwards. Dotfiles like .env (deployment secrets) are left + # alone too — the glob below doesn't match them — and neither is ever + # part of the git checkout in the first place, since both are + # gitignored. - name: Sync to /srv/docker/maptool run: | mkdir -p /srv/docker/maptool @@ -65,6 +67,24 @@ jobs: cp -a . /srv/docker/maptool/ rm -rf /srv/docker/maptool/.git + # Regenerated on every deploy from repo secrets (Settings -> Actions + # -> Secrets) rather than created once by hand on the server — see + # docs/deployment.adoc. Values are masked in the job log by Gitea + # automatically since they come from `secrets`. Secret names here + # can't start with "GITEA_"/"GITHUB_" — Gitea reserves those prefixes + # — hence OAUTH_CLIENT_* rather than GITEA_OAUTH_CLIENT_*; the .env + # var names on the right of `=` are unaffected, still what app.rb + # actually reads. + - name: Write .env + run: | + cat > /srv/docker/maptool/.env < 4.2" gem "sinatra-contrib", "~> 4.2" gem "puma", "~> 8.0" gem "rexml", "~> 3.4" +gem "oauth2", "~> 2.0" group :development do gem "rerun" diff --git a/Gemfile.lock b/Gemfile.lock index ea12060..4788232 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,18 @@ GEM remote: https://rubygems.org/ specs: + anonymous_loader (0.1.3) + version_gem (~> 1.1, >= 1.1.14) + auth-sanitizer (0.2.3) + version_gem (~> 1.1, >= 1.1.14) base64 (0.3.0) + bigdecimal (4.1.2) + faraday (2.14.3) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.4) + net-http (~> 0.5) ffi (1.17.4-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-musl) ffi (1.17.4-arm-linux-gnu) @@ -12,14 +23,33 @@ GEM ffi (1.17.4-x86_64-darwin) ffi (1.17.4-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-musl) + hashie (5.1.0) + logger + json (2.21.2) + jwt (3.2.0) + base64 listen (3.10.0) logger rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) logger (1.7.0) multi_json (1.21.1) + multi_xml (0.9.1) + bigdecimal (>= 3.1, < 5) mustermann (3.1.1) + net-http (0.9.1) + uri (>= 0.11.1) nio4r (2.7.5) + oauth2 (2.0.25) + anonymous_loader (~> 0.1, >= 0.1.3) + auth-sanitizer (~> 0.2, >= 0.2.3) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.7) + version_gem (~> 1.1, >= 1.1.14) puma (8.0.2) nio4r (~> 2.0) rack (3.2.7) @@ -49,7 +79,12 @@ GEM rack-protection (= 4.2.1) sinatra (= 4.2.1) tilt (~> 2.0) + snaky_hash (2.0.7) + hashie (>= 0.1.0, < 6) + version_gem (~> 1.1, >= 1.1.14) tilt (2.9.0) + uri (1.1.1) + version_gem (1.1.15) PLATFORMS aarch64-linux-gnu @@ -64,6 +99,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + oauth2 (~> 2.0) puma (~> 8.0) rerun rexml (~> 3.4) @@ -71,8 +107,13 @@ DEPENDENCIES sinatra-contrib (~> 4.2) CHECKSUMS + anonymous_loader (0.1.3) sha256=084a18e2439144d955447dc11dfc982f41fcd1583ad32d4d55151325dc44cb55 + auth-sanitizer (0.2.3) sha256=db10aac92cfbe4c64ab637eebcbe1d67395d1694798041362173370f59933e3c base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bundler (4.0.18) sha256=02d9a17429de1847b4e0c9f27a9ee4b20c0a74c0a641b4e77195d6019e3618ac + faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278 + faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588 ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 @@ -83,11 +124,17 @@ CHECKSUMS ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080 + multi_xml (0.9.1) sha256=7ce766b59c17241ed62976caeae1fae9b2431b263398c35396239a68c4a64e57 mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + oauth2 (2.0.25) sha256=2f736a2f93c2caa67c1b08dc3c9889bb907d62643f3183fefaa1723cba6a82ac puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac @@ -98,7 +145,10 @@ CHECKSUMS rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a + snaky_hash (2.0.7) sha256=7d02c70012a3f932e48860cd024577908300c9aa615e0cb9b450aaa749cbcb4d tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5 + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + version_gem (1.1.15) sha256=a73241587b29252e3567e0c818ded80730eb754d43b508f6ce4db22ca9ba27d0 BUNDLED WITH 4.0.18 diff --git a/app.rb b/app.rb index 7ac9be3..ac7e5e4 100644 --- a/app.rb +++ b/app.rb @@ -1,5 +1,7 @@ require "sinatra/base" require "sinatra/json" +require "oauth2" +require "securerandom" require_relative "lib/maptool/gpx" require_relative "lib/maptool/gpx_store" require_relative "lib/maptool/gpsd_client" @@ -9,9 +11,73 @@ module Maptool set :public_folder, File.join(__dir__, "public") set :views, File.join(__dir__, "views") + # A persistent secret across restarts/redeploys — otherwise every deploy + # would invalidate every logged-in session's cookie (see + # docs/deployment.adoc). Falls back to a random one so the app still + # loads locally/in CI without it set; that fallback just means sessions + # don't survive a restart there, which is fine outside production. + set :sessions, httponly: true, secure: production?, same_site: :lax, + secret: ENV.fetch("SESSION_SECRET") { SecureRandom.hex(32) } + OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles") GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx")) + # Login delegates to the Gitea instance the user already has an account + # on, rather than maintaining a local username/password — see + # docs/architecture.adoc. Only ALLOWED_GITEA_USER may write; anyone else + # can authenticate against Gitea but is still refused. No defaults for + # the OAuth2 client id/secret/user: blank values just mean login (and + # thus writing) doesn't work, which is fine outside production — nothing + # here is eagerly validated at load time. + GITEA_URL = ENV.fetch("GITEA_URL", "https://git.priesol.net") + ALLOWED_GITEA_USER = ENV.fetch("MAPTOOL_ALLOWED_GITEA_USER", "") + OAUTH_CLIENT = OAuth2::Client.new( + ENV.fetch("GITEA_OAUTH_CLIENT_ID", ""), + ENV.fetch("GITEA_OAUTH_CLIENT_SECRET", ""), + site: GITEA_URL, + authorize_url: "/login/oauth/authorize", + token_url: "/login/oauth/access_token" + ) + + helpers do + def logged_in? + !!session[:user] + end + + def require_login! + halt 401, json(error: "login required", login_url: "/login") unless logged_in? + end + + def callback_url + "#{request.base_url}/auth/callback" + end + end + + get "/login" do + session[:oauth_state] = SecureRandom.hex(16) + redirect OAUTH_CLIENT.auth_code.authorize_url( + redirect_uri: callback_url, + state: session[:oauth_state], + scope: "read:user" + ) + end + + get "/auth/callback" do + halt 400, "invalid state" unless params[:state] && params[:state] == session.delete(:oauth_state) + + token = OAUTH_CLIENT.auth_code.get_token(params[:code], redirect_uri: callback_url) + user = token.get("/api/v1/user").parsed + halt 403, "not authorized" unless user["login"] == ALLOWED_GITEA_USER + + session[:user] = user["login"] + redirect "/" + end + + get "/logout" do + session.clear + redirect "/" + end + # Live GNSS position, polled by the frontend from /api/gnss/position. A # single background thread reads gpsd (if it's running — harmless retry # loop if not, see GpsdClient) and keeps this updated; polling instead @@ -41,7 +107,7 @@ module Maptool end get "/" do - erb :index + erb :index, locals: { logged_in: logged_in?, user: session[:user] } end get "/api/health" do @@ -66,6 +132,7 @@ module Maptool end post "/api/gpx" do + require_login! file = params[:file] halt 400, json(error: "file is required") unless file && file[:tempfile] @@ -94,6 +161,7 @@ module Maptool end post "/api/gpx/folder" do + require_login! begin GPX_STORE.create_folder(params[:path].to_s) rescue GpxStore::InvalidPath @@ -104,6 +172,7 @@ module Maptool end delete "/api/gpx/file/*" do + require_login! begin GPX_STORE.delete_file(params[:splat].first) rescue GpxStore::InvalidPath @@ -116,6 +185,7 @@ module Maptool end delete "/api/gpx/folder/*" do + require_login! begin GPX_STORE.delete_folder(params[:splat].first) rescue GpxStore::InvalidPath @@ -128,6 +198,7 @@ module Maptool end post "/api/gpx/move" do + require_login! begin path = GPX_STORE.move(from: params[:from].to_s, to_folder: params[:to_folder].to_s) rescue GpxStore::InvalidPath diff --git a/config/puma.rb b/config/puma.rb index aab4c9b..9cf873b 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,8 +1,9 @@ # 127.0.0.1 by default: local-only, not exposed on the network (see -# docs/architecture.adoc). The Docker deployment overrides this to 0.0.0.0 -# via BIND_HOST — inside a container, binding to loopback only would make -# the app unreachable through the published port; Docker's own port -# publishing is the network boundary there instead. +# docs/architecture.adoc). The Docker deployment overrides this via +# BIND_HOST (to "[::]", dual-stack IPv4+IPv6 — see docker-compose.yaml) — +# inside a container, binding to loopback only would make the app +# unreachable through the published port; Docker's own port publishing is +# the network boundary there instead. bind "tcp://#{ENV.fetch('BIND_HOST', '127.0.0.1')}:#{ENV.fetch('PORT', 4567)}" environment ENV.fetch("RACK_ENV", "development") diff --git a/docker-compose.yaml b/docker-compose.yaml index 6b566e3..416f936 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,8 +5,18 @@ services: ports: - "4567:4567" environment: - BIND_HOST: 0.0.0.0 + # "[::]", not "0.0.0.0": binding the IPv6 wildcard also accepts IPv4 + # connections (dual-stack is the Linux default), so this covers both + # without a second listener. Puma's bind URL needs the brackets around + # a literal IPv6 address (config/puma.rb interpolates this as-is). + BIND_HOST: "[::]" RACK_ENV: production + # Read from .env (docker compose loads it automatically) — not + # committed, see docs/deployment.adoc. + SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required, see docs/deployment.adoc} + GITEA_OAUTH_CLIENT_ID: ${GITEA_OAUTH_CLIENT_ID:?GITEA_OAUTH_CLIENT_ID is required, see docs/deployment.adoc} + GITEA_OAUTH_CLIENT_SECRET: ${GITEA_OAUTH_CLIENT_SECRET:?GITEA_OAUTH_CLIENT_SECRET is required, see docs/deployment.adoc} + MAPTOOL_ALLOWED_GITEA_USER: ${MAPTOOL_ALLOWED_GITEA_USER:?MAPTOOL_ALLOWED_GITEA_USER is required, see docs/deployment.adoc} volumes: # Uploaded GPX files and the offline PMTiles extract — kept outside # the image so a rebuild/redeploy doesn't wipe them (see diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 79cb7a0..fedebd1 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -239,5 +239,32 @@ Online/Offline) with no extra re-add handling needed. == Networking -The Sinatra server only binds to `127.0.0.1`. It is not meant to be -reachable from other machines. +By default (local dev) the Sinatra server only binds to `127.0.0.1` +and isn't reachable from other machines. The deployed instance is an +exception — see `docs/deployment.adoc` for `BIND_HOST` and how it's +exposed via Docker/nginx as `map.priesol.net`. + +== Authentication + +The GPX store's read endpoints (viewing the map, the folder tree, +individual files) are public — anyone with the URL can look. Its write +endpoints (`POST /api/gpx`, `POST /api/gpx/folder`, `DELETE +/api/gpx/file/*`, `DELETE /api/gpx/folder/*`, `POST /api/gpx/move`) +require login, guarded by `require_login!` in `app.rb`. + +Login delegates to the Gitea instance at `git.priesol.net` via OAuth2 +(`GET /login` redirects to Gitea's authorize endpoint, `GET +/auth/callback` exchanges the code and fetches +`/api/v1/user`) rather than maintaining a separate local +username/password — there's already an account to reuse, and it keeps +credential storage (password hash, resets, ...) out of this app +entirely. Successfully authenticating with Gitea is not by itself +enough to write, though — the callback also checks the returned +`login` against `MAPTOOL_ALLOWED_GITEA_USER`, so registering (or +already having) any other account on that Gitea instance doesn't grant +access here. + +Session state is a signed cookie (`Rack::Session::Cookie`, `secure` +in production, `httponly`, `same_site: lax`) keyed off +`SESSION_SECRET` — see `docs/deployment.adoc` for where that and the +OAuth2 client id/secret live (not committed to the repo). diff --git a/docs/deployment.adoc b/docs/deployment.adoc index 64a60df..a8a619b 100644 --- a/docs/deployment.adoc +++ b/docs/deployment.adoc @@ -68,6 +68,40 @@ the deployed container specifically; Docker's own port publishing (`ports:` in the compose file) is the network boundary there instead of Puma's bind address. +== Secrets (`.env`) + +`docker-compose.yaml` reads `SESSION_SECRET`, `GITEA_OAUTH_CLIENT_ID`, +`GITEA_OAUTH_CLIENT_SECRET` and `MAPTOOL_ALLOWED_GITEA_USER` from the +environment (see `docs/architecture.adoc` for what they're for) — +docker compose loads these automatically from a `.env` file next to +`docker-compose.yaml`, i.e. `/srv/docker/maptool/.env`. That file is +gitignored and dockerignored on purpose: nothing here ever puts +secrets in the repository or a built image layer. + +It's not created by hand on the server — the deploy job's "Write +.env" step regenerates it on every deploy from repo-level Gitea +Actions secrets (*Settings → Actions → Secrets*): `SESSION_SECRET`, +`OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `MAPTOOL_ALLOWED_GITEA_USER`. +The OAuth2 ones aren't named `GITEA_OAUTH_CLIENT_*` to match the `.env` +var names 1:1 — Gitea rejects secret names starting with `GITEA_` or +`GITHUB_` as reserved — but the workflow step maps them to the right +`.env` var names regardless. Gitea masks secret values in the job log +automatically. Set these once in the repo settings and every deploy +since keeps `.env` in sync — no server-side step needed beyond the +first-time OAuth2 application registration below. + +`SESSION_SECRET` must stay the same value across deploys (regenerating +it every push would silently log everyone out every time `main` +changes) — generate it once (`openssl rand -hex 32`) and store that +same value as the secret from then on, don't regenerate per-deploy. + +Register the OAuth2 application on the Gitea side (as the +`MAPTOOL_ALLOWED_GITEA_USER` account): *Settings → Applications → +Manage OAuth2 Applications → Create a new OAuth2 Application*, with +redirect URI `https://map.priesol.net/auth/callback`. That gives you +the client id/secret to store as the two `GITEA_OAUTH_*` secrets +above. + == CI deploy job `.gitea/workflows/ci.yml`'s `deploy` job runs after `check` passes, only diff --git a/public/css/style.css b/public/css/style.css index 5618662..6eadeb9 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -26,6 +26,15 @@ html, body { color: #333; } +.topbar__auth { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #555; +} + .app-body { flex: 1; display: flex; diff --git a/public/js/gpx.js b/public/js/gpx.js index cba6b06..c7ccfb4 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -84,6 +84,7 @@ export class GpxPanelControl { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `path=${encodeURIComponent(path.trim())}`, }); + if (redirectIfUnauthorized(response)) return; if (response.ok) { const { tree } = await response.json(); this._renderTree(tree); @@ -351,6 +352,7 @@ export class GpxPanelControl { formData.append("folder", folder); try { const response = await fetch(UPLOAD_URL, { method: "POST", body: formData }); + if (redirectIfUnauthorized(response)) return; if (!response.ok) throw new Error("upload failed"); ({ tree } = await response.json()); } catch (err) { @@ -366,6 +368,7 @@ export class GpxPanelControl { if (!window.confirm(`Delete "${path}"?`)) return; const response = await fetch(gpxFileUrl(path), { method: "DELETE" }); + if (redirectIfUnauthorized(response)) return; if (!response.ok) return; if (this._visible.has(path)) { @@ -382,6 +385,7 @@ export class GpxPanelControl { const response = await fetch(`/api/gpx/folder/${path.split("/").map(encodeURIComponent).join("/")}`, { method: "DELETE", }); + if (redirectIfUnauthorized(response)) return; if (!response.ok) return; const prefix = `${path}/`; @@ -404,6 +408,7 @@ export class GpxPanelControl { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `from=${encodeURIComponent(from)}&to_folder=${encodeURIComponent(toFolder)}`, }); + if (redirectIfUnauthorized(response)) return; if (!response.ok) return; const { path: newPath, tree } = await response.json(); @@ -422,6 +427,15 @@ export class GpxPanelControl { } } +// Write endpoints require login (see app.rb); a 401 means the session +// expired or was never established — send the user to log in rather than +// silently failing. +function redirectIfUnauthorized(response) { + if (response.status !== 401) return false; + window.location.href = "/login"; + return true; +} + function sourceId(path) { return `gpx-${path}`; } diff --git a/views/index.erb b/views/index.erb index fe89fb2..fef330d 100644 --- a/views/index.erb +++ b/views/index.erb @@ -1,6 +1,14 @@
MapTool +
+ <% if logged_in %> + <%= user %> + Log out + <% else %> + Log in + <% end %> +