Files
map-asset-gateway/services/api-go/internal/basemapapi/server.go
T
Harmon 46d83145d9 Keep the admin surface local while serving tiles on the LAN
Binding to 0.0.0.0 used to demand credentials because the admin console and
the tile routes shared one listener. That forced a choice between keeping the
console off the network and letting other machines fetch tiles.

Admin routes are now loopback-only by default: the service can bind 0.0.0.0 so
/tiles, /vectors, the catalog and the probes reach the LAN, while /admin and
/api/v1/admin answer 403 to anything arriving from another host. Credentials
are only required when ADMIN_ALLOW_REMOTE=true actually opens the console up,
which is the case worth guarding.

Tests cover both sides: a remote admin request is refused, a remote tile
request still reaches the token check.
2026-10-16 10:41:32 +08:00

1373 lines
45 KiB
Go

package basemapapi
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"net"
"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
// AllowRemote permits admin requests from other hosts. Off by default: the
// service can then serve tiles to the LAN while the console stays local.
AllowRemote bool
}
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.AllowRemote && !isLoopbackRequest(r) {
writeError(w, http.StatusForbidden, errors.New("admin endpoints are loopback-only; set ADMIN_ALLOW_REMOTE=true to accept remote callers"))
return
}
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)
}
}
// isLoopbackRequest reports whether the request reached us over a loopback
// address. RemoteAddr is the only trustworthy source here; X-Forwarded-For is
// attacker-controlled unless a trusted proxy is in front.
func isLoopbackRequest(r *http.Request) bool {
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err != nil {
host = strings.TrimSpace(r.RemoteAddr)
}
ip := net.ParseIP(strings.Trim(host, "[]"))
if ip == nil {
return false
}
return ip.IsLoopback()
}
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(),
})
}