Rewrite the gateway around health, retries, slicing and a Windows service

Scanner
- manifest lookup is now depth-bounded and prunes numeric zoom directories, so
  a scan no longer walks the whole tile payload
- rescans only report a version as updated when something actually changed
- overlapping runs are refused instead of interleaving their bookkeeping

Hot path
- token authorisation loads only that token grants and throttles the
  last_used_at write instead of touching it on every tile
- SQLite runs in WAL with a busy timeout and a real connection pool
- the catalog lookup used by /tiles no longer loads every basemap

Security
- the admin surface requires ADMIN_TOKEN (bearer) or ADMIN_USER/ADMIN_PASSWORD
  (basic), and the service refuses to bind off-host without credentials
- CORS is an explicit allow-list; newly minted tokens never reach a redirect URL
- client tokens are redacted from the access log

Operations
- resource health inspection flags a moved or deleted tile root before a 404
- pushes retry with backoff and can be re-queued by hand; the schedule lives in
  the database so a restart does not lose it
- access log plus /metrics counters

Image slicing
- tile_jobs drive the GDAL bundled with QGIS via scripts/gdal2tiles-qgis.bat
- layer_sources remembers what a layer is made of: submit one source to append
  a flight, or all of them to rebuild the layer as a VRT mosaic with no tile
  seams; the console exposes both, including a one-click rebuild
- slicing is available to integrations through a narrow slice token scope
  (submit, poll, idempotent by code, completion callback)

Console
- rebuilt around a fixed rail and single-view navigation with health and
  outstanding work leading, instead of one long scrolling page

Windows service
- native install/start/stop/status with crash recovery, auto-start and a
  rotating log file, so no NSSM wrapper is needed
- start-all.bat and stop-all.bat are removed: they killed the process by port
  with taskkill, which fought the service restart policy

