Require Gitea login to write GPX data; IPv6-capable bind
CI / check (push) Successful in 6m28s
CI / deploy (push) Successful in 13s

Upload/delete/move/create-folder now require logging in via Gitea
OAuth2 (only MAPTOOL_ALLOWED_GITEA_USER is granted write access);
reading stays public. Deploy secrets (session secret, OAuth2 client
credentials, allowed user) are written to .env from Gitea Actions
repo secrets on every deploy instead of living on the server by hand.

Also switch the container's bind address to "[::]" (dual-stack) so
the app is reachable over IPv6, not just IPv4.
This commit is contained in:
randy committed 2026-08-27 18:02:37 +02:00
1 parent ae67cc72c7
commit e13d1a1e97
13 files changed
+261 -11

No files matched your search

+1
View File
@@ -3,3 +3,4 @@ data
.gitea
.claude
.ruby-lsp
.env
+23 -3
View File
@@ -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 <<EOF
SESSION_SECRET=${{ secrets.SESSION_SECRET }}
GITEA_OAUTH_CLIENT_ID=${{ secrets.OAUTH_CLIENT_ID }}
GITEA_OAUTH_CLIENT_SECRET=${{ secrets.OAUTH_CLIENT_SECRET }}
MAPTOOL_ALLOWED_GITEA_USER=${{ secrets.MAPTOOL_ALLOWED_GITEA_USER }}
EOF
chmod 600 /srv/docker/maptool/.env
- name: Deploy
run: |
cd /srv/docker/maptool
+4
View File
@@ -19,3 +19,7 @@
# Local tooling settings
.claude/settings.local.json
# Deployment secrets (SESSION_SECRET, Gitea OAuth2 client id/secret) — lives
# only on the server, see docs/deployment.adoc
.env
+1
View File
@@ -4,6 +4,7 @@ gem "sinatra", "~> 4.2"
gem "sinatra-contrib", "~> 4.2"
gem "puma", "~> 8.0"
gem "rexml", "~> 3.4"
gem "oauth2", "~> 2.0"
group :development do
gem "rerun"
+50
View File
@@ -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
+72 -1
View File
@@ -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
+5 -4
View File
@@ -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")
+11 -1
View File
@@ -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
+29 -2
View File
@@ -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).
+34
View File
@@ -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
+9
View File
@@ -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;
+14
View File
@@ -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}`;
}
+8
View File
@@ -1,6 +1,14 @@
<div id="app">
<header class="topbar">
<span class="topbar__brand">MapTool</span>
<div class="topbar__auth">
<% if logged_in %>
<span><%= user %></span>
<a href="/logout">Log out</a>
<% else %>
<a href="/login">Log in</a>
<% end %>
</div>
</header>
<div class="app-body">