Updated backend/frontend

This commit is contained in:
2025-09-17 10:39:49 +00:00
parent 58f701c1eb
commit 8ae9f1afdf
17 changed files with 676 additions and 383 deletions

View File

@@ -0,0 +1,4 @@
{
"name": "go-devcontainer",
"image": "mcr.microsoft.com/devcontainers/go:1-1.24-bookworm"
}

37
.dockerignore Normal file
View File

@@ -0,0 +1,37 @@
# Ignore VCS and IDE files
.git/
.gitea/
.devcontainer/
.vscode/
.idea/
# OS/editor junk
**/.DS_Store
**/*.swp
**/*.swo
**/*.tmp
# Environment and secrets
.env
*.env
# Logs and coverage
*.log
logs/
coverage/
# Build outputs
build/
dist/
out/
bin/
# Node (if any subprojects)
node_modules/
# Python caches (if any scripts)
**/__pycache__/
**/*.pyc
# Go vendor (if used)
vendor/

View File

@@ -1,11 +1,43 @@
FROM golang:1.24-alpine ## Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /src
# Install build dependencies (none needed for static build) and enable static build
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
# Cache go modules
COPY app/web/go.mod ./app/web/
RUN cd app/web && go mod download
# Copy source
COPY app/web/ ./app/web/
# Build
RUN cd app/web && go build -o /out/freemoto-web ./
## Runtime stage
FROM alpine:3.20
WORKDIR /app/web WORKDIR /app/web
COPY app/web/ . # Add CA certificates for any outbound HTTPS (future-proofing)
RUN apk add --no-cache ca-certificates tzdata wget
RUN go mod tidy # Copy binary and static files
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
USER appuser
ENV PORT=8080
EXPOSE 8080 EXPOSE 8080
CMD ["go", "run", "main.go"] # Simple healthcheck against /healthz
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:${PORT}/healthz || exit 1
ENTRYPOINT ["/app/web/freemoto-web"]

View File

@@ -24,6 +24,13 @@ Create a `.env` file in the project root:
``` ```
VALHALLA_URL=http://valhalla:8002/route VALHALLA_URL=http://valhalla:8002/route
PORT=8080 PORT=8080
# Optional: Nominatim base URL (defaults to https://nominatim.openstreetmap.org)
NOMINATIM_URL=https://nominatim.openstreetmap.org
# Recommended: configure a descriptive User-Agent per Nominatim usage policy
# Example format: "AppName/Version (+contact-url-or-email)"
NOMINATIM_USER_AGENT=FreeMoto/1.0 (+https://fm.ztsw.de/)
# Optional: log level (debug, info, warn, error). Default: info
LOG_LEVEL=info
``` ```
### Local Development ### Local Development
@@ -59,6 +66,9 @@ services:
#environment: #environment:
# - VALHALLA_URL=http://10.200.0.15:8002/route # - VALHALLA_URL=http://10.200.0.15:8002/route
# - PORT=8080 # - PORT=8080
# - NOMINATIM_URL=https://nominatim.openstreetmap.org
# - NOMINATIM_USER_AGENT=FreeMoto/1.0 (+https://fm.ztsw.de/)
# - LOG_LEVEL=debug
valhalla-scripted: valhalla-scripted:
image: ghcr.io/valhalla/valhalla-scripted:latest image: ghcr.io/valhalla/valhalla-scripted:latest
ports: ports:
@@ -69,6 +79,17 @@ services:
- tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf - tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf
``` ```
### 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/)`.
- You can point `NOMINATIM_URL` to your own Nominatim instance or keep the default public endpoint.
### Logging
- Configure verbosity with `LOG_LEVEL`.
- 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 ## Customization
- **Map UI:** Edit `static/index.html` and `static/main.js` - **Map UI:** Edit `static/index.html` and `static/main.js`

View File

@@ -1,53 +1,249 @@
package main package main
import ( import (
"context"
"fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"sync/atomic"
"time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
type ctxKey string
const ctxReqID ctxKey = "reqID"
// atomic counter for simple request IDs
var reqIDCounter uint64
// log levels
const (
levelDebug = iota
levelInfo
levelWarn
levelError
)
var currentLogLevel = levelInfo
func parseLogLevel(s string) int {
switch strings.ToLower(s) {
case "debug":
return levelDebug
case "info", "":
return levelInfo
case "warn", "warning":
return levelWarn
case "error", "err":
return levelError
default:
return levelInfo
}
}
func logf(level int, format string, args ...any) {
if level < currentLogLevel {
return
}
prefix := "INFO"
switch level {
case levelDebug:
prefix = "DEBUG"
case levelInfo:
prefix = "INFO"
case levelWarn:
prefix = "WARN"
case levelError:
prefix = "ERROR"
}
// Prepend the prefix as the first argument
log.Printf("[%s] "+format, append([]any{prefix}, args...)...)
}
func main() { func main() {
_ = godotenv.Load(".env") // Load .env file _ = godotenv.Load(".env") // Load .env file
valhallaURL := os.Getenv("VALHALLA_URL") valhallaURL := os.Getenv("VALHALLA_URL")
if valhallaURL == "" { if valhallaURL == "" {
valhallaURL = "http://10.200.0.15:8002/route" valhallaURL = "http://valhalla:8002/route"
} }
nominatimBase := os.Getenv("NOMINATIM_URL")
if nominatimBase == "" {
nominatimBase = "https://nominatim.openstreetmap.org"
}
nominatimUA := os.Getenv("NOMINATIM_USER_AGENT")
if nominatimUA == "" {
nominatimUA = "FreeMoto/1.0 (+https://fm.ztsw.de/)"
}
currentLogLevel = parseLogLevel(os.Getenv("LOG_LEVEL"))
port := os.Getenv("PORT") port := os.Getenv("PORT")
if port == "" { if port == "" {
port = "8080" port = "8080"
} }
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) http.Handle("/static/", withLogging(http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))))
http.HandleFunc("/route", func(w http.ResponseWriter, r *http.Request) { http.Handle("/healthz", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})))
http.Handle("/route", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyToValhalla(w, r, valhallaURL) proxyToValhalla(w, r, valhallaURL)
}) })))
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // Nominatim proxy endpoints
http.Handle("/geocode", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyToNominatimGET(w, r, nominatimBase+"/search", nominatimUA)
})))
http.Handle("/reverse", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyToNominatimGET(w, r, nominatimBase+"/reverse", nominatimUA)
})))
http.Handle("/", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" { if r.URL.Path == "/" {
http.ServeFile(w, r, "./static/index.html") http.ServeFile(w, r, "./static/index.html")
return return
} }
http.ServeFile(w, r, "./static/"+r.URL.Path[1:]) http.ServeFile(w, r, "./static/"+r.URL.Path[1:])
}) })))
log.Printf("Listening on :%s", port) logf(levelInfo, "Listening on :%s", port)
http.ListenAndServe(":"+port, nil) http.ListenAndServe(":"+port, nil)
} }
func proxyToValhalla(w http.ResponseWriter, r *http.Request, valhallaURL string) { func proxyToValhalla(w http.ResponseWriter, r *http.Request, valhallaURL string) {
req, _ := http.NewRequest("POST", valhallaURL, r.Body) // Create outbound request with caller context and preserve headers
req.Header = r.Header req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, valhallaURL, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Header = r.Header.Clone()
client := http.Client{} client := http.Client{Timeout: 15 * time.Second}
start := time.Now()
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
logf(levelError, "rid=%s upstream=valhalla error=%v", rid, err)
} else {
logf(levelError, "upstream=valhalla error=%v", err)
}
http.Error(w, err.Error(), http.StatusBadGateway) http.Error(w, err.Error(), http.StatusBadGateway)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) // Propagate relevant headers and status code
io.Copy(w, resp.Body) for key, values := range resp.Header {
// Optionally filter headers; here we forward common ones
if key == "Content-Type" || key == "Content-Encoding" || key == "Cache-Control" || key == "Vary" {
for _, v := range values {
w.Header().Add(key, v)
}
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
logf(levelDebug, "rid=%s upstream=valhalla status=%d dur=%s", rid, resp.StatusCode, time.Since(start))
} else {
logf(levelDebug, "upstream=valhalla status=%d dur=%s", resp.StatusCode, time.Since(start))
}
}
// proxyToNominatimGET proxies GET requests to Nominatim endpoints (search/reverse),
// sets a descriptive User-Agent, and enforces a timeout.
func proxyToNominatimGET(w http.ResponseWriter, r *http.Request, target string, userAgent string) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Preserve original query parameters
url := target
if rq := r.URL.RawQuery; rq != "" {
url = url + "?" + rq
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Set a proper User-Agent per Nominatim usage policy
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json")
client := http.Client{Timeout: 10 * time.Second}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
logf(levelError, "rid=%s upstream=nominatim url=%s error=%v", rid, url, err)
} else {
logf(levelError, "upstream=nominatim url=%s error=%v", url, err)
}
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Forward selected headers and status code
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
logf(levelDebug, "rid=%s upstream=nominatim status=%d dur=%s", rid, resp.StatusCode, time.Since(start))
} else {
logf(levelDebug, "upstream=nominatim status=%d dur=%s", resp.StatusCode, time.Since(start))
}
}
// withLogging wraps handlers to add request ID, status, duration logging
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := fmt.Sprintf("%x", atomic.AddUint64(&reqIDCounter, 1))
ctx := context.WithValue(r.Context(), ctxReqID, rid)
r = r.WithContext(ctx)
lrw := &loggingResponseWriter{ResponseWriter: w, status: 200}
start := time.Now()
next.ServeHTTP(lrw, r)
dur := time.Since(start)
logf(levelInfo, "rid=%s method=%s path=%s status=%d bytes=%d dur=%s ua=\"%s\" ip=%s",
rid, r.Method, r.URL.Path, lrw.status, lrw.bytes, dur, r.UserAgent(), clientIP(r))
})
}
type loggingResponseWriter struct {
http.ResponseWriter
status int
bytes int
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.status = code
lrw.ResponseWriter.WriteHeader(code)
}
func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
n, err := lrw.ResponseWriter.Write(b)
lrw.bytes += n
return n, err
}
func clientIP(r *http.Request) string {
// honor X-Forwarded-For if present (first IP)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return xff
}
return r.RemoteAddr
} }

View File

@@ -1,6 +1,6 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
function geocode(query, callback) { function geocode(query, callback) {
fetch('https://nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(query)) fetch('/geocode?format=json&q=' + encodeURIComponent(query))
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data && data.length > 0) { if (data && data.length > 0) {
@@ -16,7 +16,7 @@ document.addEventListener('DOMContentLoaded', function() {
navigator.geolocation.getCurrentPosition(function(position) { navigator.geolocation.getCurrentPosition(function(position) {
var lat = position.coords.latitude; var lat = position.coords.latitude;
var lon = position.coords.longitude; var lon = position.coords.longitude;
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}`) fetch(`/reverse?format=json&lat=${lat}&lon=${lon}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
document.getElementById(inputId).value = data.display_name || `${lat},${lon}`; document.getElementById(inputId).value = data.display_name || `${lat},${lon}`;
@@ -54,7 +54,7 @@ document.addEventListener('DOMContentLoaded', function() {
suggestionsBox.style.display = 'none'; suggestionsBox.style.display = 'none';
return; return;
} }
fetch('https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&q=' + encodeURIComponent(value)) fetch('/geocode?format=json&addressdetails=1&q=' + encodeURIComponent(value))
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
suggestionsBox.innerHTML = ''; suggestionsBox.innerHTML = '';

View File

@@ -10,26 +10,25 @@ document.addEventListener('DOMContentLoaded', function() {
function calculateRoute() { function calculateRoute() {
if (points.length === 2) { if (points.length === 2) {
var options = { var moto = {};
"exclude_restrictions": true // Avoid highways -> lower highway usage weight
};
if (avoidHighwaysCheckbox && avoidHighwaysCheckbox.checked) { if (avoidHighwaysCheckbox && avoidHighwaysCheckbox.checked) {
options.use_highways = 0; moto.use_highways = 0.0; // 0..1 (0 avoids, 1 prefers)
}
if (useShortestCheckbox && useShortestCheckbox.checked) {
options.use_shortest = true;
}
if (avoidTollRoadsCheckbox && avoidTollRoadsCheckbox.checked) {
options.avoid_toll = true;
} }
// Avoid ferries -> lower ferry usage weight
if (avoidFerriesCheckbox && avoidFerriesCheckbox.checked) { if (avoidFerriesCheckbox && avoidFerriesCheckbox.checked) {
options.avoid_ferry = true; moto.use_ferry = 0.0; // 0..1
} }
// Avoid unpaved -> exclude unpaved roads entirely
if (avoidUnpavedCheckbox && avoidUnpavedCheckbox.checked) { if (avoidUnpavedCheckbox && avoidUnpavedCheckbox.checked) {
options.avoid_unpaved = true; moto.exclude_unpaved = true;
}
// Avoid tolls -> exclude tolls
if (avoidTollRoadsCheckbox && avoidTollRoadsCheckbox.checked) {
moto.exclude_tolls = true;
} }
var costing_options = { motorcycle: options }; var costing_options = { motorcycle: moto };
var requestBody = { var requestBody = {
locations: [ locations: [
@@ -37,8 +36,12 @@ document.addEventListener('DOMContentLoaded', function() {
{ lat: points[1].lat, lon: points[1].lng } { lat: points[1].lat, lon: points[1].lng }
], ],
costing: "motorcycle", costing: "motorcycle",
costing_options: costing_options costing_options: costing_options,
units: "kilometers"
}; };
if (useShortestCheckbox && useShortestCheckbox.checked) {
requestBody.shortest = true; // top-level shortest flag
}
fetch('/route', { fetch('/route', {
method: 'POST', method: 'POST',
@@ -50,7 +53,7 @@ document.addEventListener('DOMContentLoaded', function() {
var leg = data.trip && data.trip.legs && data.trip.legs[0]; var leg = data.trip && data.trip.legs && data.trip.legs[0];
var infoCard = document.getElementById('routeInfoCard'); var infoCard = document.getElementById('routeInfoCard');
if (leg && leg.summary && typeof leg.summary.length === 'number' && typeof leg.summary.time === 'number') { if (leg && leg.summary && typeof leg.summary.length === 'number' && typeof leg.summary.time === 'number') {
var distanceKm = (leg.summary.length / 1000).toFixed(1); // meters to km var distanceKm = (leg.summary.length).toFixed(1); // already in km
var durationMin = Math.round(leg.summary.time / 60); // seconds to minutes var durationMin = Math.round(leg.summary.time / 60); // seconds to minutes
var info = `<strong>Distance:</strong> ${distanceKm} km<br> var info = `<strong>Distance:</strong> ${distanceKm} km<br>
<strong>Estimated Time:</strong> ${durationMin} min<br> <strong>Estimated Time:</strong> ${durationMin} min<br>
@@ -201,7 +204,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
function reverseGeocode(lat, lon, inputId) { function reverseGeocode(lat, lon, inputId) {
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}`) fetch(`/reverse?format=json&lat=${lat}&lon=${lon}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
var input = document.getElementById(inputId); var input = document.getElementById(inputId);

BIN
bin/freemoto-web Normal file

Binary file not shown.