Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a53bbe743e | |||
| 7b1f49c182 | |||
| 8c8486f9af | |||
| 7d0f4c8065 | |||
| 3d521973ab | |||
| cb8e4bddd8 | |||
| 4cfe4d0ccb | |||
| 366695ffd6 |
@@ -45,7 +45,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RELEASE_KEYSTORE: ${{ secrets.RELEASE_KEYSTORE }}
|
RELEASE_KEYSTORE: ${{ secrets.RELEASE_KEYSTORE }}
|
||||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
run: bash scripts/ci/compile-android.sh "${{ gitea.ref_name }}"
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
run: bash scripts/ci/compile-android.sh "$REF_NAME"
|
||||||
|
|
||||||
- name: Upload android artifact
|
- name: Upload android artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
@@ -65,7 +66,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Compile (Windows installer)
|
- name: Compile (Windows installer)
|
||||||
shell: bash
|
shell: bash
|
||||||
run: bash scripts/ci/compile-windows.sh "${{ gitea.ref_name }}"
|
env:
|
||||||
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
run: bash scripts/ci/compile-windows.sh "$REF_NAME"
|
||||||
|
|
||||||
- name: Upload windows artifact
|
- name: Upload windows artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
@@ -80,38 +83,44 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download android artifact
|
# 一次性下所有 artifact(不带 name),避免同 job 内两次复用 download-artifact
|
||||||
|
# action → act 对其只读缓存 git 仓库做二次操作时 EACCES(pack idx 444)。
|
||||||
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: android
|
path: dist-raw/
|
||||||
path: dist/
|
|
||||||
|
|
||||||
- name: Download windows artifact
|
- name: Flatten artifacts into dist/
|
||||||
uses: actions/download-artifact@v3
|
shell: bash
|
||||||
with:
|
run: |
|
||||||
name: windows
|
mkdir -p dist
|
||||||
path: dist/
|
find dist-raw -type f -exec cp {} dist/ \;
|
||||||
|
echo "dist/ 内容:"; ls -la dist/
|
||||||
|
|
||||||
- name: Release → Forgejo
|
- name: Release → Forgejo
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||||
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
|
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
|
||||||
run: bash scripts/ci/release-client.sh "${{ gitea.ref_name }}"
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
run: bash scripts/ci/release-client.sh "$REF_NAME"
|
||||||
|
|
||||||
- name: Deploy → pangolin1 (downloads/)
|
- name: Deploy → pangolin1 (downloads/)
|
||||||
env:
|
env:
|
||||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
run: bash scripts/ci/deploy-client.sh "${{ gitea.ref_name }}"
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
run: bash scripts/ci/deploy-client.sh "$REF_NAME"
|
||||||
|
|
||||||
- name: Notify
|
- name: Notify
|
||||||
if: always()
|
if: always()
|
||||||
env:
|
env:
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
JOB_STATUS: ${{ job.status }}
|
||||||
run: |
|
run: |
|
||||||
. scripts/ci/notify.sh
|
. scripts/ci/notify.sh
|
||||||
if [ "${{ job.status }}" = "success" ]; then
|
if [ "$JOB_STATUS" = "success" ]; then
|
||||||
notify_ok "client ${{ gitea.ref_name }} released + deployed"
|
notify_ok "client $REF_NAME released + deployed"
|
||||||
else
|
else
|
||||||
notify_fail "client ${{ gitea.ref_name }} pipeline failed"
|
notify_fail "client $REF_NAME pipeline failed"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -109,9 +109,17 @@ ISS="${BUILD_CLIENT}/windows/installer/pangolin.iss"
|
|||||||
sed -i "s/^#define MyAppVersion .*/#define MyAppVersion \"${VER}\"/" "$ISS"
|
sed -i "s/^#define MyAppVersion .*/#define MyAppVersion \"${VER}\"/" "$ISS"
|
||||||
|
|
||||||
echo "==> building installer with Inno Setup (version ${VER})"
|
echo "==> building installer with Inno Setup (version ${VER})"
|
||||||
# MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL disable Git Bash's automatic conversion
|
# ISCC 是原生 Windows 程序,只认 Windows 路径(C:\...);传 MSYS 风格 /c/... 会被它
|
||||||
# of paths, matching jiu's ISCC invocation fix.
|
# 当成选项 → "Unknown option: /c/...pangolin.iss"。故先用 cygpath 把 .iss 转成
|
||||||
MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$ISCC" "$ISS"
|
# Windows 路径再传(jiu 是直接写死 C:\ 字面量;这里用 cygpath 更稳)。
|
||||||
|
# 不用 $() 命令替换(仓库约定):cygpath 输出落临时文件、read 读回。
|
||||||
|
cygpath -w "$ISS" > /tmp/pangolin_iss_win.$$
|
||||||
|
ISS_WIN=""
|
||||||
|
read -r ISS_WIN < /tmp/pangolin_iss_win.$$
|
||||||
|
rm -f /tmp/pangolin_iss_win.$$
|
||||||
|
# MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL 关掉 Git Bash 对 /-开头参数的自动路径转换
|
||||||
|
# (否则会把 .iss 里将来可能的 /D 定义也改坏)。ISS_WIN 已是 Windows 路径,原样传即可。
|
||||||
|
MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$ISCC" "$ISS_WIN"
|
||||||
|
|
||||||
# pangolin.iss has no explicit OutputDir -> Inno defaults to {src}\Output
|
# pangolin.iss has no explicit OutputDir -> Inno defaults to {src}\Output
|
||||||
# (relative to the .iss file), filename OutputBaseFilename=pangolin-setup-<ver>.
|
# (relative to the .iss file), filename OutputBaseFilename=pangolin-setup-<ver>.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,14 @@ import { SITE } from '../config/site';
|
|||||||
interface Props { t: T }
|
interface Props { t: T }
|
||||||
const { t } = Astro.props;
|
const { t } = Astro.props;
|
||||||
|
|
||||||
// href 缺省 = 本轮未接入下载(iOS / macOS / Linux),按钮渲染为禁用态占位。
|
// href 缺省 = 本轮未接入下载(iOS / macOS / Windows / Linux),按钮渲染为禁用态占位。
|
||||||
|
// 本轮仅 Android 有真实产物;Windows 待其 CI(需 windows runner)产出 exe 后接
|
||||||
|
// SITE.downloads.windows(已在 site.ts 备好)。
|
||||||
const plats: { icon: string; name: string; ver: string; href?: string }[] = [
|
const plats: { icon: string; name: string; ver: string; href?: string }[] = [
|
||||||
{ icon: 'smartphone', name: 'iOS', ver: 'iOS 16+' },
|
{ icon: 'smartphone', name: 'iOS', ver: 'iOS 16+' },
|
||||||
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+', href: SITE.downloads.android },
|
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+', href: SITE.downloads.android },
|
||||||
{ icon: 'laptop', name: 'macOS', ver: 'macOS 12+' },
|
{ icon: 'laptop', name: 'macOS', ver: 'macOS 12+' },
|
||||||
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11', href: SITE.downloads.windows },
|
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11' },
|
||||||
{ icon: 'terminal', name: 'Linux', ver: 'deb / rpm' },
|
{ icon: 'terminal', name: 'Linux', ver: 'deb / rpm' },
|
||||||
];
|
];
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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