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.
1351 lines
44 KiB
Go
1351 lines
44 KiB
Go
package basemapapi
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"tile-server/api-go/internal/basemap"
|
|
)
|
|
|
|
// 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 (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() {
|
|
return "-"
|
|
}
|
|
return value.Local().Format("2006-01-02 15:04:05")
|
|
},
|
|
"fmtTimePtr": func(value *time.Time) string {
|
|
if value == nil || value.IsZero() {
|
|
return "-"
|
|
}
|
|
return value.Local().Format("2006-01-02 15:04:05")
|
|
},
|
|
"defaultVersion": func(value *basemap.BasemapVersion) string {
|
|
if value == nil {
|
|
return "-"
|
|
}
|
|
return value.Version
|
|
},
|
|
"tokenGrants": func(grants []basemap.TokenGrant) string {
|
|
if len(grants) == 0 {
|
|
return "-"
|
|
}
|
|
parts := make([]string, 0, len(grants))
|
|
for _, grant := range grants {
|
|
if grant.BasemapVersion != nil {
|
|
parts = append(parts, grant.BasemapCode+":"+*grant.BasemapVersion)
|
|
} else {
|
|
parts = append(parts, grant.BasemapCode+":*")
|
|
}
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
},
|
|
"vectorTokenGrants": func(grants []basemap.VectorTokenGrant) string {
|
|
if len(grants) == 0 {
|
|
return "-"
|
|
}
|
|
parts := make([]string, 0, len(grants))
|
|
for _, grant := range grants {
|
|
parts = append(parts, grant.VectorCode)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
},
|
|
"versionLabel": func(item basemap.BasemapVersion) string {
|
|
if item.IsDefault {
|
|
return item.Version + "(默认)"
|
|
}
|
|
return item.Version
|
|
},
|
|
"vectorPath": func(item basemap.VectorAsset) string {
|
|
if value, ok := item.Metadata["relative_path"].(string); ok && strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
return item.FileName
|
|
},
|
|
"statusText": statusText,
|
|
"statusTone": statusTone,
|
|
"enabledText": func(enabled bool) string {
|
|
if enabled {
|
|
return "启用"
|
|
}
|
|
return "停用"
|
|
},
|
|
"enabledTone": func(enabled bool) string {
|
|
if enabled {
|
|
return "success"
|
|
}
|
|
return "danger"
|
|
},
|
|
"formatBytes": formatBytes,
|
|
"flashClass": func(value string) string {
|
|
if strings.HasPrefix(strings.TrimSpace(value), "操作失败:") {
|
|
return "danger"
|
|
}
|
|
return ""
|
|
},
|
|
}).Parse(adminTemplate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
server := &Server{
|
|
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 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() {
|
|
s.mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/admin/", http.StatusFound)
|
|
})
|
|
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)
|
|
|
|
// 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)
|
|
s.mux.HandleFunc("GET /api/v1/catalog/vectors", s.handleVectorCatalogList)
|
|
s.mux.HandleFunc("GET /api/v1/catalog/vectors/{code}", s.handleVectorCatalogGet)
|
|
s.mux.HandleFunc("GET /tiles/{code}/{version}/{tilePath...}", s.handleTile)
|
|
s.mux.HandleFunc("GET /geojson/{assetPath...}", s.handleGeoJSONByPath)
|
|
s.mux.HandleFunc("GET /shp/{assetPath...}", s.handleShapeByPath)
|
|
s.mux.HandleFunc("GET /vectors/{code}/download", s.handleVectorDownload)
|
|
|
|
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) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "ok",
|
|
"service": s.system.ServiceName,
|
|
"version": s.system.ServiceVersion,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": "ready",
|
|
"started_at": s.startedAt,
|
|
"scan_root": s.system.ScanRoot,
|
|
"sqlite": s.system.SQLitePath,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, s.system)
|
|
}
|
|
|
|
func (s *Server) handleDashboardJSON(w http.ResponseWriter, r *http.Request) {
|
|
data, err := s.store.Dashboard(r.Context(), s.system, "", "")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, data)
|
|
}
|
|
|
|
func (s *Server) handleBasemapsJSON(w http.ResponseWriter, r *http.Request) {
|
|
items, err := s.store.ListBasemaps(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (s *Server) handleVectorsJSON(w http.ResponseWriter, r *http.Request) {
|
|
items, err := s.store.ListVectorAssets(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (s *Server) handleScanSourcesJSON(w http.ResponseWriter, r *http.Request) {
|
|
items, err := s.store.ListScanSources(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (s *Server) handleCreateScanSourceJSON(w http.ResponseWriter, r *http.Request) {
|
|
var input basemap.CreateScanSourceInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, fmt.Errorf("decode json: %w", err))
|
|
return
|
|
}
|
|
item, err := s.store.EnsureScanSource(r.Context(), input)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, item)
|
|
}
|
|
|
|
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, scanErrorStatus(err), err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, run)
|
|
}
|
|
|
|
func (s *Server) handleRunVectorScanJSON(w http.ResponseWriter, r *http.Request) {
|
|
result, err := s.store.RunVectorScan(r.Context())
|
|
if err != nil {
|
|
writeError(w, scanErrorStatus(err), err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func (s *Server) handleSetDefaultVersionJSON(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
Version string `json:"version"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if err := s.store.SetDefaultVersion(r.Context(), r.PathValue("code"), payload.Version); err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleTargetsJSON(w http.ResponseWriter, r *http.Request) {
|
|
items, err := s.store.ListTargetSystems(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (s *Server) handleCreateTargetJSON(w http.ResponseWriter, r *http.Request) {
|
|
var input basemap.CreateTargetSystemInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
item, err := s.store.CreateTargetSystem(r.Context(), input)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, item)
|
|
}
|
|
|
|
func (s *Server) handleTokensJSON(w http.ResponseWriter, r *http.Request) {
|
|
items, err := s.store.ListTokens(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (s *Server) handleCreateTokenJSON(w http.ResponseWriter, r *http.Request) {
|
|
input, err := decodeTokenInput(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
item, err := s.store.CreateToken(r.Context(), input)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, item)
|
|
}
|
|
|
|
func (s *Server) handleDisableTokenJSON(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.store.DisableToken(r.Context(), r.PathValue("id")); err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handlePushRecordsJSON(w http.ResponseWriter, r *http.Request) {
|
|
items, err := s.store.ListPushRecords(r.Context(), 20)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (s *Server) handleVectorPushRecordsJSON(w http.ResponseWriter, r *http.Request) {
|
|
items, err := s.store.ListVectorPushRecords(r.Context(), 20)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (s *Server) handlePushJSON(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
TargetCode string `json:"target_code"`
|
|
BasemapCode string `json:"basemap_code"`
|
|
Version string `json:"version"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
item, err := s.store.PushBasemapVersion(r.Context(), payload.TargetCode, payload.BasemapCode, payload.Version)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, item)
|
|
}
|
|
|
|
func (s *Server) handleVectorPushJSON(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
TargetCode string `json:"target_code"`
|
|
VectorCode string `json:"vector_code"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
item, err := s.store.PushVectorAsset(r.Context(), payload.TargetCode, payload.VectorCode)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, item)
|
|
}
|
|
|
|
func (s *Server) handleCatalogList(w http.ResponseWriter, r *http.Request) {
|
|
auth, ok := s.requireToken(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := s.store.ListBasemaps(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.store.FilterCatalog(auth, items))
|
|
}
|
|
|
|
func (s *Server) handleCatalogGet(w http.ResponseWriter, r *http.Request) {
|
|
auth, ok := s.requireToken(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
item, err := s.store.GetBasemapByCode(r.Context(), r.PathValue("code"))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
filtered := s.store.FilterCatalog(auth, []basemap.Basemap{item})
|
|
if len(filtered) == 0 {
|
|
writeError(w, http.StatusForbidden, errors.New("token has no access to this basemap"))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, filtered[0])
|
|
}
|
|
|
|
func (s *Server) handleVectorCatalogList(w http.ResponseWriter, r *http.Request) {
|
|
auth, ok := s.requireToken(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
items, err := s.store.ListVectorAssets(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.store.FilterVectors(auth, items))
|
|
}
|
|
|
|
func (s *Server) handleVectorCatalogGet(w http.ResponseWriter, r *http.Request) {
|
|
auth, ok := s.requireToken(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
item, err := s.store.GetVectorAssetByCode(r.Context(), r.PathValue("code"))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
filtered := s.store.FilterVectors(auth, []basemap.VectorAsset{item})
|
|
if len(filtered) == 0 {
|
|
writeError(w, http.StatusForbidden, errors.New("token has no access to this vector asset"))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, filtered[0])
|
|
}
|
|
|
|
func (s *Server) handleTile(w http.ResponseWriter, r *http.Request) {
|
|
auth, ok := s.requireToken(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
version := r.PathValue("version")
|
|
tilePath := r.PathValue("tilePath")
|
|
item, err := s.store.ResolveTile(r.Context(), auth, r.PathValue("code"), version, tilePath)
|
|
if err != nil && errors.Is(err, basemap.ErrBasemapVersionNotFound) && isTileZoom(version) {
|
|
item, err = s.store.ResolveDefaultTile(r.Context(), auth, r.PathValue("code"), version+"/"+tilePath)
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", item.ContentType)
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
http.ServeFile(w, r, item.FilePath)
|
|
}
|
|
|
|
func (s *Server) handleVectorDownload(w http.ResponseWriter, r *http.Request) {
|
|
auth, ok := s.requireToken(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
item, err := s.store.ResolveVectorDownload(r.Context(), auth, r.PathValue("code"))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", item.ContentType)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", item.FileName))
|
|
if item.Zipped {
|
|
if err := basemap.WriteVectorZip(w, item); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
http.ServeFile(w, r, item.FilePath)
|
|
}
|
|
|
|
func (s *Server) handleGeoJSONByPath(w http.ResponseWriter, r *http.Request) {
|
|
auth, ok := s.requireToken(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
item, err := s.store.ResolveVectorDownloadByRef(r.Context(), auth, r.PathValue("assetPath"), "geojson")
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", item.ContentType)
|
|
http.ServeFile(w, r, item.FilePath)
|
|
}
|
|
|
|
func (s *Server) handleShapeByPath(w http.ResponseWriter, r *http.Request) {
|
|
auth, ok := s.requireToken(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
item, err := s.store.ResolveVectorDownloadByRef(r.Context(), auth, r.PathValue("assetPath"), "shapefile", "shpzip")
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", item.ContentType)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", item.FileName))
|
|
if item.Zipped {
|
|
if err := basemap.WriteVectorZip(w, item); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
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 {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.template.Execute(w, data); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleCreateScanSourceForm(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
s.redirectAdmin(w, r, "", err)
|
|
return
|
|
}
|
|
_, err := s.store.EnsureScanSource(r.Context(), basemap.CreateScanSourceInput{
|
|
Code: r.FormValue("code"),
|
|
Name: r.FormValue("name"),
|
|
RootPath: r.FormValue("root_path"),
|
|
ManifestName: r.FormValue("manifest_name"),
|
|
})
|
|
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)
|
|
}
|
|
|
|
func (s *Server) handleRunVectorScanForm(w http.ResponseWriter, r *http.Request) {
|
|
_, err := s.store.RunVectorScan(r.Context())
|
|
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)
|
|
return
|
|
}
|
|
err := s.store.SetDefaultVersion(r.Context(), r.PathValue("code"), r.FormValue("version"))
|
|
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)
|
|
return
|
|
}
|
|
headers := map[string]string{}
|
|
for _, line := range splitLines(r.FormValue("headers")) {
|
|
key, value, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
headers[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
|
}
|
|
_, err := s.store.CreateTargetSystem(r.Context(), basemap.CreateTargetSystemInput{
|
|
Code: r.FormValue("code"),
|
|
Name: r.FormValue("name"),
|
|
CallbackURL: r.FormValue("callback_url"),
|
|
CallbackMethod: r.FormValue("callback_method"),
|
|
CallbackHeaders: headers,
|
|
})
|
|
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)
|
|
return
|
|
}
|
|
item, err := s.store.CreateToken(r.Context(), input)
|
|
if err != nil {
|
|
s.redirectAdmin(w, r, "", err)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|
|
func (s *Server) handlePushForm(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
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)
|
|
}
|
|
|
|
func (s *Server) handleVectorPushForm(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
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)
|
|
}
|
|
|
|
func (s *Server) requireToken(w http.ResponseWriter, r *http.Request) (basemap.TokenAuth, bool) {
|
|
rawToken := extractToken(r)
|
|
auth, err := s.store.AuthorizeToken(r.Context(), rawToken)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, err)
|
|
return basemap.TokenAuth{}, false
|
|
}
|
|
return auth, true
|
|
}
|
|
|
|
func extractToken(r *http.Request) string {
|
|
if value := strings.TrimSpace(r.Header.Get("X-Access-Token")); value != "" {
|
|
return value
|
|
}
|
|
if header := strings.TrimSpace(r.Header.Get("Authorization")); header != "" {
|
|
if strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
|
return strings.TrimSpace(header[7:])
|
|
}
|
|
}
|
|
for _, key := range []string{"token", "access_token"} {
|
|
if value := strings.TrimSpace(r.URL.Query().Get(key)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func decodeTokenInput(r *http.Request) (basemap.CreateTokenInput, error) {
|
|
var payload struct {
|
|
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 {
|
|
return basemap.CreateTokenInput{}, err
|
|
}
|
|
input := basemap.CreateTokenInput{
|
|
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)
|
|
if err != nil {
|
|
return basemap.CreateTokenInput{}, fmt.Errorf("parse expires_at: %w", err)
|
|
}
|
|
input.ExpiresAt = &parsed
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
func decodeTokenInputFromForm(r *http.Request) (basemap.CreateTokenInput, error) {
|
|
if err := r.ParseForm(); err != nil {
|
|
return basemap.CreateTokenInput{}, err
|
|
}
|
|
input := basemap.CreateTokenInput{
|
|
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 {
|
|
expiresAt := time.Now().UTC().Add(time.Duration(minutes) * time.Minute)
|
|
input.ExpiresAt = &expiresAt
|
|
}
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
func splitCSV(value string) []string {
|
|
fields := strings.FieldsFunc(value, func(r rune) bool {
|
|
return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
|
|
})
|
|
result := make([]string, 0, len(fields))
|
|
for _, field := range fields {
|
|
field = strings.TrimSpace(field)
|
|
if field != "" {
|
|
result = append(result, field)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func splitLines(value string) []string {
|
|
raw := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")
|
|
lines := make([]string, 0, len(raw))
|
|
for _, line := range raw {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func isTileZoom(value string) bool {
|
|
if value == "" {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func statusText(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "":
|
|
return "-"
|
|
case "active", "enabled":
|
|
return "启用"
|
|
case "disabled":
|
|
return "停用"
|
|
case "ready":
|
|
return "就绪"
|
|
case "succeeded":
|
|
return "成功"
|
|
case "failed":
|
|
return "失败"
|
|
case "pending":
|
|
return "待处理"
|
|
case "retrying":
|
|
return "重试中"
|
|
case "running":
|
|
return "运行中"
|
|
case "processing":
|
|
return "处理中"
|
|
case "current":
|
|
return "当前"
|
|
case "error":
|
|
return "异常"
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func statusTone(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "active", "enabled", "ready", "succeeded", "current":
|
|
return "success"
|
|
case "pending", "running", "processing", "retrying":
|
|
return "warning"
|
|
case "disabled", "failed", "error", "expired", "invalid":
|
|
return "danger"
|
|
default:
|
|
return "neutral"
|
|
}
|
|
}
|
|
|
|
func formatBytes(size int64) string {
|
|
if size < 0 {
|
|
return "-"
|
|
}
|
|
units := []string{"B", "KB", "MB", "GB", "TB"}
|
|
value := float64(size)
|
|
unit := 0
|
|
for value >= 1024 && unit < len(units)-1 {
|
|
value /= 1024
|
|
unit++
|
|
}
|
|
if unit == 0 {
|
|
return fmt.Sprintf("%d %s", size, units[unit])
|
|
}
|
|
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 ""
|
|
}
|
|
msg := strings.TrimSpace(err.Error())
|
|
quoted := quotedSegments(msg)
|
|
|
|
switch {
|
|
case msg == "scan source code is required":
|
|
return "扫描源编码不能为空"
|
|
case msg == "scan source root path is required":
|
|
return "扫描根目录不能为空"
|
|
case strings.HasPrefix(msg, "resolve root path:"):
|
|
return "扫描根目录解析失败:" + strings.TrimSpace(strings.TrimPrefix(msg, "resolve root path:"))
|
|
case len(quoted) == 1 && strings.HasPrefix(msg, `scan source "`) && strings.HasSuffix(msg, `" not found`):
|
|
return fmt.Sprintf("未找到扫描源:%s", quoted[0])
|
|
case len(quoted) == 1 && strings.HasPrefix(msg, `scan source "`) && strings.HasSuffix(msg, `" is disabled`):
|
|
return fmt.Sprintf("扫描源已停用:%s", quoted[0])
|
|
case strings.HasPrefix(msg, "read scan root:"):
|
|
return "读取扫描目录失败:" + strings.TrimSpace(strings.TrimPrefix(msg, "read scan root:"))
|
|
case strings.HasPrefix(msg, "scan root ") && strings.HasSuffix(msg, " is not a directory"):
|
|
return "配置的扫描根路径不是目录:" + strings.TrimSuffix(strings.TrimPrefix(msg, "scan root "), " is not a directory")
|
|
case msg == "target system code is required":
|
|
return "目标系统编码不能为空"
|
|
case msg == "callback url is required":
|
|
return "回调地址不能为空"
|
|
case len(quoted) == 1 && strings.HasPrefix(msg, `target system "`) && strings.HasSuffix(msg, `" not found`):
|
|
return fmt.Sprintf("未找到目标系统:%s", quoted[0])
|
|
case len(quoted) == 1 && strings.HasPrefix(msg, `target system "`) && strings.HasSuffix(msg, `" is disabled`):
|
|
return fmt.Sprintf("目标系统已停用:%s", quoted[0])
|
|
case msg == "token name 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`):
|
|
return fmt.Sprintf("未找到访问令牌:%s", quoted[0])
|
|
case msg == "basemap code and version are required":
|
|
return "底图编码和版本不能为空"
|
|
case len(quoted) == 1 && strings.HasPrefix(msg, `basemap "`) && strings.HasSuffix(msg, `" not found`):
|
|
return fmt.Sprintf("未找到底图资源:%s", quoted[0])
|
|
case len(quoted) == 2 && strings.HasPrefix(msg, `version "`) && strings.Contains(msg, `" not found for basemap "`):
|
|
return fmt.Sprintf("底图资源 %s 未找到版本 %s", quoted[1], quoted[0])
|
|
case len(quoted) == 1 && strings.HasPrefix(msg, `vector asset "`) && strings.HasSuffix(msg, `" not found`):
|
|
return fmt.Sprintf("未找到矢量资源:%s", quoted[0])
|
|
case len(quoted) == 1 && strings.HasPrefix(msg, `vector asset "`) && strings.HasSuffix(msg, `" is not available in requested format`):
|
|
return fmt.Sprintf("矢量资源当前不可按所选格式提供:%s", quoted[0])
|
|
default:
|
|
return msg
|
|
}
|
|
}
|
|
|
|
func quotedSegments(value string) []string {
|
|
parts := strings.Split(value, `"`)
|
|
result := make([]string, 0, len(parts)/2)
|
|
for i := 1; i < len(parts); i += 2 {
|
|
result = append(result, parts[i])
|
|
}
|
|
return result
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
location := "/admin/"
|
|
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)
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, err error) {
|
|
writeJSON(w, status, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|