feat(platform): proxy amap security requests
This commit is contained in:
@@ -6,8 +6,11 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -51,7 +54,10 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
api := platform.NewHandler(platform.NewServiceWithRuntime(store, platform.RuntimeInfo{
|
||||
RequestTimeoutMs: int(cfg.RequestTimeout / time.Millisecond),
|
||||
}))
|
||||
return withRequestTimeout(withAppConfig(static.Handler(cfg.StaticDir, api), cfg), cfg.RequestTimeout)
|
||||
handler := static.Handler(cfg.StaticDir, api)
|
||||
handler = withAppConfig(handler, cfg)
|
||||
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
|
||||
return withRequestTimeout(handler, cfg.RequestTimeout)
|
||||
}
|
||||
|
||||
func withAppConfig(next http.Handler, cfg config.Config) http.Handler {
|
||||
@@ -69,7 +75,7 @@ func withAppConfig(next http.Handler, cfg config.Config) http.Handler {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
body, err := json.Marshal(appConfig{
|
||||
AMapWebJSKey: cfg.AMapWebJSKey,
|
||||
AMapSecurityCode: cfg.AMapSecurityCode,
|
||||
AMapSecurityCode: exposedAMapSecurityCode(cfg),
|
||||
AMapServiceHost: cfg.AMapServiceHost,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -80,6 +86,109 @@ func withAppConfig(next http.Handler, cfg config.Config) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func exposedAMapSecurityCode(cfg config.Config) string {
|
||||
if cfg.AMapServiceHost != "" {
|
||||
return ""
|
||||
}
|
||||
return cfg.AMapSecurityCode
|
||||
}
|
||||
|
||||
type amapProxyUpstreams struct {
|
||||
RestAPI string
|
||||
WebAPI string
|
||||
FMap string
|
||||
}
|
||||
|
||||
func defaultAMapProxyUpstreams() amapProxyUpstreams {
|
||||
return amapProxyUpstreams{
|
||||
RestAPI: "https://restapi.amap.com",
|
||||
WebAPI: "https://webapi.amap.com",
|
||||
FMap: "https://fmap01.amap.com",
|
||||
}
|
||||
}
|
||||
|
||||
func (upstreams amapProxyUpstreams) normalized() amapProxyUpstreams {
|
||||
defaults := defaultAMapProxyUpstreams()
|
||||
if upstreams.RestAPI == "" {
|
||||
upstreams.RestAPI = defaults.RestAPI
|
||||
}
|
||||
if upstreams.WebAPI == "" {
|
||||
upstreams.WebAPI = defaults.WebAPI
|
||||
}
|
||||
if upstreams.FMap == "" {
|
||||
upstreams.FMap = defaults.FMap
|
||||
}
|
||||
upstreams.RestAPI = strings.TrimRight(upstreams.RestAPI, "/")
|
||||
upstreams.WebAPI = strings.TrimRight(upstreams.WebAPI, "/")
|
||||
upstreams.FMap = strings.TrimRight(upstreams.FMap, "/")
|
||||
return upstreams
|
||||
}
|
||||
|
||||
func withAMapSecurityProxy(next http.Handler, cfg config.Config, upstreams amapProxyUpstreams, client *http.Client) http.Handler {
|
||||
serviceHost := strings.TrimRight(cfg.AMapServiceHost, "/")
|
||||
securityCode := strings.TrimSpace(cfg.AMapSecurityCode)
|
||||
if serviceHost == "" || securityCode == "" {
|
||||
return next
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
upstreams = upstreams.normalized()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != serviceHost && !strings.HasPrefix(r.URL.Path, serviceHost+"/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
target, err := amapProxyURL(upstreams, serviceHost, securityCode, r)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "AMAP_PROXY_URL_INVALID", "高德地图代理地址无效", err.Error(), requestTraceID(r))
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "AMAP_PROXY_REQUEST_INVALID", "高德地图代理请求无效", err.Error(), requestTraceID(r))
|
||||
return
|
||||
}
|
||||
req.Header = r.Header.Clone()
|
||||
req.Host = ""
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "AMAP_PROXY_FAILED", "高德地图代理请求失败", err.Error(), requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
})
|
||||
}
|
||||
|
||||
func amapProxyURL(upstreams amapProxyUpstreams, serviceHost string, securityCode string, r *http.Request) (string, error) {
|
||||
targetPath := strings.TrimPrefix(r.URL.Path, serviceHost)
|
||||
if targetPath == "" {
|
||||
targetPath = "/"
|
||||
}
|
||||
upstreamBase := upstreams.RestAPI
|
||||
if strings.HasPrefix(targetPath, "/v4/map/styles") {
|
||||
upstreamBase = upstreams.WebAPI
|
||||
} else if strings.HasPrefix(targetPath, "/v3/vectormap") {
|
||||
upstreamBase = upstreams.FMap
|
||||
}
|
||||
base, err := url.Parse(upstreamBase)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base.Path = strings.TrimRight(base.Path, "/") + targetPath
|
||||
query := r.URL.Query()
|
||||
query.Set("jscode", securityCode)
|
||||
base.RawQuery = query.Encode()
|
||||
return base.String(), nil
|
||||
}
|
||||
|
||||
func withRequestTimeout(next http.Handler, timeout time.Duration) http.Handler {
|
||||
if timeout <= 0 {
|
||||
return next
|
||||
|
||||
@@ -77,9 +77,81 @@ func TestAppConfigScriptIsRuntimeRendered(t *testing.T) {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"window.__LINGNIU_APP_CONFIG__=", `"amapWebJsKey":"web-key"`, `"amapSecurityJsCode":"security-code"`, `"amapSecurityServiceHost":"/_AMapService"`} {
|
||||
for _, want := range []string{"window.__LINGNIU_APP_CONFIG__=", `"amapWebJsKey":"web-key"`, `"amapSecurityServiceHost":"/_AMapService"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("app config script missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "security-code") || strings.Contains(body, "amapSecurityJsCode") {
|
||||
t.Fatalf("app config script should not expose AMap security code: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMapSecurityProxyAppendsServerSideJscode(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotJscode string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotJscode = r.URL.Query().Get("jscode")
|
||||
w.Header().Set("X-AMap-Test", "ok")
|
||||
_, _ = w.Write([]byte("proxied"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
handler := withAMapSecurityProxy(http.NotFoundHandler(), config.Config{
|
||||
AMapSecurityCode: "security-code",
|
||||
AMapServiceHost: "/_AMapService",
|
||||
}, amapProxyUpstreams{RestAPI: upstream.URL}, http.DefaultClient)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/_AMapService/v3/weather/weatherInfo?city=440100", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if gotPath != "/v3/weather/weatherInfo" || gotJscode != "security-code" {
|
||||
t.Fatalf("upstream path=%q jscode=%q", gotPath, gotJscode)
|
||||
}
|
||||
if rec.Body.String() != "proxied" || rec.Header().Get("X-AMap-Test") != "ok" {
|
||||
t.Fatalf("proxy response not copied: headers=%v body=%s", rec.Header(), rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMapSecurityProxyRoutesKnownMapResources(t *testing.T) {
|
||||
seen := map[string]string{}
|
||||
newUpstream := func(name string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen[name] = r.URL.Path
|
||||
_, _ = w.Write([]byte(name))
|
||||
}))
|
||||
}
|
||||
rest := newUpstream("rest")
|
||||
webapi := newUpstream("webapi")
|
||||
fmap := newUpstream("fmap")
|
||||
defer rest.Close()
|
||||
defer webapi.Close()
|
||||
defer fmap.Close()
|
||||
|
||||
handler := withAMapSecurityProxy(http.NotFoundHandler(), config.Config{
|
||||
AMapSecurityCode: "security-code",
|
||||
AMapServiceHost: "/_AMapService",
|
||||
}, amapProxyUpstreams{RestAPI: rest.URL, WebAPI: webapi.URL, FMap: fmap.URL}, http.DefaultClient)
|
||||
|
||||
for _, path := range []string{
|
||||
"/_AMapService/v4/map/styles",
|
||||
"/_AMapService/v3/vectormap",
|
||||
"/_AMapService/v3/weather/weatherInfo",
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
if seen["webapi"] != "/v4/map/styles" || seen["fmap"] != "/v3/vectormap" || seen["rest"] != "/v3/weather/weatherInfo" {
|
||||
t.Fatalf("unexpected proxy routing: %+v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,11 @@ CAPACITY_CHECK_BIN=/opt/lingniu-go-native/current/capacity-check
|
||||
AUTH_TOKEN=***
|
||||
REQUEST_TIMEOUT_MS=5000
|
||||
AMAP_WEB_JS_KEY=***
|
||||
AMAP_SECURITY_JS_CODE=***
|
||||
AMAP_SECURITY_SERVICE_HOST=/_AMapService
|
||||
```
|
||||
|
||||
`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. Prefer `AMAP_SECURITY_SERVICE_HOST` with a server-side proxy for production. `AMAP_SECURITY_JS_CODE` is also supported for debugging or controlled internal deployments, but it is exposed to the browser by design and should not be committed.
|
||||
`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. For production, set both `AMAP_SECURITY_JS_CODE` and `AMAP_SECURITY_SERVICE_HOST=/_AMapService`; the API service keeps the security code on the server and proxies AMap requests with `jscode` appended. Only omit `AMAP_SECURITY_SERVICE_HOST` for controlled debugging where exposing `AMAP_SECURITY_JS_CODE` to the browser is acceptable.
|
||||
|
||||
## Health
|
||||
|
||||
|
||||
Reference in New Issue
Block a user