From a2743dd7fbb72253c713d170ff80ae37cf018702 Mon Sep 17 00:00:00 2001 From: Pedan Date: Thu, 18 Sep 2025 00:23:21 +0200 Subject: [PATCH] complete overhaul --- .env.example | 15 + .gitea/workflows/build-and-push.yml | 61 ++++ .gitea/workflows/docker.yml | 22 -- .gitignore | 2 + Dockerfile | 7 +- README.md | 112 +++++- app/web/main.go | 106 +++++- app/web/static/index.html | 394 +++++++++++++-------- app/web/static/main.js | 211 ++++++++++-- app/web/static/route.js | 511 +++++++++++++++++++++++++--- docker-compose.yml | 31 ++ 11 files changed, 1201 insertions(+), 271 deletions(-) create mode 100644 .env.example create mode 100644 .gitea/workflows/build-and-push.yml delete mode 100644 .gitea/workflows/docker.yml create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8f6f5c3 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Web app configuration +PORT=8080 + +# Routing backend (Valhalla) +# When using docker-compose, the service is named `valhalla` and exposes 8002 +VALHALLA_URL=http://valhalla:8002/route + +# Geocoding (Nominatim) +# Defaults to the public Nominatim instance if not set +# NOMINATIM_URL=https://nominatim.openstreetmap.org +# Per Nominatim policy, set a descriptive User-Agent including contact URL or email +NOMINATIM_USER_AGENT=FreeMoto/1.0 (+https://fm.ztsw.de/) + +# Logging verbosity: debug, info, warn, error +LOG_LEVEL=info diff --git a/.gitea/workflows/build-and-push.yml b/.gitea/workflows/build-and-push.yml new file mode 100644 index 0000000..5ed3e0a --- /dev/null +++ b/.gitea/workflows/build-and-push.yml @@ -0,0 +1,61 @@ +name: build-and-push + +on: + push: + branches: [ main, master ] + tags: [ 'v*.*.*', 'v*', '*.*.*' ] + +#on: [workflow_dispatch] + +# Registry/image can be customized here +env: + REGISTRY: git.ztsw.de + IMAGE: git.ztsw.de/pedan/freemoto/freemoto-web + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU (multi-arch) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.CR_USERNAME }} + password: ${{ secrets.CR_PASSWORD }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }} + + - name: Build and push (multi-arch) + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml deleted file mode 100644 index 94930a8..0000000 --- a/.gitea/workflows/docker.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Build and Publish Docker Image -on: [workflow_dispatch] -jobs: - build-amd64: - runs-on: amd64 - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Build AMD64 - run: | - docker build -t git.ztsw.de/pedan/freemoto/freemoto-web:amd64 . - docker push git.ztsw.de/pedan/freemoto/freemoto-web:amd64 - - build-arm64: - runs-on: arm64 - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Build ARM64 - run: | - docker build -t git.ztsw.de/pedan/freemoto/freemoto-web:arm64 . - docker push git.ztsw.de/pedan/freemoto/freemoto-web:arm64 diff --git a/.gitignore b/.gitignore index 7b2f841..0775a56 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ backend/custom_tiles # Static build output dist/ build/ +custom_tiles/ +custom_files/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 99f8069..8802f89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,9 +28,10 @@ RUN apk add --no-cache ca-certificates tzdata wget COPY --from=builder /out/freemoto-web /app/web/freemoto-web COPY app/web/static/ /app/web/static/ -# Use non-root user -RUN adduser -S -D -H -h /nonexistent appuser && \ - chown -R appuser:appuser /app +# Use non-root user (create group explicitly for Alpine) +RUN addgroup -S appuser \ + && adduser -S -D -H -h /nonexistent -G appuser appuser \ + && chown -R appuser:appuser /app USER appuser ENV PORT=8080 diff --git a/README.md b/README.md index 47ee71d..6642c74 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,30 @@ # FreeMoto Web -A web-based motorcycle route planner built with Go, Leaflet, Bootstrap, and Valhalla routing, ready for containerized deployment. +A modern, mobile‑first motorcycle route planner. Backend in Go (proxy + static), frontend with Bootstrap + Leaflet, and Valhalla as the routing engine. Container‑ready with docker‑compose and CI for multi‑arch images. ## Features -- Interactive map with OpenStreetMap tiles -- Route planning with options (avoid highways, tolls, ferries, unpaved, shortest route) -- Docker-ready and configurable via `.env` +- Modern, mobile‑friendly UI (single tap on mobile, double‑click on desktop) +- Multi‑waypoint routing with draggable markers and reorder/remove list +- Round Trip planner + - Distance target (km) and option to avoid repeated segments + - Respects all routing options +- Rider controls + - Twistiness slider (prefer curvy roads) + - Highways preference slider (prefer faster highways) + - Avoid: freeways, tolls, ferries, unpaved; Shortest route toggle +- Directions + - Directions bottom sheet (collapsible, drag-to-resize) + - Next‑maneuver banner; optional voice prompt +- Export GPX + - Track (`//`) and/or Route (`/`) + - OSMAnd‑friendly +- Theming and UX + - Dracula‑style dark mode toggle with persistence + - Summary pill (distance/time), floating actions, clean layout +- Performance + - Gzip compression and strong caching for static assets + - Small Alpine runtime image ## Getting Started @@ -42,6 +60,32 @@ go run main.go Visit [http://localhost:8080](http://localhost:8080) in your browser. +### Quick Start (docker-compose) + +Build and run the web app alongside a Valhalla routing container with a single command. A `docker-compose.yml` is provided at the repository root. + +1. Copy the example environment file and adjust values if needed: + +```bash +cp .env.example .env +``` + +2. Start the stack: + +```bash +docker compose up -d --build +``` + +3. Open the app: + +``` +http://localhost:8080 +``` + +Notes: +- The web app will proxy `/route` to the `valhalla` service at `http://valhalla:8002/route`. +- You may mount `custom_files/` into the Valhalla container (see `docker-compose.yml`) for tiles and custom configurations. + ### Docker Build and run the container: @@ -79,6 +123,8 @@ services: - tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf ``` +Alternatively, use the provided root-level `docker-compose.yml` for a local build of the web app and a Valhalla companion service, then run `docker compose up -d --build`. + ### Notes on Nominatim - Please follow the official usage policy for Nominatim. Provide a meaningful `NOMINATIM_USER_AGENT` that includes a contact URL or email. The default is `FreeMoto/1.0 (+https://fm.ztsw.de/)`. @@ -90,12 +136,60 @@ services: - Supported values: `debug`, `info` (default), `warn`, `error`. - Incoming requests are logged at `info` level. Upstream success traces (Valhalla/Nominatim) are at `debug`. Errors are at `error`. -## Customization +## UI Overview -- **Map UI:** Edit `static/index.html` and `static/main.js` -- **Routing logic:** Edit `static/route.js` -- **Geolocation/autocomplete:** Edit `static/geolocate.js` -- **Backend proxy:** Edit `main.go` +- Start/End fields with autocomplete and reverse geocoding +- Tap/double‑click to add waypoints; drag markers to adjust +- Waypoint list for reorder/remove +- Route Options include twistiness, highways, and avoid toggles +- Directions sheet (collapsible, draggable) + Next‑maneuver banner and optional voice +- Export GPX: Track/Route/Both + +## Performance + +- Static assets are served with gzip (when supported) and strong caching + - `Cache-Control: public, max-age=31536000, immutable` for `/static` + - Short caching for `index.html` + +## Multi‑arch build and Registry + +Build and push multi‑arch (amd64 + arm64) with Buildx: + +```bash +# Login first (example) +docker login git.ztsw.de -u + +export IMAGE=git.ztsw.de/pedan/freemoto/freemoto-web +export TAG=latest +export TAG2=$(date +%Y%m%d-%H%M) + +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t $IMAGE:$TAG \ + -t $IMAGE:$TAG2 \ + --push \ + . +``` + +## CI: Gitea Actions + +A workflow at `.gitea/workflows/build-and-push.yml` builds and pushes multi‑arch images on pushes and tags. + +Configure repository secrets: + +- `CR_USERNAME` – registry username +- `CR_PASSWORD` – registry token/password + +Adjust `env.IMAGE`/`env.REGISTRY` in the workflow if you move the image. + +## Customization & Development + +- Frontend + - `app/web/static/index.html` – layout and controls + - `app/web/static/main.js` – map init, theme, global controls + - `app/web/static/route.js` – routing, waypoints, round trips, GPX +- Backend + - `app/web/main.go` – static serving (gzip + caching), proxies `/route`, `/geocode`, `/reverse` ## License diff --git a/app/web/main.go b/app/web/main.go index f8391b3..7d9bbee 100644 --- a/app/web/main.go +++ b/app/web/main.go @@ -1,12 +1,14 @@ package main import ( + "compress/gzip" "context" "fmt" "io" "log" "net/http" "os" + "path/filepath" "strings" "sync/atomic" "time" @@ -89,7 +91,8 @@ func main() { port = "8080" } - http.Handle("/static/", withLogging(http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))) + // Static files under /static with long-lived caching and gzip for text assets + http.Handle("/static/", withLogging(withCacheGzip(http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))), true))) http.Handle("/healthz", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) @@ -105,10 +108,35 @@ func main() { proxyToNominatimGET(w, r, nominatimBase+"/reverse", nominatimUA) }))) http.Handle("/", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Serve index if r.URL.Path == "/" { - http.ServeFile(w, r, "./static/index.html") + // Short cache for HTML entrypoint + setShortCache(w) + maybeGzipAndServeFile(w, r, "./static/index.html") return } + // Serve other assets from ./static with long cache for versioned files + // Decide cache and compression based on extension + ext := strings.ToLower(filepath.Ext(r.URL.Path)) + if isTextLikeExt(ext) { + setLongCache(w) + w.Header().Add("Vary", "Accept-Encoding") + // Use gzip when supported + if acceptsGzip(r) { + gz := gzip.NewWriter(w) + defer gz.Close() + w.Header().Set("Content-Encoding", "gzip") + // Avoid Content-Length when gzipping + w.Header().Del("Content-Length") + gzw := &gzipResponseWriter{ResponseWriter: w, gw: gz} + http.ServeFile(gzw, r, "./static/"+r.URL.Path[1:]) + return + } + http.ServeFile(w, r, "./static/"+r.URL.Path[1:]) + return + } + // Non-text assets: just set long cache + setLongCache(w) http.ServeFile(w, r, "./static/"+r.URL.Path[1:]) }))) logf(levelInfo, "Listening on :%s", port) @@ -246,4 +274,78 @@ func clientIP(r *http.Request) string { return xff } return r.RemoteAddr +} + +// ---- Performance helpers: caching and gzip compression ---- + +func setShortCache(w http.ResponseWriter) { + // Short cache e.g., for HTML entrypoint + w.Header().Set("Cache-Control", "public, max-age=300") +} + +func setLongCache(w http.ResponseWriter) { + // Long-lived cache for versioned static assets + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") +} + +func isTextLikeExt(ext string) bool { + switch ext { + case ".html", ".css", ".js", ".mjs", ".json", ".svg", ".xml", ".txt", ".map", ".webmanifest": + return true + default: + return false + } +} + +func acceptsGzip(r *http.Request) bool { + ae := r.Header.Get("Accept-Encoding") + return strings.Contains(ae, "gzip") +} + +type gzipResponseWriter struct { + http.ResponseWriter + gw *gzip.Writer +} + +func (g *gzipResponseWriter) Write(b []byte) (int, error) { + return g.gw.Write(b) +} + +// withCacheGzip wraps a handler to set cache headers and gzip text-like responses +func withCacheGzip(next http.Handler, longCache bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if longCache { + setLongCache(w) + } else { + setShortCache(w) + } + ext := strings.ToLower(filepath.Ext(r.URL.Path)) + if isTextLikeExt(ext) && acceptsGzip(r) { + w.Header().Add("Vary", "Accept-Encoding") + w.Header().Set("Content-Encoding", "gzip") + w.Header().Del("Content-Length") + gz := gzip.NewWriter(w) + defer gz.Close() + gzw := &gzipResponseWriter{ResponseWriter: w, gw: gz} + next.ServeHTTP(gzw, r) + return + } + next.ServeHTTP(w, r) + }) +} + +func maybeGzipAndServeFile(w http.ResponseWriter, r *http.Request, path string) { + // For text-like files, gzip when supported + ext := strings.ToLower(filepath.Ext(path)) + if isTextLikeExt(ext) && acceptsGzip(r) { + w.Header().Add("Vary", "Accept-Encoding") + w.Header().Set("Content-Encoding", "gzip") + w.Header().Del("Content-Length") + gz := gzip.NewWriter(w) + defer gz.Close() + gzw := &gzipResponseWriter{ResponseWriter: w, gw: gz} + http.ServeFile(gzw, r, path) + return + } + http.ServeFile(w, r, path) } \ No newline at end of file diff --git a/app/web/static/index.html b/app/web/static/index.html index 2829463..e57cd76 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -1,171 +1,263 @@ - - FreeMoto Navigation - - - - - - - -
-