Require Gitea login to write GPX data; IPv6-capable bind
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:
1 parent
ae67cc72c7
commit
e13d1a1e97
13 files changed
+261
-11
No files matched your search
@@ -3,3 +3,4 @@ data
|
|||||||
.gitea
|
.gitea
|
||||||
.claude
|
.claude
|
||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
|
.env
|
||||||
+23
-3
@@ -52,9 +52,11 @@ jobs:
|
|||||||
git checkout -q FETCH_HEAD
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
# Clears out everything in the target except data/ (uploaded GPX
|
# Clears out everything in the target except data/ (uploaded GPX
|
||||||
# files, offline.pmtiles — never part of the git checkout in the
|
# files, offline.pmtiles) and copies the fresh checkout over, dropping
|
||||||
# first place, since it's gitignored) and copies the fresh checkout
|
# .git afterwards. Dotfiles like .env (deployment secrets) are left
|
||||||
# over, dropping .git afterwards.
|
# 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
|
- name: Sync to /srv/docker/maptool
|
||||||
run: |
|
run: |
|
||||||
mkdir -p /srv/docker/maptool
|
mkdir -p /srv/docker/maptool
|
||||||
@@ -65,6 +67,24 @@ jobs:
|
|||||||
cp -a . /srv/docker/maptool/
|
cp -a . /srv/docker/maptool/
|
||||||
rm -rf /srv/docker/maptool/.git
|
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
|
- name: Deploy
|
||||||
run: |
|
run: |
|
||||||
cd /srv/docker/maptool
|
cd /srv/docker/maptool
|
||||||
|
|||||||
@@ -19,3 +19,7 @@
|
|||||||
|
|
||||||
# Local tooling settings
|
# Local tooling settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Deployment secrets (SESSION_SECRET, Gitea OAuth2 client id/secret) — lives
|
||||||
|
# only on the server, see docs/deployment.adoc
|
||||||
|
.env
|
||||||
@@ -4,6 +4,7 @@ gem "sinatra", "~> 4.2"
|
|||||||
gem "sinatra-contrib", "~> 4.2"
|
gem "sinatra-contrib", "~> 4.2"
|
||||||
gem "puma", "~> 8.0"
|
gem "puma", "~> 8.0"
|
||||||
gem "rexml", "~> 3.4"
|
gem "rexml", "~> 3.4"
|
||||||
|
gem "oauth2", "~> 2.0"
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem "rerun"
|
gem "rerun"
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
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)
|
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-gnu)
|
||||||
ffi (1.17.4-aarch64-linux-musl)
|
ffi (1.17.4-aarch64-linux-musl)
|
||||||
ffi (1.17.4-arm-linux-gnu)
|
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-darwin)
|
||||||
ffi (1.17.4-x86_64-linux-gnu)
|
ffi (1.17.4-x86_64-linux-gnu)
|
||||||
ffi (1.17.4-x86_64-linux-musl)
|
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)
|
listen (3.10.0)
|
||||||
logger
|
logger
|
||||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||||
rb-inotify (~> 0.9, >= 0.9.10)
|
rb-inotify (~> 0.9, >= 0.9.10)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
multi_json (1.21.1)
|
multi_json (1.21.1)
|
||||||
|
multi_xml (0.9.1)
|
||||||
|
bigdecimal (>= 3.1, < 5)
|
||||||
mustermann (3.1.1)
|
mustermann (3.1.1)
|
||||||
|
net-http (0.9.1)
|
||||||
|
uri (>= 0.11.1)
|
||||||
nio4r (2.7.5)
|
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)
|
puma (8.0.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
rack (3.2.7)
|
rack (3.2.7)
|
||||||
@@ -49,7 +79,12 @@ GEM
|
|||||||
rack-protection (= 4.2.1)
|
rack-protection (= 4.2.1)
|
||||||
sinatra (= 4.2.1)
|
sinatra (= 4.2.1)
|
||||||
tilt (~> 2.0)
|
tilt (~> 2.0)
|
||||||
|
snaky_hash (2.0.7)
|
||||||
|
hashie (>= 0.1.0, < 6)
|
||||||
|
version_gem (~> 1.1, >= 1.1.14)
|
||||||
tilt (2.9.0)
|
tilt (2.9.0)
|
||||||
|
uri (1.1.1)
|
||||||
|
version_gem (1.1.15)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux-gnu
|
aarch64-linux-gnu
|
||||||
@@ -64,6 +99,7 @@ PLATFORMS
|
|||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
oauth2 (~> 2.0)
|
||||||
puma (~> 8.0)
|
puma (~> 8.0)
|
||||||
rerun
|
rerun
|
||||||
rexml (~> 3.4)
|
rexml (~> 3.4)
|
||||||
@@ -71,8 +107,13 @@ DEPENDENCIES
|
|||||||
sinatra-contrib (~> 4.2)
|
sinatra-contrib (~> 4.2)
|
||||||
|
|
||||||
CHECKSUMS
|
CHECKSUMS
|
||||||
|
anonymous_loader (0.1.3) sha256=084a18e2439144d955447dc11dfc982f41fcd1583ad32d4d55151325dc44cb55
|
||||||
|
auth-sanitizer (0.2.3) sha256=db10aac92cfbe4c64ab637eebcbe1d67395d1694798041362173370f59933e3c
|
||||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||||
|
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
|
||||||
bundler (4.0.18) sha256=02d9a17429de1847b4e0c9f27a9ee4b20c0a74c0a641b4e77195d6019e3618ac
|
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-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
|
||||||
ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
|
ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
|
||||||
ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
|
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-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
|
||||||
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
|
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
|
||||||
ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
|
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
|
listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
|
||||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||||
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
|
multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
|
||||||
|
multi_xml (0.9.1) sha256=7ce766b59c17241ed62976caeae1fae9b2431b263398c35396239a68c4a64e57
|
||||||
mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
|
mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
|
||||||
|
net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
|
||||||
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
|
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
|
||||||
|
oauth2 (2.0.25) sha256=2f736a2f93c2caa67c1b08dc3c9889bb907d62643f3183fefaa1723cba6a82ac
|
||||||
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
|
puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb
|
||||||
rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7
|
rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7
|
||||||
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
|
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
|
||||||
@@ -98,7 +145,10 @@ CHECKSUMS
|
|||||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||||
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
|
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
|
||||||
sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a
|
sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a
|
||||||
|
snaky_hash (2.0.7) sha256=7d02c70012a3f932e48860cd024577908300c9aa615e0cb9b450aaa749cbcb4d
|
||||||
tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5
|
tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5
|
||||||
|
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
|
||||||
|
version_gem (1.1.15) sha256=a73241587b29252e3567e0c818ded80730eb754d43b508f6ce4db22ca9ba27d0
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
4.0.18
|
4.0.18
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
require "sinatra/base"
|
require "sinatra/base"
|
||||||
require "sinatra/json"
|
require "sinatra/json"
|
||||||
|
require "oauth2"
|
||||||
|
require "securerandom"
|
||||||
require_relative "lib/maptool/gpx"
|
require_relative "lib/maptool/gpx"
|
||||||
require_relative "lib/maptool/gpx_store"
|
require_relative "lib/maptool/gpx_store"
|
||||||
require_relative "lib/maptool/gpsd_client"
|
require_relative "lib/maptool/gpsd_client"
|
||||||
@@ -9,9 +11,73 @@ module Maptool
|
|||||||
set :public_folder, File.join(__dir__, "public")
|
set :public_folder, File.join(__dir__, "public")
|
||||||
set :views, File.join(__dir__, "views")
|
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")
|
OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles")
|
||||||
GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx"))
|
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
|
# Live GNSS position, polled by the frontend from /api/gnss/position. A
|
||||||
# single background thread reads gpsd (if it's running — harmless retry
|
# single background thread reads gpsd (if it's running — harmless retry
|
||||||
# loop if not, see GpsdClient) and keeps this updated; polling instead
|
# loop if not, see GpsdClient) and keeps this updated; polling instead
|
||||||
@@ -41,7 +107,7 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "/" do
|
get "/" do
|
||||||
erb :index
|
erb :index, locals: { logged_in: logged_in?, user: session[:user] }
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/health" do
|
get "/api/health" do
|
||||||
@@ -66,6 +132,7 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
post "/api/gpx" do
|
post "/api/gpx" do
|
||||||
|
require_login!
|
||||||
file = params[:file]
|
file = params[:file]
|
||||||
halt 400, json(error: "file is required") unless file && file[:tempfile]
|
halt 400, json(error: "file is required") unless file && file[:tempfile]
|
||||||
|
|
||||||
@@ -94,6 +161,7 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
post "/api/gpx/folder" do
|
post "/api/gpx/folder" do
|
||||||
|
require_login!
|
||||||
begin
|
begin
|
||||||
GPX_STORE.create_folder(params[:path].to_s)
|
GPX_STORE.create_folder(params[:path].to_s)
|
||||||
rescue GpxStore::InvalidPath
|
rescue GpxStore::InvalidPath
|
||||||
@@ -104,6 +172,7 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
delete "/api/gpx/file/*" do
|
delete "/api/gpx/file/*" do
|
||||||
|
require_login!
|
||||||
begin
|
begin
|
||||||
GPX_STORE.delete_file(params[:splat].first)
|
GPX_STORE.delete_file(params[:splat].first)
|
||||||
rescue GpxStore::InvalidPath
|
rescue GpxStore::InvalidPath
|
||||||
@@ -116,6 +185,7 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
delete "/api/gpx/folder/*" do
|
delete "/api/gpx/folder/*" do
|
||||||
|
require_login!
|
||||||
begin
|
begin
|
||||||
GPX_STORE.delete_folder(params[:splat].first)
|
GPX_STORE.delete_folder(params[:splat].first)
|
||||||
rescue GpxStore::InvalidPath
|
rescue GpxStore::InvalidPath
|
||||||
@@ -128,6 +198,7 @@ module Maptool
|
|||||||
end
|
end
|
||||||
|
|
||||||
post "/api/gpx/move" do
|
post "/api/gpx/move" do
|
||||||
|
require_login!
|
||||||
begin
|
begin
|
||||||
path = GPX_STORE.move(from: params[:from].to_s, to_folder: params[:to_folder].to_s)
|
path = GPX_STORE.move(from: params[:from].to_s, to_folder: params[:to_folder].to_s)
|
||||||
rescue GpxStore::InvalidPath
|
rescue GpxStore::InvalidPath
|
||||||
|
|||||||
+5
-4
@@ -1,8 +1,9 @@
|
|||||||
# 127.0.0.1 by default: local-only, not exposed on the network (see
|
# 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
|
# docs/architecture.adoc). The Docker deployment overrides this via
|
||||||
# via BIND_HOST — inside a container, binding to loopback only would make
|
# BIND_HOST (to "[::]", dual-stack IPv4+IPv6 — see docker-compose.yaml) —
|
||||||
# the app unreachable through the published port; Docker's own port
|
# inside a container, binding to loopback only would make the app
|
||||||
# publishing is the network boundary there instead.
|
# 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)}"
|
bind "tcp://#{ENV.fetch('BIND_HOST', '127.0.0.1')}:#{ENV.fetch('PORT', 4567)}"
|
||||||
|
|
||||||
environment ENV.fetch("RACK_ENV", "development")
|
environment ENV.fetch("RACK_ENV", "development")
|
||||||
|
|||||||
+11
-1
@@ -5,8 +5,18 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "4567:4567"
|
- "4567:4567"
|
||||||
environment:
|
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
|
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:
|
volumes:
|
||||||
# Uploaded GPX files and the offline PMTiles extract — kept outside
|
# Uploaded GPX files and the offline PMTiles extract — kept outside
|
||||||
# the image so a rebuild/redeploy doesn't wipe them (see
|
# the image so a rebuild/redeploy doesn't wipe them (see
|
||||||
|
|||||||
+29
-2
@@ -239,5 +239,32 @@ Online/Offline) with no extra re-add handling needed.
|
|||||||
|
|
||||||
== Networking
|
== Networking
|
||||||
|
|
||||||
The Sinatra server only binds to `127.0.0.1`. It is not meant to be
|
By default (local dev) the Sinatra server only binds to `127.0.0.1`
|
||||||
reachable from other machines.
|
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).
|
||||||
@@ -68,6 +68,40 @@ the deployed container specifically; Docker's own port publishing
|
|||||||
(`ports:` in the compose file) is the network boundary there instead
|
(`ports:` in the compose file) is the network boundary there instead
|
||||||
of Puma's bind address.
|
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
|
== CI deploy job
|
||||||
|
|
||||||
`.gitea/workflows/ci.yml`'s `deploy` job runs after `check` passes, only
|
`.gitea/workflows/ci.yml`'s `deploy` job runs after `check` passes, only
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ html, body {
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar__auth {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
.app-body {
|
.app-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export class GpxPanelControl {
|
|||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: `path=${encodeURIComponent(path.trim())}`,
|
body: `path=${encodeURIComponent(path.trim())}`,
|
||||||
});
|
});
|
||||||
|
if (redirectIfUnauthorized(response)) return;
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const { tree } = await response.json();
|
const { tree } = await response.json();
|
||||||
this._renderTree(tree);
|
this._renderTree(tree);
|
||||||
@@ -351,6 +352,7 @@ export class GpxPanelControl {
|
|||||||
formData.append("folder", folder);
|
formData.append("folder", folder);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(UPLOAD_URL, { method: "POST", body: formData });
|
const response = await fetch(UPLOAD_URL, { method: "POST", body: formData });
|
||||||
|
if (redirectIfUnauthorized(response)) return;
|
||||||
if (!response.ok) throw new Error("upload failed");
|
if (!response.ok) throw new Error("upload failed");
|
||||||
({ tree } = await response.json());
|
({ tree } = await response.json());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -366,6 +368,7 @@ export class GpxPanelControl {
|
|||||||
if (!window.confirm(`Delete "${path}"?`)) return;
|
if (!window.confirm(`Delete "${path}"?`)) return;
|
||||||
|
|
||||||
const response = await fetch(gpxFileUrl(path), { method: "DELETE" });
|
const response = await fetch(gpxFileUrl(path), { method: "DELETE" });
|
||||||
|
if (redirectIfUnauthorized(response)) return;
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
|
|
||||||
if (this._visible.has(path)) {
|
if (this._visible.has(path)) {
|
||||||
@@ -382,6 +385,7 @@ export class GpxPanelControl {
|
|||||||
const response = await fetch(`/api/gpx/folder/${path.split("/").map(encodeURIComponent).join("/")}`, {
|
const response = await fetch(`/api/gpx/folder/${path.split("/").map(encodeURIComponent).join("/")}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
if (redirectIfUnauthorized(response)) return;
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
|
|
||||||
const prefix = `${path}/`;
|
const prefix = `${path}/`;
|
||||||
@@ -404,6 +408,7 @@ export class GpxPanelControl {
|
|||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: `from=${encodeURIComponent(from)}&to_folder=${encodeURIComponent(toFolder)}`,
|
body: `from=${encodeURIComponent(from)}&to_folder=${encodeURIComponent(toFolder)}`,
|
||||||
});
|
});
|
||||||
|
if (redirectIfUnauthorized(response)) return;
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
|
|
||||||
const { path: newPath, tree } = await response.json();
|
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) {
|
function sourceId(path) {
|
||||||
return `gpx-${path}`;
|
return `gpx-${path}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<span class="topbar__brand">MapTool</span>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div class="app-body">
|
<div class="app-body">
|
||||||
|
|||||||
Reference in new issue
Block a user