Commit 0847eefd authored by xuchentao's avatar xuchentao

feat: add CMS captcha and preview deployment config

parent 5dbda737
Pipeline #423 failed with stages
in 5 seconds
# 上线前请替换为真实域名与高强度随机密钥
SITE_URL=https://www.jiayunche.com
# 上线前请替换为高强度随机密钥
CMS_PORT=8790
CMS_PASSWORD=change-this-password
CMS_SECRET=replace-with-at-least-32-random-characters
......
......@@ -18,11 +18,11 @@ variables:
SHARED_ENV: "/root/jiayun/shared/.env"
APP_NAME: "jiayun"
PORT: "8790"
SITE_URL: "http://127.0.0.1:8790/"
HEALTHCHECK_URL: "http://127.0.0.1:8790/"
PM2_BIN: "/usr/bin/pm2"
PM2_HOME_DIR: "/root/.pm2"
PM2_USE_SUDO: "1"
PUBLIC_HOST: "101.126.10.129"
PUBLIC_HOST: "jiayun.101-126-10-129.sslip.io"
stages:
- deploy
......@@ -61,7 +61,7 @@ deploy:
current_target=$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || true)
if [ "$current_target" = "$RELEASE_DIR" ] && [ -f "$RELEASE_DIR/.deploy-success" ]; then
if [ -d "$RELEASE_DIR/node_modules" ] && curl --fail --silent --show-error --max-time 5 "$SITE_URL" >/dev/null; then
if [ -d "$RELEASE_DIR/node_modules" ] && curl --fail --silent --show-error --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
echo "[deploy] 该 pipeline 已部署且服务正常,无需重复执行"
exit 0
fi
......@@ -110,7 +110,7 @@ deploy:
du -sh node_modules
echo "[deploy] 等待 CMS 共享构建锁"
node --env-file="$SHARED_ENV" scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh "$DEPLOY_ROOT" "$RELEASE_DIR" "$APP_NAME" "$PM2_BIN" "$PM2_HOME_DIR" "$PM2_USE_SUDO" "$SITE_URL" "$CI_PIPELINE_ID"
node --env-file="$SHARED_ENV" scripts/run-with-site-lock.mjs -- bash scripts/deploy-release.sh "$DEPLOY_ROOT" "$RELEASE_DIR" "$APP_NAME" "$PM2_BIN" "$PM2_HOME_DIR" "$PM2_USE_SUDO" "$HEALTHCHECK_URL" "$CI_PIPELINE_ID"
echo "[deploy] 当前版本:$(readlink -f "$DEPLOY_ROOT/current")"
# ── 2. 安装并验证 Nginx 配置 ─────────────────────────
......@@ -147,8 +147,8 @@ verify:
verify_status=0
current_target=$(readlink -f "$DEPLOY_ROOT/current" 2>/dev/null || true)
if [ "$current_target" = "$RELEASE_DIR" ]; then
if curl --fail --silent --show-error --max-time 5 "$SITE_URL" >/dev/null; then
echo "[verify] 服务运行正常:$SITE_URL"
if curl --fail --silent --show-error --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
echo "[verify] 服务运行正常:$HEALTHCHECK_URL"
else
echo "[verify] 服务无响应,输出本项目最近日志"
sudo -n env PM2_HOME="$PM2_HOME_DIR" "$PM2_BIN" logs "$APP_NAME" --lines 30 --nostream || true
......@@ -164,4 +164,3 @@ verify:
fi
bash "$CI_PROJECT_DIR/scripts/cleanup-releases.sh" "$DEPLOY_ROOT" "$RELEASE_DIR"
exit "$verify_status"
......@@ -37,7 +37,6 @@ npm start
## 必需环境变量
```dotenv
SITE_URL=https://你的正式域名
CMS_PORT=8790
CMS_PASSWORD=高强度后台密码
CMS_SECRET=至少32位随机字符串
......@@ -49,6 +48,6 @@ CMS_BUILD_LOCK=/root/jiayun/shared/site-build.lock
## SEO 与部署
项目包含 canonical、Open Graph、Organization/LocalBusiness/Service/FAQ/Article 结构化数据、sitemap、robots.txt、llms.txt 与 web manifest。修改正式域名时,同时更新 `SITE_URL` `public/robots.txt` 中的 Sitemap。
项目包含 canonical、Open Graph、Organization/LocalBusiness/Service/FAQ/Article 结构化数据、sitemap、robots.txt、llms.txt 与 web manifest。正式域名配置在 `astro.config.mjs` 中;修改时需同时更新 `public/robots.txt` 中的 Sitemap。
`scripts/` 保留了参考项目的共享构建锁、原子替换、release 部署、健康检查与自动回退机制;`deploy/nginx/jiayun.conf` 是反向代理示例。GitLab CI 使用 release 目录部署,需按真实服务器调整 Runner 标签、域名/IP 与目录。
......@@ -6,7 +6,7 @@ import path from "node:path";
const customOutDir = process.env.JIAYUN_BUILD_OUT_DIR;
export default defineConfig({
site: process.env.SITE_URL || "https://www.jiayunche.com",
site: "http://jiayun.101-126-10-129.sslip.io",
integrations: [sitemap()],
adapter: node({ mode: "standalone" }),
...(customOutDir ? { outDir: path.resolve(customOutDir) } : {}),
......
# 嘉运网约车官网 Nginx 配置示例
# 嘉运网约车官网 Nginx 配置
# 通过 sslip.io 临时预览域名访问,反向代理到本机 8790 端口。
server {
listen 80;
server_name _;
client_max_body_size 12m;
server_name jiayun.101-126-10-129.sslip.io;
# 图片上限为 20MB,JSON 中的 Base64 数据会额外增大请求体。
client_max_body_size 28m;
location / {
proxy_pass http://127.0.0.1:8790;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
......
......@@ -22,6 +22,17 @@ async function api(path, options = {}) {
const show = (selector) => $(selector).classList.remove("hidden");
const hide = (selector) => $(selector).classList.add("hidden");
const refreshCaptcha = () => {
$("#captcha-image").src = `${API_BASE}/captcha?t=${Date.now()}`;
$("#captcha").value = "";
};
function showLogin() {
show("#login");
hide("#app");
refreshCaptcha();
$("#password").focus();
}
async function boot() {
const session = await api("/session").catch(() => ({ authed: false }));
......@@ -30,19 +41,25 @@ async function boot() {
await loadCategories();
loadArticles();
}
else { show("#login"); hide("#app"); }
else showLogin();
}
$("#captcha-image").addEventListener("click", refreshCaptcha);
$("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
$("#login-error").textContent = "";
try {
await api("/session", { method: "POST", body: { password: $("#password").value } });
await api("/session", { method: "POST", body: { password: $("#password").value, captcha: $("#captcha").value } });
$("#password").value = "";
$("#captcha").value = "";
hide("#login"); show("#app");
await loadCategories();
loadArticles();
} catch (error) { $("#login-error").textContent = error.message; }
} catch (error) {
$("#login-error").textContent = error.message;
refreshCaptcha();
}
});
$("#logout").addEventListener("click", async () => { await api("/session", { method: "DELETE" }); location.reload(); });
......
......@@ -28,6 +28,9 @@ button, .button { min-height: 40px; padding: 0 17px; display: inline-flex; align
.login-card h1 { margin: 0; font-size: 30px; }
.login-card p { margin: -8px 0 4px; color: var(--muted); }
.login-card label, .field-panel label, .field-label { display: grid; gap: 7px; color: var(--muted); font-size: 13px; }
.captcha-row { display: flex; align-items: stretch; gap: 10px; }
.captcha-row input { min-width: 0; flex: 1; text-transform: uppercase; }
#captcha-image { width: 132px; height: 46px; flex: 0 0 auto; cursor: pointer; background: var(--light); border: 1px solid var(--border); border-radius: 9px; }
input, textarea, select { width: 100%; padding: 11px 13px; color: var(--text); background: white; border: 1px solid var(--border); border-radius: 9px; }
input:focus, textarea:focus, select:focus { outline: 2px solid rgba(255,125,65,.16); border-color: var(--primary); }
.topbar { min-height: 68px; padding: 12px 28px; position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 22px; color: white; background: #101413; }
......
......@@ -14,4 +14,4 @@ Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: https://www.jiayunche.com/sitemap-index.xml
Sitemap: http://jiayun.101-126-10-129.sslip.io/sitemap-index.xml
......@@ -25,6 +25,8 @@ import { buildSiteAtomic, tryAcquireSiteBuildLock, withSiteBuildLock } from "../
const PASSWORD = process.env.CMS_PASSWORD || "";
const SECRET = process.env.CMS_SECRET || crypto.randomBytes(32).toString("hex");
const API_KEY = process.env.CMS_API_KEY || "";
const CAPTCHA_TTL = 5 * 60 * 1000;
const CAPTCHA_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const loginAttempts = new Map<string, { count: number; until: number }>();
type BuildState = {
status: "idle" | "building" | "success" | "error";
......@@ -66,10 +68,10 @@ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
try { return await request.json(); } catch { return {}; }
}
function cookieHeader(request: Request, value: string, maxAge: number): string {
function cookieHeader(request: Request, name: string, value: string, maxAge: number): string {
const forwarded = request.headers.get("x-forwarded-proto");
const secure = new URL(request.url).protocol === "https:" || forwarded === "https";
return `cms_session=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
return `${name}=${value}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
}
function loginAllowed(ip: string): boolean {
......@@ -84,6 +86,51 @@ function loginFailed(ip: string): void {
loginAttempts.set(ip, state);
}
function randomCaptcha(length = 4): string {
let code = "";
for (let index = 0; index < length; index += 1) {
code += CAPTCHA_CHARS[crypto.randomInt(CAPTCHA_CHARS.length)];
}
return code;
}
function captchaMac(code: unknown, expiresAt: number): string {
return crypto.createHmac("sha256", SECRET)
.update(`${String(code).toUpperCase()}|${expiresAt}`)
.digest("hex");
}
function captchaValid(request: Request, answer: unknown): boolean {
const value = cookies(request).cms_captcha;
if (!value || !answer) return false;
const separator = value.lastIndexOf(".");
const expiresAt = Number(value.slice(0, separator));
const mac = value.slice(separator + 1);
return Boolean(expiresAt && Date.now() <= expiresAt && safeEqual(mac, captchaMac(answer, expiresAt)));
}
function captchaSvg(code: string): string {
const width = 132;
const height = 46;
const colors = ["#ff7d41", "#e96a32", "#c95320", "#231916"];
const randomBetween = (min: number, max: number) => crypto.randomInt(min, max + 1);
const pick = <T>(items: T[]): T => items[crypto.randomInt(items.length)];
let content = `<rect width="${width}" height="${height}" fill="#fff1e9"/>`;
for (let index = 0; index < 4; index += 1) {
content += `<line x1="${randomBetween(0, width)}" y1="${randomBetween(0, height)}" x2="${randomBetween(0, width)}" y2="${randomBetween(0, height)}" stroke="${pick(colors)}" stroke-width="1" opacity="0.45"/>`;
}
[...code].forEach((character, index) => {
const x = 16 + index * 28 + randomBetween(-3, 3);
const y = 31 + randomBetween(-4, 4);
const rotation = randomBetween(-26, 26);
content += `<text x="${x}" y="${y}" font-family="Georgia,serif" font-weight="700" font-size="${randomBetween(24, 30)}" fill="${pick(colors)}" transform="rotate(${rotation} ${x} ${y})">${character}</text>`;
});
for (let index = 0; index < 26; index += 1) {
content += `<circle cx="${randomBetween(0, width)}" cy="${randomBetween(0, height)}" r="1" fill="${pick(colors)}" opacity="0.5"/>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${content}</svg>`;
}
function requireSlug(value: string | undefined): string {
const slug = safeSlug(value);
if (!slug) throw new StoreError("网址标识不合法");
......@@ -141,20 +188,39 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
const method = request.method.toUpperCase();
try {
if (route === "captcha" && method === "GET") {
const code = randomCaptcha();
const expiresAt = Date.now() + CAPTCHA_TTL;
return new Response(captchaSvg(code), {
headers: {
"Cache-Control": "no-store",
"Content-Type": "image/svg+xml; charset=utf-8",
"Set-Cookie": cookieHeader(request, "cms_captcha", `${expiresAt}.${captchaMac(code, expiresAt)}`, CAPTCHA_TTL / 1000),
},
});
}
if (route === "session") {
if (method === "GET") return json({ authed: authed(request) });
if (method === "POST") {
if (!PASSWORD) throw new StoreError("资讯中心后台尚未配置管理密码", 503);
if (!loginAllowed(clientAddress)) throw new StoreError("登录尝试过于频繁,请稍后再试", 429);
const body = await bodyOf(request);
const clearCaptcha = cookieHeader(request, "cms_captcha", "", 0);
if (!captchaValid(request, body.captcha)) {
return json({ error: "验证码错误或已过期" }, 400, { "Set-Cookie": clearCaptcha });
}
if (!safeEqual(body.password || "", PASSWORD)) {
loginFailed(clientAddress);
throw new StoreError("管理密码错误", 401);
return json({ error: "管理密码错误" }, 401, { "Set-Cookie": clearCaptcha });
}
loginAttempts.delete(clientAddress);
return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, sessionToken(), 86400) });
const headers = new Headers();
headers.append("Set-Cookie", clearCaptcha);
headers.append("Set-Cookie", cookieHeader(request, "cms_session", sessionToken(), 86400));
return json({ ok: true }, 200, headers);
}
if (method === "DELETE") return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, "", 0) });
if (method === "DELETE") return json({ ok: true }, 200, { "Set-Cookie": cookieHeader(request, "cms_session", "", 0) });
}
if (!authed(request)) throw new StoreError("请先登录资讯中心后台", 401);
......
......@@ -12,8 +12,13 @@
<form id="login-form" class="login-card">
<div class="brand-mark">嘉运网约车</div>
<h1>资讯中心后台</h1>
<p>输入管理密码,管理官网资讯内容。</p>
<p>输入管理密码和图片验证码,管理官网资讯内容。</p>
<label>管理密码<input id="password" type="password" autocomplete="current-password" required /></label>
<label>图片验证码</label>
<div class="captcha-row">
<input id="captcha" autocomplete="off" maxlength="4" inputmode="text" aria-label="图片验证码" required />
<img id="captcha-image" alt="图片验证码,点击可更换" title="看不清?点击换一张" />
</div>
<button class="primary" type="submit">登录后台</button>
<div id="login-error" class="error"></div>
</form>
......
......@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
import { site } from "../data/site";
export const prerender = true;
export const GET: APIRoute = ({ site: siteUrl }) => {
const base = (siteUrl?.href || "https://www.jiayunche.com/").replace(/\/$/, "");
const base = (siteUrl?.href || "http://jiayun.101-126-10-129.sslip.io/").replace(/\/$/, "");
const body = `# ${site.brand.name}\n\n> ${site.brand.tagline}\n\n${site.home.subhead}\n\n## 核心服务\n${site.services.map((item) => `- ${item.name}${item.intro}`).join("\n")}\n\n## 联系方式\n- 电话:${site.contact.phone}\n- 地址:${site.contact.address}\n- 服务时间:${site.contact.serviceHours}\n\n## 重要页面\n- 首页:${base}/\n- 服务方案:${base}/services/\n- 司机招募:${base}/join/\n- 资讯中心:${base}/articles/\n- 关于嘉运:${base}/about/\n- 常见问题:${base}/faq/\n- 联系我们:${base}/contact/\n\n## 信息边界\n- 平台规则、准入条件、车型价格与优惠政策以实时信息为准。\n- 嘉运不对网约车营收作绝对承诺。\n`;
return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment