Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d521973ab | |||
| cb8e4bddd8 |
@@ -123,6 +123,8 @@ func main() {
|
|||||||
r.Use(chimw.Logger)
|
r.Use(chimw.Logger)
|
||||||
r.Use(chimw.Recoverer)
|
r.Use(chimw.Recoverer)
|
||||||
r.Use(apierr.Middleware)
|
r.Use(apierr.Middleware)
|
||||||
|
// CORS:Web 用户中心(app.yanmeiai.com)跨域调 /v1/*;原生端不受影响。
|
||||||
|
r.Use(httpapi.NewCORS())
|
||||||
|
|
||||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewCORS 返回一个 CORS 中间件:为白名单 Origin(精确匹配)补 CORS 响应头,并直接
|
||||||
|
// 应答 OPTIONS 预检。Web 用户中心(app.yanmeiai.com,浏览器)跨域调用 /v1/*,浏览器
|
||||||
|
// 会先发预检、并校验 Access-Control-Allow-Origin;原生移动/桌面客户端不是浏览器、
|
||||||
|
// 不受 CORS 约束,故此前无需 CORS。
|
||||||
|
//
|
||||||
|
// Origin 白名单来自 CORS_ORIGINS(逗号分隔),缺省含 usercenter 的正式域与 pages.dev。
|
||||||
|
// 认证走 Authorization: Bearer(非 cookie),所以不需要 Allow-Credentials。
|
||||||
|
func NewCORS() func(http.Handler) http.Handler {
|
||||||
|
raw := os.Getenv("CORS_ORIGINS")
|
||||||
|
if raw == "" {
|
||||||
|
raw = "https://app.yanmeiai.com,https://pangolin-usercenter.pages.dev"
|
||||||
|
}
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, o := range strings.Split(raw, ",") {
|
||||||
|
o = strings.TrimSpace(o)
|
||||||
|
if o != "" {
|
||||||
|
allowed[o] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin != "" && allowed[origin] {
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Access-Control-Allow-Origin", origin)
|
||||||
|
h.Add("Vary", "Origin")
|
||||||
|
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
h.Set("Access-Control-Max-Age", "600")
|
||||||
|
}
|
||||||
|
// 预检请求直接 204(不落到业务路由,避免 /v1/... 的 OPTIONS 404)。
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCORS(t *testing.T) {
|
||||||
|
t.Setenv("CORS_ORIGINS", "https://app.yanmeiai.com")
|
||||||
|
mw := NewCORS()
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
|
||||||
|
h := mw(next)
|
||||||
|
|
||||||
|
// 允许的 Origin:补 Allow-Origin,普通请求继续。
|
||||||
|
r := httptest.NewRequest("POST", "/v1/auth/login", nil)
|
||||||
|
r.Header.Set("Origin", "https://app.yanmeiai.com")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.yanmeiai.com" {
|
||||||
|
t.Fatalf("allowed origin: want header, got %q", got)
|
||||||
|
}
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("non-preflight should reach next, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPTIONS 预检:204,不落到业务。
|
||||||
|
r = httptest.NewRequest("OPTIONS", "/v1/auth/login", nil)
|
||||||
|
r.Header.Set("Origin", "https://app.yanmeiai.com")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("preflight: want 204, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if w.Header().Get("Access-Control-Allow-Methods") == "" {
|
||||||
|
t.Fatalf("preflight missing Allow-Methods")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未白名单 Origin:不补 Allow-Origin。
|
||||||
|
r = httptest.NewRequest("POST", "/v1/auth/login", nil)
|
||||||
|
r.Header.Set("Origin", "https://evil.example.com")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Fatalf("disallowed origin should get no header, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Download, Menu } from 'lucide-react';
|
import { Download, Menu } from 'lucide-react';
|
||||||
|
import { SITE } from '../config/site';
|
||||||
|
|
||||||
function Mark() {
|
function Mark() {
|
||||||
return (
|
return (
|
||||||
@@ -60,8 +61,7 @@ export default function Header({ lang = 'zh', t = {} }) {
|
|||||||
<a data-lang="zh" class={lang === 'zh' ? 'on' : undefined} href="/">中文</a>
|
<a data-lang="zh" class={lang === 'zh' ? 'on' : undefined} href="/">中文</a>
|
||||||
<a data-lang="en" class={lang === 'en' ? 'on' : undefined} href="/en/">EN</a>
|
<a data-lang="en" class={lang === 'en' ? 'on' : undefined} href="/en/">EN</a>
|
||||||
</div>
|
</div>
|
||||||
{/* 「登录」暂移除:Web 用户中心(web/usercenter)未部署、无 URL;待 #30 CI/CD
|
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
|
||||||
部署后接上 usercenter 登录地址再恢复。 */}
|
|
||||||
<a class="btn btn-primary" href="#download">
|
<a class="btn btn-primary" href="#download">
|
||||||
<Download />
|
<Download />
|
||||||
<span>{t.get}</span>
|
<span>{t.get}</span>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const SITE = {
|
|||||||
/** 自助发卡商店 —— 占位待定(#24)。 */
|
/** 自助发卡商店 —— 占位待定(#24)。 */
|
||||||
store: { label: 'shop.pangolin.vpn' },
|
store: { label: 'shop.pangolin.vpn' },
|
||||||
|
|
||||||
|
/** Web 用户中心(web/usercenter,Next.js 静态导出 → CF Pages)。登录/订阅/设备管理入口。 */
|
||||||
|
usercenter: 'https://app.yanmeiai.com',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露
|
* 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露
|
||||||
* /downloads/<file>(origin = 127.0.0.1:8080),文件由
|
* /downloads/<file>(origin = 127.0.0.1:8080),文件由
|
||||||
|
|||||||
Reference in New Issue
Block a user