A Go service that scans existing basemap tile directories and vector files, catalogues them in SQLite, issues scoped access tokens, and serves tiles and downloads to downstream systems. It deliberately does not generate or process raster data: GIS software produces the tiles, this service catalogues, authorises and distributes them. Capabilities - scans basemap tile directories (manifest-based or raw z/x/y) and vector assets (.geojson, .json, .zip, shapefiles with sidecars) - token auth with per-resource grants, expiry and coarse scopes (read, slice) - resource health inspection flags a moved or deleted tile root before a client hits a 404 - downstream push with exponential-backoff retries and manual re-queue - image slicing jobs driven by the GDAL bundled with QGIS, including VRT mosaicking so successive drone flights accumulate into one seamless layer - a narrow, non-admin slicing API for external callers: submit, poll, idempotent by job code, completion callback - ops console covering health, ledger, tokens, pushes, scans, slicing and service info - native Windows service install/start/stop with crash recovery, auto-start and a rotating log file, so no NSSM or WinSW wrapper is needed Verified with 49 tests plus live runs against real tile data and real GDAL.
190 lines
5.3 KiB
Go
190 lines
5.3 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
Host string
|
|
Port int
|
|
APIBaseURL string
|
|
TilePublicBaseURL string
|
|
VectorPublicBaseURL string
|
|
SQLitePath string
|
|
BasemapScanRoot string
|
|
VectorScanRoot string
|
|
AutoScanOnStart bool
|
|
Env string
|
|
ServiceName string
|
|
ServiceVersion string
|
|
Commit string
|
|
|
|
AdminToken string
|
|
AdminUser string
|
|
AdminPassword string
|
|
CORSAllowedOrigins []string
|
|
TokenDefaultTTL time.Duration
|
|
|
|
HealthCheckInterval time.Duration
|
|
TileJobCommand string
|
|
TileJobVRTCommand string
|
|
TileJobLogDir string
|
|
}
|
|
|
|
func Load() (Config, error) {
|
|
if err := LoadEnvDefaults(); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
port, err := intEnv("API_PORT", 8910)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("parse API_PORT: %w", err)
|
|
}
|
|
autoScanOnStart, err := boolEnv("AUTO_SCAN_ON_START", true)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("parse AUTO_SCAN_ON_START: %w", err)
|
|
}
|
|
tokenDefaultTTL, err := durationEnv("TOKEN_DEFAULT_TTL", 720*time.Hour)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("parse TOKEN_DEFAULT_TTL: %w", err)
|
|
}
|
|
healthCheckInterval, err := durationEnv("HEALTH_CHECK_INTERVAL", 5*time.Minute)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("parse HEALTH_CHECK_INTERVAL: %w", err)
|
|
}
|
|
|
|
cfg := Config{
|
|
Host: stringEnv("API_HOST", "127.0.0.1"),
|
|
Port: port,
|
|
APIBaseURL: stringEnv("API_BASE_URL", ""),
|
|
TilePublicBaseURL: stringEnv("TILE_PUBLIC_BASE_URL", ""),
|
|
VectorPublicBaseURL: stringEnv("VECTOR_PUBLIC_BASE_URL", ""),
|
|
SQLitePath: stringEnv("SQLITE_PATH", filepath.Clean(filepath.Join("data", "basemap-pusher.db"))),
|
|
BasemapScanRoot: stringEnv("BASEMAP_SCAN_ROOT", filepath.Clean(filepath.Join("data", "tiles"))),
|
|
VectorScanRoot: stringEnv("VECTOR_SCAN_ROOT", filepath.Clean(filepath.Join("data", "geojson"))),
|
|
AutoScanOnStart: autoScanOnStart,
|
|
Env: stringEnv("APP_ENV", "dev"),
|
|
ServiceName: stringEnv("SERVICE_NAME", "basemap-pusher"),
|
|
ServiceVersion: stringEnv("SERVICE_VERSION", "0.2.0"),
|
|
Commit: stringEnv("SERVICE_COMMIT", "local"),
|
|
AdminToken: strings.TrimSpace(stringEnv("ADMIN_TOKEN", "")),
|
|
AdminUser: strings.TrimSpace(stringEnv("ADMIN_USER", "")),
|
|
AdminPassword: stringEnv("ADMIN_PASSWORD", ""),
|
|
CORSAllowedOrigins: listEnv("CORS_ALLOWED_ORIGINS"),
|
|
TokenDefaultTTL: tokenDefaultTTL,
|
|
HealthCheckInterval: healthCheckInterval,
|
|
TileJobCommand: stringEnv("TILE_JOB_COMMAND", ""),
|
|
TileJobVRTCommand: stringEnv("TILE_JOB_VRT_COMMAND", ""),
|
|
TileJobLogDir: stringEnv("TILE_JOB_LOG_DIR", ""),
|
|
}
|
|
if cfg.APIBaseURL == "" {
|
|
cfg.APIBaseURL = deriveBaseURL(cfg.Host, cfg.Port)
|
|
}
|
|
if cfg.TilePublicBaseURL == "" {
|
|
cfg.TilePublicBaseURL = cfg.APIBaseURL
|
|
}
|
|
if cfg.VectorPublicBaseURL == "" {
|
|
cfg.VectorPublicBaseURL = cfg.APIBaseURL
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c Config) ListenAddr() string {
|
|
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
|
}
|
|
|
|
// HasAdminCredentials reports whether the admin console has any credential
|
|
// configured. Without one, the admin surface is only safe on loopback.
|
|
func (c Config) HasAdminCredentials() bool {
|
|
return c.AdminToken != "" || (c.AdminUser != "" && c.AdminPassword != "")
|
|
}
|
|
|
|
// BindsToNonLoopback reports whether the listen address is reachable from
|
|
// outside the host, which makes credentials mandatory.
|
|
func (c Config) BindsToNonLoopback() bool {
|
|
return !isLoopbackHost(c.Host)
|
|
}
|
|
|
|
func deriveBaseURL(host string, port int) string {
|
|
if host == "" || host == "0.0.0.0" || host == "::" {
|
|
host = "127.0.0.1"
|
|
}
|
|
return fmt.Sprintf("http://%s:%d", host, port)
|
|
}
|
|
|
|
func isLoopbackHost(host string) bool {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" || strings.EqualFold(host, "localhost") {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(strings.Trim(host, "[]"))
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
return ip.IsLoopback()
|
|
}
|
|
|
|
func stringEnv(key, fallback string) string {
|
|
value, ok := os.LookupEnv(key)
|
|
if ok && value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func intEnv(key string, fallback int) (int, error) {
|
|
value, ok := os.LookupEnv(key)
|
|
if ok && value != "" {
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return parsed, nil
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
func boolEnv(key string, fallback bool) (bool, error) {
|
|
value, ok := os.LookupEnv(key)
|
|
if ok && value != "" {
|
|
parsed, err := strconv.ParseBool(value)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return parsed, nil
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
func durationEnv(key string, fallback time.Duration) (time.Duration, error) {
|
|
value, ok := os.LookupEnv(key)
|
|
if ok && strings.TrimSpace(value) != "" {
|
|
parsed, err := time.ParseDuration(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return parsed, nil
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
func listEnv(key string) []string {
|
|
raw := strings.FieldsFunc(stringEnv(key, ""), func(r rune) bool {
|
|
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
|
|
})
|
|
values := make([]string, 0, len(raw))
|
|
for _, item := range raw {
|
|
item = strings.TrimSpace(item)
|
|
if item != "" {
|
|
values = append(values, item)
|
|
}
|
|
}
|
|
return values
|
|
}
|