Verified with 49 tests and live runs against real tile data and real GDAL.
This commit is contained in:
2026-10-16 10:35:30 +08:00
parent 8faf25b436
commit 73ad03fa59
49 changed files with 8668 additions and 1817 deletions
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@ import (
"path/filepath"
"testing"
"map-asset-gateway/api-go/internal/basemap"
"tile-server/api-go/internal/basemap"
)
func TestCompatibilityRoutes(t *testing.T) {
@@ -65,7 +65,7 @@ func TestCompatibilityRoutes(t *testing.T) {
}
server, err := New(store, basemap.SystemInfo{
ServiceName: "map-asset-gateway",
ServiceName: "basemap-pusher",
ServiceVersion: "test",
APIBaseURL: "http://127.0.0.1:8910",
TileBaseURL: "http://127.0.0.1:8910",
@@ -73,7 +73,7 @@ func TestCompatibilityRoutes(t *testing.T) {
SQLitePath: sqlitePath,
ScanRoot: tileRoot,
VectorScanRoot: vectorRoot,
})
}, Options{})
if err != nil {
t.Fatalf("new server: %v", err)
}
@@ -0,0 +1,279 @@
package basemapapi
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"tile-server/api-go/internal/basemap"
)
func newTestStore(t *testing.T, root string) *basemap.Store {
t.Helper()
tileRoot := filepath.Join(root, "tiles")
writeFile(t, filepath.Join(tileRoot, "google_image", "0", "0", "0.webp"), "tile")
vectorRoot := filepath.Join(root, "vectors")
store, err := basemap.OpenStore(basemap.StoreConfig{
SQLitePath: filepath.Join(root, "basemap.db"),
ScanRoot: tileRoot,
VectorScanRoot: vectorRoot,
APIBaseURL: "http://127.0.0.1:8910",
TileBaseURL: "http://127.0.0.1:8910",
VectorBaseURL: "http://127.0.0.1:8910",
})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
if _, err := store.RunScan(context.Background(), "local-basemaps"); err != nil {
t.Fatalf("run scan: %v", err)
}
return store
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
}
func newTestServer(t *testing.T, store *basemap.Store, options Options) *httptest.Server {
t.Helper()
server, err := New(store, basemap.SystemInfo{ServiceName: "basemap-pusher"}, options)
if err != nil {
t.Fatalf("new server: %v", err)
}
httpServer := httptest.NewServer(server.Handler())
t.Cleanup(httpServer.Close)
return httpServer
}
func basicAuthHeader(user, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+password))
}
func TestAdminRequiresBasicAuth(t *testing.T) {
store := newTestStore(t, t.TempDir())
httpServer := newTestServer(t, store, Options{Admin: AdminAuth{User: "ops", Password: "s3cret"}})
for _, path := range []string{"/api/v1/admin/basemaps", "/admin/"} {
response, err := http.Get(httpServer.URL + path)
if err != nil {
t.Fatalf("unauthorized GET %s: %v", path, err)
}
response.Body.Close()
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 for anonymous %s, got %d", path, response.StatusCode)
}
request, err := http.NewRequest(http.MethodGet, httpServer.URL+path, nil)
if err != nil {
t.Fatalf("build request: %v", err)
}
request.Header.Set("Authorization", basicAuthHeader("ops", "s3cret"))
response, err = http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("authorized GET %s: %v", path, err)
}
response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for authorized %s, got %d", path, response.StatusCode)
}
}
}
func TestAdminRejectsWrongBasicAuth(t *testing.T) {
store := newTestStore(t, t.TempDir())
httpServer := newTestServer(t, store, Options{Admin: AdminAuth{User: "ops", Password: "s3cret"}})
request, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/api/v1/admin/basemaps", nil)
request.Header.Set("Authorization", basicAuthHeader("ops", "wrong"))
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", response.StatusCode)
}
}
func TestAdminAcceptsBearerToken(t *testing.T) {
store := newTestStore(t, t.TempDir())
httpServer := newTestServer(t, store, Options{Admin: AdminAuth{Token: "admin-token"}})
request, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/api/v1/admin/basemaps", nil)
request.Header.Set("Authorization", "Bearer admin-token")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("request: %v", err)
}
response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with bearer token, got %d", response.StatusCode)
}
request, _ = http.NewRequest(http.MethodGet, httpServer.URL+"/api/v1/admin/basemaps", nil)
request.Header.Set("Authorization", "Bearer nope")
response, err = http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("request: %v", err)
}
response.Body.Close()
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 with a wrong bearer token, got %d", response.StatusCode)
}
}
func TestAdminOpenWhenUnconfigured(t *testing.T) {
store := newTestStore(t, t.TempDir())
httpServer := newTestServer(t, store, Options{})
response, err := http.Get(httpServer.URL + "/api/v1/admin/basemaps")
if err != nil {
t.Fatalf("request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("expected the admin API to be open without credentials, got %d", response.StatusCode)
}
}
func TestCatalogStillRequiresTokenEvenWithAdminAuth(t *testing.T) {
store := newTestStore(t, t.TempDir())
httpServer := newTestServer(t, store, Options{Admin: AdminAuth{User: "ops", Password: "s3cret"}})
response, err := http.Get(httpServer.URL + "/api/v1/catalog/basemaps")
if err != nil {
t.Fatalf("request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected the catalog to still require a service token, got %d", response.StatusCode)
}
}
func TestCORSAllowlist(t *testing.T) {
store := newTestStore(t, t.TempDir())
httpServer := newTestServer(t, store, Options{CORSAllowedOrigins: []string{"https://maps.example.com"}})
allowed, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/health", nil)
allowed.Header.Set("Origin", "https://maps.example.com")
response, err := http.DefaultClient.Do(allowed)
if err != nil {
t.Fatalf("allowed origin: %v", err)
}
response.Body.Close()
if got := response.Header.Get("Access-Control-Allow-Origin"); got != "https://maps.example.com" {
t.Fatalf("expected the allowlisted origin to be reflected, got %q", got)
}
blocked, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/health", nil)
blocked.Header.Set("Origin", "https://evil.example.com")
response, err = http.DefaultClient.Do(blocked)
if err != nil {
t.Fatalf("blocked origin: %v", err)
}
response.Body.Close()
if got := response.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("expected no CORS header for an unlisted origin, got %q", got)
}
}
func TestCreateTokenFormKeepsTokenOutOfURL(t *testing.T) {
store := newTestStore(t, t.TempDir())
httpServer := newTestServer(t, store, Options{})
form := url.Values{}
form.Set("name", "console")
form.Set("basemap_codes", "google_image")
request, err := http.NewRequest(http.MethodPost, httpServer.URL+"/admin/tokens", strings.NewReader(form.Encode()))
if err != nil {
t.Fatalf("build request: %v", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
response, err := client.Do(request)
if err != nil {
t.Fatalf("post form: %v", err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if location := response.Header.Get("Location"); strings.Contains(location, "bmp_") {
t.Fatalf("token leaked into the redirect URL: %s", location)
}
if !strings.Contains(string(body), "bmp_") {
t.Fatalf("expected the new token to be rendered in the response body")
}
}
func TestMetricsEndpointCountsTileMisses(t *testing.T) {
store := newTestStore(t, t.TempDir())
httpServer := newTestServer(t, store, Options{})
created, err := store.CreateToken(context.Background(), basemap.CreateTokenInput{
Name: "metrics",
BasemapCodes: []string{"google_image"},
})
if err != nil {
t.Fatalf("create token: %v", err)
}
response, err := http.Get(httpServer.URL + "/tiles/google_image/current/9/9/9.png?token=" + created.Token)
if err != nil {
t.Fatalf("tile request: %v", err)
}
response.Body.Close()
if response.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 for a missing tile, got %d", response.StatusCode)
}
response, err = http.Get(httpServer.URL + "/metrics")
if err != nil {
t.Fatalf("metrics request: %v", err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
text := string(body)
for _, expected := range []string{
"basemap_pusher_requests_total",
"basemap_pusher_tile_requests_total 1",
"basemap_pusher_tile_misses_total 1",
} {
if !strings.Contains(text, expected) {
t.Fatalf("expected /metrics to contain %q, got:\n%s", expected, text)
}
}
}
func TestScanErrorStatusMapping(t *testing.T) {
cases := []struct {
err error
want int
}{
{basemap.ErrScanInProgress, http.StatusConflict},
{basemap.ErrScanSourceNotFound, http.StatusNotFound},
{basemap.ErrScanSourceDisabled, http.StatusBadRequest},
{io.ErrUnexpectedEOF, http.StatusInternalServerError},
}
for _, tc := range cases {
if got := scanErrorStatus(tc.err); got != tc.want {
t.Fatalf("scanErrorStatus(%v) = %d, want %d", tc.err, got, tc.want)
}
}
}
+545 -83
View File
@@ -1,29 +1,63 @@
package basemapapi
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync/atomic"
"time"
"map-asset-gateway/api-go/internal/basemap"
"tile-server/api-go/internal/basemap"
)
type Server struct {
store *basemap.Store
system basemap.SystemInfo
startedAt time.Time
template *template.Template
mux *http.ServeMux
// AdminAuth protects the admin console and admin API. When neither credential
// is configured the admin surface is only safe on loopback, which main enforces.
type AdminAuth struct {
Token string
User string
Password string
}
func New(store *basemap.Store, system basemap.SystemInfo) (*Server, error) {
func (a AdminAuth) enabled() bool {
return strings.TrimSpace(a.Token) != "" || (strings.TrimSpace(a.User) != "" && a.Password != "")
}
// Options carries wiring that must not be exposed through the public system
// info payload (credentials, CORS policy).
type Options struct {
Admin AdminAuth
CORSAllowedOrigins []string
}
// metrics holds cheap in-process counters for the /metrics endpoint.
type metrics struct {
requests atomic.Int64
status2xx atomic.Int64
status4xx atomic.Int64
status5xx atomic.Int64
tiles atomic.Int64
tileMisses atomic.Int64
authFailures atomic.Int64
}
type Server struct {
store *basemap.Store
system basemap.SystemInfo
startedAt time.Time
template *template.Template
mux *http.ServeMux
admin AdminAuth
corsOrigins []string
metrics *metrics
}
func New(store *basemap.Store, system basemap.SystemInfo, options Options) (*Server, error) {
tmpl, err := template.New("admin").Funcs(template.FuncMap{
"fmtTime": func(value time.Time) string {
if value.IsZero() {
@@ -106,18 +140,143 @@ func New(store *basemap.Store, system basemap.SystemInfo) (*Server, error) {
}
server := &Server{
store: store,
system: system,
startedAt: time.Now().UTC(),
template: tmpl,
mux: http.NewServeMux(),
store: store,
system: system,
startedAt: time.Now().UTC(),
template: tmpl,
mux: http.NewServeMux(),
admin: options.Admin,
corsOrigins: normalizeOrigins(options.CORSAllowedOrigins),
metrics: &metrics{},
}
server.routes()
return server, nil
}
func (s *Server) Handler() http.Handler {
return s.mux
return withAccessLog(withCORS(s.mux, s.corsOrigins), s.metrics)
}
func normalizeOrigins(values []string) []string {
origins := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
origins = append(origins, value)
}
}
return origins
}
// withCORS only reflects origins that were explicitly allowed. An empty
// allow-list means no cross-origin access at all, rather than echoing any site.
func withCORS(next http.Handler, allowed []string) http.Handler {
allowAll := false
allowedSet := map[string]struct{}{}
for _, origin := range allowed {
if origin == "*" {
allowAll = true
}
allowedSet[strings.ToLower(origin)] = struct{}{}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin != "" {
_, ok := allowedSet[strings.ToLower(origin)]
if ok || allowAll {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Access-Token")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
}
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(code int) {
if r.status == 0 {
r.status = code
}
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(p []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
n, err := r.ResponseWriter.Write(p)
r.bytes += n
return n, err
}
// withAccessLog records one line per request and feeds /metrics. Credentials
// carried in the query string are redacted before logging.
func withAccessLog(next http.Handler, stats *metrics) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
recorder := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(recorder, r)
if recorder.status == 0 {
recorder.status = http.StatusOK
}
stats.requests.Add(1)
switch {
case recorder.status >= 500:
stats.status5xx.Add(1)
case recorder.status >= 400:
stats.status4xx.Add(1)
case recorder.status >= 200:
stats.status2xx.Add(1)
}
if strings.HasPrefix(r.URL.Path, "/tiles/") {
stats.tiles.Add(1)
if recorder.status == http.StatusNotFound {
stats.tileMisses.Add(1)
}
}
if recorder.status == http.StatusUnauthorized {
stats.authFailures.Add(1)
}
if r.URL.Path == "/health" || r.URL.Path == "/ready" || r.URL.Path == "/metrics" {
return
}
log.Printf("%s %s %d %dB %s", r.Method, redactRequestURI(r), recorder.status, recorder.bytes, time.Since(startedAt).Round(time.Millisecond))
})
}
func redactRequestURI(r *http.Request) string {
if r.URL.RawQuery == "" {
return r.URL.Path
}
redacted := url.Values{}
for key, values := range r.URL.Query() {
switch strings.ToLower(key) {
case "token", "access_token":
redacted.Set(key, "***")
default:
for _, value := range values {
redacted.Add(key, value)
}
}
}
if encoded := redacted.Encode(); encoded != "" {
return r.URL.Path + "?" + encoded
}
return r.URL.Path
}
func (s *Server) routes() {
@@ -126,25 +285,38 @@ func (s *Server) routes() {
})
s.mux.HandleFunc("GET /health", s.handleHealth)
s.mux.HandleFunc("GET /ready", s.handleReady)
s.mux.HandleFunc("GET /metrics", s.handleMetrics)
s.mux.HandleFunc("GET /api/v1/system", s.handleSystem)
s.mux.HandleFunc("GET /api/v1/admin/dashboard", s.handleDashboardJSON)
s.mux.HandleFunc("GET /api/v1/admin/basemaps", s.handleBasemapsJSON)
s.mux.HandleFunc("GET /api/v1/admin/vectors", s.handleVectorsJSON)
s.mux.HandleFunc("GET /api/v1/admin/scan-sources", s.handleScanSourcesJSON)
s.mux.HandleFunc("POST /api/v1/admin/scan-sources", s.handleCreateScanSourceJSON)
s.mux.HandleFunc("POST /api/v1/admin/scan-sources/{code}/run", s.handleRunScanJSON)
s.mux.HandleFunc("POST /api/v1/admin/vectors/rescan", s.handleRunVectorScanJSON)
s.mux.HandleFunc("POST /api/v1/admin/basemaps/{code}/default-version", s.handleSetDefaultVersionJSON)
s.mux.HandleFunc("GET /api/v1/admin/targets", s.handleTargetsJSON)
s.mux.HandleFunc("POST /api/v1/admin/targets", s.handleCreateTargetJSON)
s.mux.HandleFunc("GET /api/v1/admin/tokens", s.handleTokensJSON)
s.mux.HandleFunc("POST /api/v1/admin/tokens", s.handleCreateTokenJSON)
s.mux.HandleFunc("POST /api/v1/admin/tokens/{id}/disable", s.handleDisableTokenJSON)
s.mux.HandleFunc("GET /api/v1/admin/push-records", s.handlePushRecordsJSON)
s.mux.HandleFunc("POST /api/v1/admin/pushes", s.handlePushJSON)
s.mux.HandleFunc("GET /api/v1/admin/vector-push-records", s.handleVectorPushRecordsJSON)
s.mux.HandleFunc("POST /api/v1/admin/vector-pushes", s.handleVectorPushJSON)
// Creating a token here grants read access to every basemap, so the whole
// admin surface sits behind the admin credentials.
s.mux.HandleFunc("GET /api/v1/admin/dashboard", s.requireAdmin(s.handleDashboardJSON))
s.mux.HandleFunc("GET /api/v1/admin/basemaps", s.requireAdmin(s.handleBasemapsJSON))
s.mux.HandleFunc("GET /api/v1/admin/vectors", s.requireAdmin(s.handleVectorsJSON))
s.mux.HandleFunc("GET /api/v1/admin/scan-sources", s.requireAdmin(s.handleScanSourcesJSON))
s.mux.HandleFunc("POST /api/v1/admin/scan-sources", s.requireAdmin(s.handleCreateScanSourceJSON))
s.mux.HandleFunc("POST /api/v1/admin/scan-sources/{code}/run", s.requireAdmin(s.handleRunScanJSON))
s.mux.HandleFunc("POST /api/v1/admin/vectors/rescan", s.requireAdmin(s.handleRunVectorScanJSON))
s.mux.HandleFunc("POST /api/v1/admin/basemaps/{code}/default-version", s.requireAdmin(s.handleSetDefaultVersionJSON))
s.mux.HandleFunc("GET /api/v1/admin/targets", s.requireAdmin(s.handleTargetsJSON))
s.mux.HandleFunc("POST /api/v1/admin/targets", s.requireAdmin(s.handleCreateTargetJSON))
s.mux.HandleFunc("GET /api/v1/admin/tokens", s.requireAdmin(s.handleTokensJSON))
s.mux.HandleFunc("POST /api/v1/admin/tokens", s.requireAdmin(s.handleCreateTokenJSON))
s.mux.HandleFunc("POST /api/v1/admin/tokens/{id}/disable", s.requireAdmin(s.handleDisableTokenJSON))
s.mux.HandleFunc("GET /api/v1/admin/push-records", s.requireAdmin(s.handlePushRecordsJSON))
s.mux.HandleFunc("POST /api/v1/admin/pushes", s.requireAdmin(s.handlePushJSON))
s.mux.HandleFunc("GET /api/v1/admin/vector-push-records", s.requireAdmin(s.handleVectorPushRecordsJSON))
s.mux.HandleFunc("POST /api/v1/admin/vector-pushes", s.requireAdmin(s.handleVectorPushJSON))
s.mux.HandleFunc("POST /api/v1/admin/health/check", s.requireAdmin(s.handleCheckHealthJSON))
s.mux.HandleFunc("GET /api/v1/admin/tile-jobs", s.requireAdmin(s.handleTileJobsJSON))
s.mux.HandleFunc("POST /api/v1/admin/tile-jobs", s.requireAdmin(s.handleCreateTileJobJSON))
s.mux.HandleFunc("POST /api/v1/admin/push-records/{id}/retry", s.requireAdmin(s.handleRetryPushJSON))
s.mux.HandleFunc("POST /api/v1/admin/vector-push-records/{id}/retry", s.requireAdmin(s.handleRetryVectorPushJSON))
// Slicing for external callers: a token with the "slice" scope, not admin.
s.mux.HandleFunc("POST /api/v1/slice/jobs", s.handleSliceJobCreate)
s.mux.HandleFunc("GET /api/v1/slice/jobs/{id}", s.handleSliceJobGet)
s.mux.HandleFunc("GET /api/v1/slice/layers/{code}/sources", s.handleSliceLayerSources)
s.mux.HandleFunc("GET /api/v1/catalog/basemaps", s.handleCatalogList)
s.mux.HandleFunc("GET /api/v1/catalog/basemaps/{code}", s.handleCatalogGet)
@@ -155,16 +327,78 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /shp/{assetPath...}", s.handleShapeByPath)
s.mux.HandleFunc("GET /vectors/{code}/download", s.handleVectorDownload)
s.mux.HandleFunc("GET /admin/", s.handleAdminPage)
s.mux.HandleFunc("POST /admin/scan-sources", s.handleCreateScanSourceForm)
s.mux.HandleFunc("POST /admin/scan-sources/{code}/run", s.handleRunScanForm)
s.mux.HandleFunc("POST /admin/vectors/rescan", s.handleRunVectorScanForm)
s.mux.HandleFunc("POST /admin/basemaps/{code}/default", s.handleSetDefaultVersionForm)
s.mux.HandleFunc("POST /admin/targets", s.handleCreateTargetForm)
s.mux.HandleFunc("POST /admin/tokens", s.handleCreateTokenForm)
s.mux.HandleFunc("POST /admin/tokens/{id}/disable", s.handleDisableTokenForm)
s.mux.HandleFunc("POST /admin/pushes", s.handlePushForm)
s.mux.HandleFunc("POST /admin/vector-pushes", s.handleVectorPushForm)
s.mux.HandleFunc("GET /admin/", s.requireAdmin(s.handleAdminPage))
s.mux.HandleFunc("POST /admin/scan-sources", s.requireAdmin(s.handleCreateScanSourceForm))
s.mux.HandleFunc("POST /admin/scan-sources/{code}/run", s.requireAdmin(s.handleRunScanForm))
s.mux.HandleFunc("POST /admin/vectors/rescan", s.requireAdmin(s.handleRunVectorScanForm))
s.mux.HandleFunc("POST /admin/basemaps/{code}/default", s.requireAdmin(s.handleSetDefaultVersionForm))
s.mux.HandleFunc("POST /admin/targets", s.requireAdmin(s.handleCreateTargetForm))
s.mux.HandleFunc("POST /admin/tokens", s.requireAdmin(s.handleCreateTokenForm))
s.mux.HandleFunc("POST /admin/tokens/{id}/disable", s.requireAdmin(s.handleDisableTokenForm))
s.mux.HandleFunc("POST /admin/pushes", s.requireAdmin(s.handlePushForm))
s.mux.HandleFunc("POST /admin/vector-pushes", s.requireAdmin(s.handleVectorPushForm))
s.mux.HandleFunc("POST /admin/health/check", s.requireAdmin(s.handleCheckHealthForm))
s.mux.HandleFunc("POST /admin/tile-jobs", s.requireAdmin(s.handleCreateTileJobForm))
s.mux.HandleFunc("POST /admin/tile-jobs/rebuild", s.requireAdmin(s.handleRebuildLayerForm))
s.mux.HandleFunc("POST /admin/push-records/{id}/retry", s.requireAdmin(s.handleRetryPushForm))
s.mux.HandleFunc("POST /admin/vector-push-records/{id}/retry", s.requireAdmin(s.handleRetryVectorPushForm))
}
// requireAdmin guards the admin surface. With no credentials configured the
// service is only reachable on loopback, which main enforces at startup.
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.admin.enabled() {
next(w, r)
return
}
if s.adminAllows(r) {
next(w, r)
return
}
w.Header().Set("WWW-Authenticate", `Basic realm="basemap-pusher admin"`)
http.Error(w, "admin credentials required", http.StatusUnauthorized)
}
}
func (s *Server) adminAllows(r *http.Request) bool {
if token := strings.TrimSpace(s.admin.Token); token != "" {
header := strings.TrimSpace(r.Header.Get("Authorization"))
if len(header) > 7 && strings.EqualFold(header[:7], "bearer ") {
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(header[7:])), []byte(token)) == 1 {
return true
}
}
}
user := strings.TrimSpace(s.admin.User)
if user == "" || s.admin.Password == "" {
return false
}
givenUser, givenPassword, ok := r.BasicAuth()
if !ok {
return false
}
userOK := subtle.ConstantTimeCompare([]byte(givenUser), []byte(user)) == 1
passwordOK := subtle.ConstantTimeCompare([]byte(givenPassword), []byte(s.admin.Password)) == 1
return userOK && passwordOK
}
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
fmt.Fprintf(w, "# TYPE basemap_pusher_requests_total counter\n")
fmt.Fprintf(w, "basemap_pusher_requests_total %d\n", s.metrics.requests.Load())
fmt.Fprintf(w, "# TYPE basemap_pusher_requests_by_status_total counter\n")
fmt.Fprintf(w, "basemap_pusher_requests_by_status_total{class=\"2xx\"} %d\n", s.metrics.status2xx.Load())
fmt.Fprintf(w, "basemap_pusher_requests_by_status_total{class=\"4xx\"} %d\n", s.metrics.status4xx.Load())
fmt.Fprintf(w, "basemap_pusher_requests_by_status_total{class=\"5xx\"} %d\n", s.metrics.status5xx.Load())
fmt.Fprintf(w, "# TYPE basemap_pusher_tile_requests_total counter\n")
fmt.Fprintf(w, "basemap_pusher_tile_requests_total %d\n", s.metrics.tiles.Load())
fmt.Fprintf(w, "# TYPE basemap_pusher_tile_misses_total counter\n")
fmt.Fprintf(w, "basemap_pusher_tile_misses_total %d\n", s.metrics.tileMisses.Load())
fmt.Fprintf(w, "# TYPE basemap_pusher_auth_failures_total counter\n")
fmt.Fprintf(w, "basemap_pusher_auth_failures_total %d\n", s.metrics.authFailures.Load())
fmt.Fprintf(w, "# TYPE basemap_pusher_uptime_seconds gauge\n")
fmt.Fprintf(w, "basemap_pusher_uptime_seconds %.0f\n", time.Since(s.startedAt).Seconds())
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -241,7 +475,7 @@ func (s *Server) handleCreateScanSourceJSON(w http.ResponseWriter, r *http.Reque
func (s *Server) handleRunScanJSON(w http.ResponseWriter, r *http.Request) {
run, err := s.store.RunScan(r.Context(), r.PathValue("code"))
if err != nil {
writeError(w, http.StatusBadRequest, err)
writeError(w, scanErrorStatus(err), err)
return
}
writeJSON(w, http.StatusOK, run)
@@ -250,7 +484,7 @@ func (s *Server) handleRunScanJSON(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRunVectorScanJSON(w http.ResponseWriter, r *http.Request) {
result, err := s.store.RunVectorScan(r.Context())
if err != nil {
writeError(w, http.StatusBadRequest, err)
writeError(w, scanErrorStatus(err), err)
return
}
writeJSON(w, http.StatusOK, result)
@@ -358,7 +592,7 @@ func (s *Server) handlePushJSON(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusCreated, item)
writeJSON(w, http.StatusAccepted, item)
}
func (s *Server) handleVectorPushJSON(w http.ResponseWriter, r *http.Request) {
@@ -375,7 +609,7 @@ func (s *Server) handleVectorPushJSON(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusCreated, item)
writeJSON(w, http.StatusAccepted, item)
}
func (s *Server) handleCatalogList(w http.ResponseWriter, r *http.Request) {
@@ -456,6 +690,7 @@ func (s *Server) handleTile(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", item.ContentType)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeFile(w, r, item.FilePath)
}
@@ -515,6 +750,203 @@ func (s *Server) handleShapeByPath(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, item.FilePath)
}
func (s *Server) handleCheckHealthJSON(w http.ResponseWriter, r *http.Request) {
report, err := s.store.CheckHealth(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, report)
}
func (s *Server) handleTileJobsJSON(w http.ResponseWriter, r *http.Request) {
items, err := s.store.ListTileJobs(r.Context(), 50)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (s *Server) handleCreateTileJobJSON(w http.ResponseWriter, r *http.Request) {
var input basemap.CreateTileJobInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
job, err := s.store.CreateTileJob(r.Context(), input)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusAccepted, job)
}
func (s *Server) handleRetryPushJSON(w http.ResponseWriter, r *http.Request) {
if err := s.store.RetryPush(r.Context(), "basemap", r.PathValue("id")); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "queued"})
}
func (s *Server) handleRetryVectorPushJSON(w http.ResponseWriter, r *http.Request) {
if err := s.store.RetryPush(r.Context(), "vector", r.PathValue("id")); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "queued"})
}
func (s *Server) handleCheckHealthForm(w http.ResponseWriter, r *http.Request) {
report, err := s.store.CheckHealth(r.Context())
flash := "资源巡检已完成"
if err == nil {
flash = fmt.Sprintf("资源巡检完成:底图缺 %d / %d,矢量缺 %d / %d",
report.VersionsMissing, report.VersionsChecked, report.VectorsMissing, report.VectorsChecked)
}
s.redirectAdmin(w, r, flash, err)
}
func (s *Server) handleCreateTileJobForm(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "", err)
return
}
zoomMin, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("zoom_min")))
zoomMax, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("zoom_max")))
tileSize, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("tile_size")))
_, err := s.store.CreateTileJob(r.Context(), basemap.CreateTileJobInput{
Code: r.FormValue("code"),
Name: r.FormValue("name"),
SourcePath: r.FormValue("source_path"),
TargetCode: r.FormValue("target_code"),
ZoomMin: zoomMin,
ZoomMax: zoomMax,
TileSize: tileSize,
TileFormat: r.FormValue("tile_format"),
CallbackURL: r.FormValue("callback_url"),
RequestedBy: r.FormValue("requested_by"),
})
s.redirectAdmin(w, r, "切片任务已提交,刷新查看进度", err)
}
// handleRebuildLayerForm slices every source of a layer into one VRT mosaic,
// reusing the zoom range the layer is already published at.
func (s *Server) handleRebuildLayerForm(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "", err)
return
}
targetCode := strings.TrimSpace(r.FormValue("target_code"))
if targetCode == "" {
s.redirectAdmin(w, r, "", errors.New("target code is required"))
return
}
sources, err := s.store.ListLayerSources(r.Context(), targetCode)
if err != nil {
s.redirectAdmin(w, r, "", err)
return
}
if len(sources) == 0 {
s.redirectAdmin(w, r, "", fmt.Errorf("layer %q has no recorded sources", targetCode))
return
}
zoomMin := 0
zoomMax := 18
tileFormat := "webp"
if items, err := s.store.ListLayerInventories(r.Context()); err == nil {
for _, item := range items {
if item.TargetCode == normalizeLayerCode(targetCode) {
zoomMin, zoomMax, tileFormat = item.ZoomMin, item.ZoomMax, item.TileFormat
break
}
}
}
paths := make([]string, 0, len(sources))
for _, source := range sources {
paths = append(paths, source.SourcePath)
}
if _, err := s.store.CreateTileJob(r.Context(), basemap.CreateTileJobInput{
Name: "重建 " + targetCode,
TargetCode: targetCode,
SourcePaths: paths,
ZoomMin: zoomMin,
ZoomMax: zoomMax,
TileFormat: tileFormat,
RequestedBy: "console",
}); err != nil {
s.redirectAdmin(w, r, "", err)
return
}
s.redirectAdmin(w, r, fmt.Sprintf("已提交 %s 的镶嵌重建(%d 个源,级别 %d-%d)", targetCode, len(paths), zoomMin, zoomMax), nil)
}
func (s *Server) handleRetryPushForm(w http.ResponseWriter, r *http.Request) {
err := s.store.RetryPush(r.Context(), "basemap", r.PathValue("id"))
s.redirectAdmin(w, r, "已重新排队推送", err)
}
func (s *Server) handleRetryVectorPushForm(w http.ResponseWriter, r *http.Request) {
err := s.store.RetryPush(r.Context(), "vector", r.PathValue("id"))
s.redirectAdmin(w, r, "已重新排队推送", err)
}
// requireSliceToken authenticates and then checks the coarse slice capability,
// so an integration can submit jobs without holding the admin credential.
func (s *Server) requireSliceToken(w http.ResponseWriter, r *http.Request) (basemap.TokenAuth, bool) {
auth, ok := s.requireToken(w, r)
if !ok {
return basemap.TokenAuth{}, false
}
if !auth.CanSlice() {
writeError(w, http.StatusForbidden, errors.New("token does not carry the slice scope"))
return basemap.TokenAuth{}, false
}
return auth, true
}
func (s *Server) handleSliceJobCreate(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSliceToken(w, r); !ok {
return
}
var input basemap.CreateTileJobInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
job, err := s.store.CreateTileJob(r.Context(), input)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusAccepted, job)
}
func (s *Server) handleSliceLayerSources(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSliceToken(w, r); !ok {
return
}
items, err := s.store.ListLayerSources(r.Context(), r.PathValue("code"))
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, items)
}
func (s *Server) handleSliceJobGet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSliceToken(w, r); !ok {
return
}
job, err := s.store.GetTileJob(r.Context(), r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, job)
}
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
data, err := s.store.Dashboard(r.Context(), s.system, r.URL.Query().Get("flash"), r.URL.Query().Get("token"))
if err != nil {
@@ -529,7 +961,7 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleCreateScanSourceForm(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "", "", err)
s.redirectAdmin(w, r, "", err)
return
}
_, err := s.store.EnsureScanSource(r.Context(), basemap.CreateScanSourceInput{
@@ -538,31 +970,31 @@ func (s *Server) handleCreateScanSourceForm(w http.ResponseWriter, r *http.Reque
RootPath: r.FormValue("root_path"),
ManifestName: r.FormValue("manifest_name"),
})
s.redirectAdmin(w, r, "扫描源已保存", "", err)
s.redirectAdmin(w, r, "扫描源已保存", err)
}
func (s *Server) handleRunScanForm(w http.ResponseWriter, r *http.Request) {
_, err := s.store.RunScan(r.Context(), r.PathValue("code"))
s.redirectAdmin(w, r, "底图扫描已完成", "", err)
s.redirectAdmin(w, r, "底图扫描已完成", err)
}
func (s *Server) handleRunVectorScanForm(w http.ResponseWriter, r *http.Request) {
_, err := s.store.RunVectorScan(r.Context())
s.redirectAdmin(w, r, "矢量扫描已完成", "", err)
s.redirectAdmin(w, r, "矢量扫描已完成", err)
}
func (s *Server) handleSetDefaultVersionForm(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "", "", err)
s.redirectAdmin(w, r, "", err)
return
}
err := s.store.SetDefaultVersion(r.Context(), r.PathValue("code"), r.FormValue("version"))
s.redirectAdmin(w, r, "默认版本已更新", "", err)
s.redirectAdmin(w, r, "默认版本已更新", err)
}
func (s *Server) handleCreateTargetForm(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "", "", err)
s.redirectAdmin(w, r, "", err)
return
}
headers := map[string]string{}
@@ -580,44 +1012,44 @@ func (s *Server) handleCreateTargetForm(w http.ResponseWriter, r *http.Request)
CallbackMethod: r.FormValue("callback_method"),
CallbackHeaders: headers,
})
s.redirectAdmin(w, r, "目标系统已保存", "", err)
s.redirectAdmin(w, r, "目标系统已保存", err)
}
func (s *Server) handleCreateTokenForm(w http.ResponseWriter, r *http.Request) {
input, err := decodeTokenInputFromForm(r)
if err != nil {
s.redirectAdmin(w, r, "", "", err)
s.redirectAdmin(w, r, "", err)
return
}
item, err := s.store.CreateToken(r.Context(), input)
if err != nil {
s.redirectAdmin(w, r, "", "", err)
s.redirectAdmin(w, r, "", err)
return
}
s.redirectAdmin(w, r, "访问令牌已创建", item.Token, nil)
s.renderAdmin(w, r, "访问令牌已创建", item.Token, nil)
}
func (s *Server) handleDisableTokenForm(w http.ResponseWriter, r *http.Request) {
err := s.store.DisableToken(r.Context(), r.PathValue("id"))
s.redirectAdmin(w, r, "访问令牌已停用", "", err)
s.redirectAdmin(w, r, "访问令牌已停用", err)
}
func (s *Server) handlePushForm(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "", "", err)
s.redirectAdmin(w, r, "", err)
return
}
_, err := s.store.PushBasemapVersion(r.Context(), r.FormValue("target_code"), r.FormValue("basemap_code"), r.FormValue("version"))
s.redirectAdmin(w, r, "底图推送已完成", "", err)
s.redirectAdmin(w, r, "底图推送已提交,稍后刷新查看结果", err)
}
func (s *Server) handleVectorPushForm(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "", "", err)
s.redirectAdmin(w, r, "", err)
return
}
_, err := s.store.PushVectorAsset(r.Context(), r.FormValue("target_code"), r.FormValue("vector_code"))
s.redirectAdmin(w, r, "矢量推送已完成", "", err)
s.redirectAdmin(w, r, "矢量推送已提交,稍后刷新查看结果", err)
}
func (s *Server) requireToken(w http.ResponseWriter, r *http.Request) (basemap.TokenAuth, bool) {
@@ -652,6 +1084,7 @@ func decodeTokenInput(r *http.Request) (basemap.CreateTokenInput, error) {
Name string `json:"name"`
BasemapCodes []string `json:"basemap_codes"`
VectorCodes []string `json:"vector_codes"`
Scopes []string `json:"scopes"`
ExpiresAt string `json:"expires_at"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
@@ -661,6 +1094,7 @@ func decodeTokenInput(r *http.Request) (basemap.CreateTokenInput, error) {
Name: payload.Name,
BasemapCodes: payload.BasemapCodes,
VectorCodes: payload.VectorCodes,
Scopes: payload.Scopes,
}
if strings.TrimSpace(payload.ExpiresAt) != "" {
parsed, err := time.Parse(time.RFC3339, payload.ExpiresAt)
@@ -680,6 +1114,7 @@ func decodeTokenInputFromForm(r *http.Request) (basemap.CreateTokenInput, error)
Name: r.FormValue("name"),
BasemapCodes: splitCSV(r.FormValue("basemap_codes")),
VectorCodes: splitCSV(r.FormValue("vector_codes")),
Scopes: splitCSV(r.FormValue("scopes")),
}
if value := strings.TrimSpace(r.FormValue("expires_at")); value != "" {
if minutes, err := strconv.Atoi(value); err == nil && minutes > 0 {
@@ -744,6 +1179,8 @@ func statusText(value string) string {
return "失败"
case "pending":
return "待处理"
case "retrying":
return "重试中"
case "running":
return "运行中"
case "processing":
@@ -761,7 +1198,7 @@ func statusTone(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "active", "enabled", "ready", "succeeded", "current":
return "success"
case "pending", "running", "processing":
case "pending", "running", "processing", "retrying":
return "warning"
case "disabled", "failed", "error", "expired", "invalid":
return "danger"
@@ -787,6 +1224,25 @@ func formatBytes(size int64) string {
return fmt.Sprintf("%.1f %s", value, units[unit])
}
// scanErrorStatus maps scan failures onto HTTP status codes: a busy scanner is
// a conflict, an unknown source is a 404, and anything else is a server fault.
// normalizeLayerCode mirrors the store's basemap code normalization so the
// console can match a layer against its inventory entry.
func normalizeLayerCode(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func scanErrorStatus(err error) int {
switch {
case errors.Is(err, basemap.ErrScanInProgress):
return http.StatusConflict
case errors.Is(err, basemap.ErrScanSourceNotFound):
return http.StatusNotFound
case errors.Is(err, basemap.ErrScanSourceDisabled):
return http.StatusBadRequest
default:
return http.StatusInternalServerError
}
}
func localizeAdminError(err error) string {
if err == nil {
return ""
@@ -819,8 +1275,8 @@ func localizeAdminError(err error) string {
return fmt.Sprintf("目标系统已停用:%s", quoted[0])
case msg == "token name is required":
return "令牌名称不能为空"
case msg == "at least one basemap code or vector code is required":
return "至少填写一个底图编码或矢量资源编码/路径"
case msg == "at least one basemap code, vector code, or the slice scope is required":
return "至少填写一个底图编码、矢量资源,或勾选切片能力"
case msg == "no valid basemap codes provided":
return "未提供有效的底图编码"
case len(quoted) == 1 && strings.HasPrefix(msg, `token "`) && strings.HasSuffix(msg, `" not found`):
@@ -849,24 +1305,38 @@ func quotedSegments(value string) []string {
return result
}
func (s *Server) redirectAdmin(w http.ResponseWriter, r *http.Request, flash string, token string, err error) {
// redirectAdmin sends the browser back to the console with a flash message. It
// deliberately never carries a secret in the URL.
func (s *Server) redirectAdmin(w http.ResponseWriter, r *http.Request, flash string, err error) {
if err != nil {
flash = "操作失败:" + localizeAdminError(err)
}
values := url.Values{}
if flash != "" {
values.Set("flash", flash)
}
if token != "" {
values.Set("token", token)
}
location := "/admin/"
if encoded := values.Encode(); encoded != "" {
location += "?" + encoded
if strings.TrimSpace(flash) != "" {
values := url.Values{}
values.Set("flash", flash)
location += "?" + values.Encode()
}
http.Redirect(w, r, location, http.StatusSeeOther)
}
// renderAdmin answers the current POST with the console HTML so one-time
// secrets (a freshly minted token) stay out of the URL, history and logs.
func (s *Server) renderAdmin(w http.ResponseWriter, r *http.Request, flash string, token string, err error) {
if err != nil {
flash = "操作失败:" + localizeAdminError(err)
}
data, dataErr := s.store.Dashboard(r.Context(), s.system, flash, token)
if dataErr != nil {
writeError(w, http.StatusInternalServerError, dataErr)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if execErr := s.template.Execute(w, data); execErr != nil {
writeError(w, http.StatusInternalServerError, execErr)
}
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
@@ -878,11 +1348,3 @@ func writeError(w http.ResponseWriter, status int, err error) {
"error": err.Error(),
})
}
func runStartupScan(ctx context.Context, store *basemap.Store) error {
if !store.AutoScanEnabled() || strings.TrimSpace(store.ScanRoot()) == "" {
return nil
}
_, err := store.RunScan(ctx, "local-basemaps")
return err
}
@@ -0,0 +1,181 @@
package basemapapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"tile-server/api-go/internal/basemap"
)
func newSliceServer(t *testing.T) (*basemap.Store, *httptest.Server) {
t.Helper()
root := t.TempDir()
tileRoot := filepath.Join(root, "tiles")
if err := os.MkdirAll(tileRoot, 0o755); err != nil {
t.Fatalf("mkdir tile root: %v", err)
}
source := filepath.Join(root, "input.tif")
writeFile(t, source, "fake raster")
// A scanned basemap gives the negative test case something to grant.
writeFile(t, filepath.Join(tileRoot, "google_image", "0", "0", "0.webp"), "tile")
store, err := basemap.OpenStore(basemap.StoreConfig{
SQLitePath: filepath.Join(root, "basemap.db"),
ScanRoot: tileRoot,
VectorScanRoot: filepath.Join(root, "vectors"),
APIBaseURL: "http://127.0.0.1:8910",
TileBaseURL: "http://127.0.0.1:8910",
VectorBaseURL: "http://127.0.0.1:8910",
TileJobCommand: "definitely-not-a-real-binary {input} {output}",
})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
if _, err := store.RunScan(context.Background(), "local-basemaps"); err != nil {
t.Fatalf("scan: %v", err)
}
return store, newTestServerRaw(t, store)
}
func newTestServerRaw(t *testing.T, store *basemap.Store) *httptest.Server {
t.Helper()
server, err := New(store, basemap.SystemInfo{ServiceName: "basemap-pusher"}, Options{})
if err != nil {
t.Fatalf("new server: %v", err)
}
httpServer := httptest.NewServer(server.Handler())
t.Cleanup(httpServer.Close)
return httpServer
}
func postJob(t *testing.T, url, token string, body map[string]any) *http.Response {
t.Helper()
encoded, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(encoded))
if err != nil {
t.Fatalf("request: %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+token)
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("post: %v", err)
}
return response
}
func TestSliceJobRequiresSliceScope(t *testing.T) {
ctx := context.Background()
store, httpServer := newSliceServer(t)
source := filepath.Join(filepath.Dir(store.ScanRoot()), "input.tif")
readOnly, err := store.CreateToken(ctx, basemap.CreateTokenInput{
Name: "read-only",
BasemapCodes: []string{"google_image"},
Scopes: []string{"read"},
})
if err != nil {
t.Fatalf("create read-only token: %v", err)
}
if _, err := store.CreateToken(ctx, basemap.CreateTokenInput{Name: "empty"}); err == nil {
t.Fatal("expected a token with neither grants nor slice scope to be rejected")
}
response := postJob(t, httpServer.URL+"/api/v1/slice/jobs", readOnly.Token, map[string]any{
"source_path": source,
"target_code": "demo",
})
response.Body.Close()
if response.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for a read-only token, got %d", response.StatusCode)
}
}
func TestSliceJobSubmitAndPoll(t *testing.T) {
ctx := context.Background()
store, httpServer := newSliceServer(t)
source := filepath.Join(filepath.Dir(store.ScanRoot()), "input.tif")
slicer, err := store.CreateToken(ctx, basemap.CreateTokenInput{
Name: "integration",
Scopes: []string{"slice"},
})
if err != nil {
t.Fatalf("create slicer token: %v", err)
}
body := map[string]any{
"code": "integration-job",
"source_path": source,
"target_code": "demo",
"zoom_min": 0,
"zoom_max": 2,
}
response := postJob(t, httpServer.URL+"/api/v1/slice/jobs", slicer.Token, body)
if response.StatusCode != http.StatusAccepted {
response.Body.Close()
t.Fatalf("expected 202, got %d", response.StatusCode)
}
var job basemap.TileJob
if err := json.NewDecoder(response.Body).Decode(&job); err != nil {
t.Fatalf("decode: %v", err)
}
response.Body.Close()
// Re-submitting the same code must not queue a second job.
again := postJob(t, httpServer.URL+"/api/v1/slice/jobs", slicer.Token, body)
again.Body.Close()
if again.StatusCode != http.StatusAccepted {
t.Fatalf("expected the repeat submit to be accepted, got %d", again.StatusCode)
}
request, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/api/v1/slice/jobs/"+job.ID, nil)
request.Header.Set("Authorization", "Bearer "+slicer.Token)
polled, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("poll: %v", err)
}
defer polled.Body.Close()
if polled.StatusCode != http.StatusOK {
t.Fatalf("expected 200 from the poll endpoint, got %d", polled.StatusCode)
}
var fetched basemap.TileJob
if err := json.NewDecoder(polled.Body).Decode(&fetched); err != nil {
t.Fatalf("decode polled job: %v", err)
}
if fetched.ID != job.ID {
t.Fatalf("polled %s, submitted %s", fetched.ID, job.ID)
}
jobs, err := store.ListTileJobs(ctx, 10)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(jobs) != 1 {
t.Fatalf("expected one job after a repeated submit, got %d", len(jobs))
}
}
func TestSliceJobRejectsAnonymous(t *testing.T) {
_, httpServer := newSliceServer(t)
request, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/api/v1/slice/jobs/whatever", nil)
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("request: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 without a token, got %d", response.StatusCode)
}
}