Files
map-asset-gateway/services/api-go/internal/basemapapi/server.go
T
Harmon 0a2ecef667 Add a read_all scope and explicit permanent tokens
An integration token used to be bound to the resource list that existed when it
was minted, so every new basemap needed a fresh token. read_all grants read on
every basemap and vector, including ones catalogued later, which is what an
internal consumer of the whole catalogue actually wants.

Tokens also gain a way to opt out of the default lifetime: expires_at accepts
never (or 0) in the API and the console, producing a token with no expiry.

The console shows blanket access as a badge instead of an empty grant list, and
the token form exposes both new options.
2026-10-16 12:19:36 +08:00

1390 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, ", ")
},
"hasScope": func(scopes []string, want string) bool {
for _, scope := range scopes {
if scope == want {
return true
}
}
return false
},
"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,
}
switch strings.ToLower(strings.TrimSpace(payload.ExpiresAt)) {
case "never", "0":
input.NoExpiry = true
case "":
default:
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 != "" {
switch strings.ToLower(value) {
case "never", "0":
input.NoExpiry = true
default:
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(),
})
}