diff --git a/.env.example b/.env.example
index 02710b4..bc822d3 100644
--- a/.env.example
+++ b/.env.example
@@ -54,3 +54,15 @@ DEV_AUTO_PAY=true
# 视频会议 / 群聊
NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn
NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn
+
+# 邮件通讯(Listmonk,Go 轻量服务)
+NEXT_PUBLIC_LISTMONK_URL=https://listmonk.nomadro.cn
+LISTMONK_API_URL=https://listmonk.nomadro.cn
+LISTMONK_API_KEY=
+LISTMONK_DEFAULT_LIST_IDS=1
+
+# 手机推送(ntfy,Go 轻量服务)
+NEXT_PUBLIC_NTFY_URL=https://ntfy.nomadro.cn
+NTFY_URL=https://ntfy.nomadro.cn
+NTFY_ENABLED=true
+NTFY_AUTH_TOKEN=
diff --git a/app/[locale]/city/[slug]/page.tsx b/app/[locale]/city/[slug]/page.tsx
index f337fd6..074c489 100644
--- a/app/[locale]/city/[slug]/page.tsx
+++ b/app/[locale]/city/[slug]/page.tsx
@@ -3,6 +3,7 @@
import { use, useEffect, useMemo, useState } from "react";
import { Link } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
+import NewsletterSubscribe from "@/app/components/NewsletterSubscribe";
import { apiFetch } from "@/app/lib/api-client";
interface CityGuide {
@@ -377,6 +378,8 @@ export default function CityGuidePage({ params }: { params: Promise<{ slug: stri
+
+
+
+
{c.countPrefix}
{displayedMeetups.length}
diff --git a/app/[locale]/settings/notifications/page.tsx b/app/[locale]/settings/notifications/page.tsx
index de77a5c..46fb732 100644
--- a/app/[locale]/settings/notifications/page.tsx
+++ b/app/[locale]/settings/notifications/page.tsx
@@ -15,7 +15,7 @@ interface PreferenceRecord {
}
const DEFAULT_PREFERENCES: PreferenceRecord = {
- channels: { in_app: true, email: false, wechat: false },
+ channels: { in_app: true, push: false, email: false, wechat: false },
categories: {
system: true,
meetup: true,
@@ -33,6 +33,7 @@ const DEFAULT_PREFERENCES: PreferenceRecord = {
const CHANNELS = [
["in_app", "站内消息", "始终建议开启,所有业务通知都会进入消息中心。"],
+ ["push", "手机推送 (ntfy)", "通过 ntfy 推送到手机;开启后请用下方订阅链接在 ntfy App 中订阅。"],
["email", "邮件摘要", "适合离线时收到每日/每周汇总。"],
["wechat", "微信提醒", "后续可接企业微信、公众号或机器人。"],
];
@@ -50,11 +51,15 @@ const CATEGORIES = [
export default function NotificationSettingsPage() {
const [prefs, setPrefs] = useState(DEFAULT_PREFERENCES);
const [saved, setSaved] = useState("");
+ const [pushSubscribeUrl, setPushSubscribeUrl] = useState("");
useEffect(() => {
apiFetch<{ record: PreferenceRecord }>("/api/notifications/preferences")
.then((data) => setPrefs({ ...DEFAULT_PREFERENCES, ...(data.record || {}) }))
.catch(() => setPrefs(DEFAULT_PREFERENCES));
+ apiFetch<{ subscribeUrl?: string }>("/api/notifications/push-config")
+ .then((data) => setPushSubscribeUrl(data.subscribeUrl || ""))
+ .catch(() => setPushSubscribeUrl(""));
}, []);
const save = async () => {
@@ -110,6 +115,22 @@ export default function NotificationSettingsPage() {
))}
+ {prefs.channels.push && pushSubscribeUrl ? (
+
+ ) : null}
diff --git a/app/components/NewsletterSubscribe.tsx b/app/components/NewsletterSubscribe.tsx
new file mode 100644
index 0000000..f942eae
--- /dev/null
+++ b/app/components/NewsletterSubscribe.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useState } from "react";
+import { apiFetch } from "@/app/lib/api-client";
+
+interface NewsletterSubscribeProps {
+ locale: string;
+ citySlug?: string;
+ list?: "city" | "meetup" | "general";
+ className?: string;
+}
+
+const copy = {
+ zh: {
+ title: "订阅城市与活动通讯",
+ hint: "通过 Listmonk 接收新活动、城市更新(可随时退订)",
+ email: "邮箱地址",
+ name: "称呼(可选)",
+ submit: "订阅",
+ success: "订阅成功,请查收确认邮件",
+ error: "订阅失败,请稍后再试",
+ },
+ en: {
+ title: "Subscribe to city & event updates",
+ hint: "Get meetup and city news via Listmonk (unsubscribe anytime)",
+ email: "Email",
+ name: "Name (optional)",
+ submit: "Subscribe",
+ success: "Subscribed — check your inbox to confirm",
+ error: "Subscription failed, please try again",
+ },
+};
+
+export default function NewsletterSubscribe({
+ locale,
+ citySlug = "",
+ list = "general",
+ className = "",
+}: NewsletterSubscribeProps) {
+ const c = locale === "zh" ? copy.zh : copy.en;
+ const [email, setEmail] = useState("");
+ const [name, setName] = useState("");
+ const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!email.trim()) return;
+ setStatus("loading");
+ try {
+ await apiFetch("/api/newsletter/subscribe", {
+ method: "POST",
+ body: JSON.stringify({ email, name, citySlug, list }),
+ });
+ setStatus("success");
+ setEmail("");
+ setName("");
+ } catch {
+ setStatus("error");
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/app/lib/listmonk.ts b/app/lib/listmonk.ts
new file mode 100644
index 0000000..a1ab0a8
--- /dev/null
+++ b/app/lib/listmonk.ts
@@ -0,0 +1,6 @@
+export const DEFAULT_LISTMONK_URL = "https://listmonk.nomadro.cn";
+export const LISTMONK_PROVIDER_LABEL = "Listmonk";
+
+export function getListmonkUrl(): string {
+ return (process.env.NEXT_PUBLIC_LISTMONK_URL || DEFAULT_LISTMONK_URL).replace(/\/$/, "");
+}
diff --git a/app/lib/meetups.ts b/app/lib/meetups.ts
index abb9d27..edfdf99 100644
--- a/app/lib/meetups.ts
+++ b/app/lib/meetups.ts
@@ -39,6 +39,7 @@ export interface MeetupSession {
displayName: string;
videoUrl: string;
chatUrl: string;
+ replayUrl?: string;
loungeChannel: string;
mirotalkRoom: string;
meetingProvider: string;
diff --git a/app/lib/ntfy.ts b/app/lib/ntfy.ts
new file mode 100644
index 0000000..c106f70
--- /dev/null
+++ b/app/lib/ntfy.ts
@@ -0,0 +1,5 @@
+export const DEFAULT_NTFY_URL = "https://ntfy.nomadro.cn";
+
+export function getNtfyBaseUrl(): string {
+ return (process.env.NEXT_PUBLIC_NTFY_URL || DEFAULT_NTFY_URL).replace(/\/$/, "");
+}
diff --git a/backend/integrations.py b/backend/integrations.py
new file mode 100644
index 0000000..83a39fe
--- /dev/null
+++ b/backend/integrations.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from typing import Any
+
+import httpx
+
+from .settings import get_settings
+
+
+async def subscribe_newsletter(
+ *,
+ email: str,
+ name: str = "",
+ list_ids: list[int] | None = None,
+ city_slug: str = "",
+) -> dict[str, Any]:
+ settings = get_settings()
+ if not settings.listmonk_api_key:
+ return {"ok": False, "error": "newsletter_not_configured"}
+
+ payload: dict[str, Any] = {
+ "email": email.strip(),
+ "name": name.strip() or email.split("@")[0],
+ "status": "enabled",
+ "lists": list_ids or settings.listmonk_default_list_ids,
+ "preconfirm_subscriptions": True,
+ }
+ if city_slug:
+ payload["attribs"] = {"city": city_slug}
+
+ headers = {"Authorization": f"token {settings.listmonk_api_key}"}
+ async with httpx.AsyncClient(timeout=20.0) as client:
+ response = await client.post(
+ f"{settings.listmonk_api_url.rstrip('/')}/api/subscribers",
+ json=payload,
+ headers=headers,
+ )
+ if response.status_code >= 400:
+ return {"ok": False, "error": response.text[:300]}
+ data = response.json()
+ return {"ok": True, "subscriber": data.get("data")}
diff --git a/backend/main.py b/backend/main.py
index ec29219..287cba6 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -20,6 +20,8 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from .avatar_assets import avatar_for_name
+from .integrations import subscribe_newsletter
+from .ntfy_push import dispatch_ntfy_for_user, ntfy_subscribe_url, ntfy_topic_for_user
from .meetup_live import (
access_lock_reason,
build_lounge_chat_url,
@@ -756,7 +758,7 @@ def default_notification_preferences(user_id: str, email: str = "") -> dict[str,
return {
"userId": user_id,
"email": email,
- "channels": {"in_app": True, "email": False, "wechat": False},
+ "channels": {"in_app": True, "push": False, "email": False, "wechat": False},
"categories": {
"system": True,
"meetup": True,
@@ -818,7 +820,7 @@ async def create_notification(
metadata: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
try:
- return await pb.create_record(
+ record = await pb.create_record(
"notifications",
{
"title": title,
@@ -839,6 +841,22 @@ async def create_notification(
)
except PocketBaseError:
return None
+ if target_user_id:
+ try:
+ prefs = await notification_preferences_for_user(target_user_id)
+ await dispatch_ntfy_for_user(
+ user_id=target_user_id,
+ preferences=prefs,
+ title=title,
+ body=body,
+ category=category,
+ priority=priority,
+ action_url=action_url,
+ icon=icon,
+ )
+ except Exception:
+ pass
+ return record
async def receipt_for(notification_id: str, user_id: str) -> dict[str, Any] | None:
@@ -1587,6 +1605,20 @@ async def mark_all_notifications_read(request: Request) -> dict[str, Any]:
return {"ok": True, "count": count}
+@app.get("/api/notifications/push-config")
+async def notification_push_config(request: Request) -> dict[str, Any]:
+ _, user = await current_user(request)
+ if not user:
+ raise HTTPException(status_code=401, detail="请先登录")
+ user_id = user["id"]
+ return {
+ "ok": True,
+ "topic": ntfy_topic_for_user(user_id),
+ "subscribeUrl": ntfy_subscribe_url(user_id),
+ "serverUrl": settings.ntfy_url,
+ }
+
+
@app.get("/api/notifications/preferences")
async def notification_preferences(request: Request) -> dict[str, Any]:
_, user = await current_user(request)
@@ -1819,6 +1851,28 @@ async def get_meetup(meetup_id: str) -> dict[str, Any]:
return {"ok": True, "item": public_record(record)}
+class NewsletterSubscribePayload(BaseModel):
+ email: str
+ name: str = ""
+ citySlug: str = ""
+ list: str = "general"
+
+
+@app.post("/api/newsletter/subscribe")
+async def newsletter_subscribe(payload: NewsletterSubscribePayload) -> dict[str, Any]:
+ email = payload.email.strip()
+ if not email or "@" not in email:
+ raise HTTPException(status_code=400, detail="邮箱格式不正确")
+ result = await subscribe_newsletter(
+ email=email,
+ name=payload.name,
+ city_slug=payload.citySlug.strip(),
+ )
+ if not result.get("ok"):
+ raise HTTPException(status_code=502, detail=result.get("error") or "订阅失败")
+ return {"ok": True, "subscriber": result.get("subscriber")}
+
+
@app.get("/api/meetups/{meetup_id}/session")
async def meetup_session(meetup_id: str, request: Request) -> dict[str, Any]:
record = await fetch_meetup_record(meetup_id)
@@ -1835,6 +1889,7 @@ async def meetup_session(meetup_id: str, request: Request) -> dict[str, Any]:
"displayName": display_name,
"videoUrl": build_mirotalk_join_url(record, display_name) if is_live_event else "",
"chatUrl": build_lounge_chat_url(record) if is_live_event else "",
+ "replayUrl": str(record.get("replayUrl") or "").strip() if is_live_event else "",
"loungeChannel": lounge_channel,
"mirotalkRoom": str(record.get("mirotalkRoom") or ""),
"meetingProvider": str(record.get("meetingProvider") or ("mirotalk" if is_live_event else "")),
diff --git a/backend/ntfy_push.py b/backend/ntfy_push.py
new file mode 100644
index 0000000..b95abe4
--- /dev/null
+++ b/backend/ntfy_push.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+import re
+from datetime import datetime, time
+from typing import Any
+from zoneinfo import ZoneInfo
+
+import httpx
+
+from .settings import get_settings
+
+
+def ntfy_topic_for_user(user_id: str) -> str:
+ clean = re.sub(r"[^a-zA-Z0-9_-]", "", user_id.strip())[:32]
+ return f"nomadcna-{clean or 'guest'}"
+
+
+def ntfy_subscribe_url(user_id: str) -> str:
+ settings = get_settings()
+ topic = ntfy_topic_for_user(user_id)
+ return f"{settings.ntfy_url}/{topic}"
+
+
+def _in_quiet_hours(preferences: dict[str, Any]) -> bool:
+ start_raw = str(preferences.get("quietHoursStart") or "22:00")
+ end_raw = str(preferences.get("quietHoursEnd") or "08:00")
+ try:
+ start = time.fromisoformat(start_raw[:5])
+ end = time.fromisoformat(end_raw[:5])
+ except ValueError:
+ return False
+ now = datetime.now(ZoneInfo("Asia/Shanghai")).time()
+ if start <= end:
+ return start <= now <= end
+ return now >= start or now <= end
+
+
+def _should_push(preferences: dict[str, Any], category: str, priority: str) -> bool:
+ channels = preferences.get("channels") or {}
+ categories = preferences.get("categories") or {}
+ if not channels.get("push"):
+ return False
+ if not categories.get(category, True):
+ return False
+ if priority == "high":
+ return True
+ return not _in_quiet_hours(preferences)
+
+
+async def push_ntfy_message(
+ *,
+ topic: str,
+ title: str,
+ message: str,
+ click: str = "",
+ priority: str = "default",
+ tags: str = "",
+) -> bool:
+ settings = get_settings()
+ if not settings.ntfy_enabled:
+ return False
+ url = f"{settings.ntfy_url.rstrip('/')}/{topic}"
+ headers: dict[str, str] = {"Title": title[:250]}
+ if click:
+ headers["Click"] = click[:1000]
+ if tags:
+ headers["Tags"] = tags[:100]
+ if priority in {"min", "low", "default", "high", "max", "urgent"}:
+ headers["Priority"] = priority
+ auth = settings.ntfy_auth_token.strip()
+ if auth:
+ headers["Authorization"] = f"Bearer {auth}"
+ try:
+ async with httpx.AsyncClient(timeout=12.0) as client:
+ response = await client.post(url, content=message[:4000], headers=headers)
+ return response.status_code < 400
+ except httpx.HTTPError:
+ return False
+
+
+async def dispatch_ntfy_for_user(
+ *,
+ user_id: str,
+ preferences: dict[str, Any],
+ title: str,
+ body: str,
+ category: str = "system",
+ priority: str = "normal",
+ action_url: str = "",
+ icon: str = "🔔",
+) -> bool:
+ if not user_id or not _should_push(preferences, category, priority):
+ return False
+ settings = get_settings()
+ site = settings.site_public_url.rstrip("/")
+ click = action_url if action_url.startswith("http") else f"{site}{action_url}" if action_url else site
+ ntfy_priority = "high" if priority == "high" else "default"
+ return await push_ntfy_message(
+ topic=ntfy_topic_for_user(user_id),
+ title=title,
+ message=body or title,
+ click=click,
+ priority=ntfy_priority,
+ tags=icon,
+ )
diff --git a/backend/settings.py b/backend/settings.py
index e2d5dec..957fb18 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -195,6 +195,31 @@ class Settings:
or os.getenv("NEXT_PUBLIC_LOUNGE_URL")
or "https://lounge.nomadro.cn"
).rstrip("/")
+ self.listmonk_url = (
+ os.getenv("LISTMONK_URL")
+ or os.getenv("NEXT_PUBLIC_LISTMONK_URL")
+ or "https://listmonk.nomadro.cn"
+ ).rstrip("/")
+ self.listmonk_api_url = (
+ os.getenv("LISTMONK_API_URL") or self.listmonk_url
+ ).rstrip("/")
+ self.listmonk_api_key = os.getenv("LISTMONK_API_KEY", "").strip()
+ list_ids_raw = os.getenv("LISTMONK_DEFAULT_LIST_IDS", "1")
+ self.listmonk_default_list_ids = [
+ int(x.strip()) for x in list_ids_raw.split(",") if x.strip().isdigit()
+ ] or [1]
+ self.ntfy_url = (
+ os.getenv("NTFY_URL")
+ or os.getenv("NEXT_PUBLIC_NTFY_URL")
+ or "https://ntfy.nomadro.cn"
+ ).rstrip("/")
+ self.ntfy_auth_token = os.getenv("NTFY_AUTH_TOKEN", "").strip()
+ self.ntfy_enabled = _bool_env("NTFY_ENABLED", "true")
+ self.site_public_url = (
+ os.getenv("NEXT_PUBLIC_SITE_URL")
+ or os.getenv("SITE_PUBLIC_URL")
+ or "https://nomadro.cn"
+ ).rstrip("/")
@lru_cache(maxsize=1)
diff --git a/config/remote-services.ts b/config/remote-services.ts
index 2069b1b..8ebe4cb 100644
--- a/config/remote-services.ts
+++ b/config/remote-services.ts
@@ -25,6 +25,8 @@ export const REMOTE_SERVICE_URLS = {
site: `https://${REMOTE_ROOT_DOMAIN}`,
mirotalk: `https://mirotalk.${REMOTE_ROOT_DOMAIN}`,
lounge: `https://lounge.${REMOTE_ROOT_DOMAIN}`,
+ listmonk: `https://listmonk.${REMOTE_ROOT_DOMAIN}`,
+ ntfy: `https://ntfy.${REMOTE_ROOT_DOMAIN}`,
} as const;
/** 当前是否为「本地前端 + 远程服务」开发模式 */
diff --git a/docker/integrations/README.md b/docker/integrations/README.md
new file mode 100644
index 0000000..19b1f16
--- /dev/null
+++ b/docker/integrations/README.md
@@ -0,0 +1,47 @@
+# Listmonk 邮件通讯(轻量 / Go)
+
+NomadCNA 仅自托管 **Listmonk**(Go 编写,~128MB)用于城市/活动邮件订阅。
+
+| 服务 | 域名 | 内网端口 |
+| --- | --- | --- |
+| Listmonk | https://listmonk.nomadro.cn | 9030 |
+
+**不自托管**(内存占用高,已移除):
+
+- Cal.com(Node,预约改由站内消息协调)
+- Owncast / PeerTube(直播/点播沿用 **MiroTalk** + **MinIO**)
+
+## 国内镜像
+
+`/etc/docker/daemon.json` 参考 `daemon.json.cn`,拉取用 `pull-images-cn.sh`(`docker.1ms.run` 前缀)。
+
+## 部署
+
+```bash
+python scripts/deploy-listmonk.py
+```
+
+## API
+
+管理后台创建 Token 后:
+
+```
+LISTMONK_API_KEY=...
+LISTMONK_DEFAULT_LIST_IDS=1
+```
+
+## ntfy 手机推送(Go,已有容器 `nomadro-ntfy`)
+
+| 域名 | 端口 |
+| --- | --- |
+| https://ntfy.nomadro.cn | 127.0.0.1:9180 |
+
+- HTTPS:`docker/integrations/nginx/ntfy.nomadro.cn.conf` + Certbot
+- 认证:`deny-all` 默认;匿名用户可读 `nomadcna-*` 主题(App 订阅)
+- 后端发布:服务器 `.env.local` 配置 `NTFY_AUTH_TOKEN=tk_...`
+- 业务接入:`create_notification` 对 `targetUserId` 推送;设置页开启「手机推送」
+
+```bash
+python scripts/ssh-fix-ntfy.py # 修复 HTTPS
+python scripts/ssh-setup-ntfy-acl3.py # ACL + 后端 token(如需重建)
+```
diff --git a/docker/integrations/daemon.json.cn b/docker/integrations/daemon.json.cn
new file mode 100644
index 0000000..c490362
--- /dev/null
+++ b/docker/integrations/daemon.json.cn
@@ -0,0 +1,9 @@
+{
+ "max-concurrent-downloads": 3,
+ "max-concurrent-uploads": 1,
+ "registry-mirrors": [
+ "https://docker.1ms.run",
+ "https://dockerproxy.net",
+ "https://mirror.ccs.tencentyun.com"
+ ]
+}
diff --git a/docker/integrations/docker-compose.yml b/docker/integrations/docker-compose.yml
new file mode 100644
index 0000000..f99b30c
--- /dev/null
+++ b/docker/integrations/docker-compose.yml
@@ -0,0 +1,43 @@
+# 轻量集成:仅 Listmonk(Go)+ PostgreSQL
+# 视频/预约沿用既有 MiroTalk + The Lounge,不自托管 Cal/Owncast/PeerTube
+services:
+ postgres-int:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-nomadro}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
+ ports:
+ - "127.0.0.1:5433:5432"
+ volumes:
+ - pg-int:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nomadro}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ mem_limit: 128m
+
+ listmonk:
+ image: listmonk/listmonk:v5.0.0
+ restart: unless-stopped
+ depends_on:
+ postgres-int:
+ condition: service_healthy
+ ports:
+ - "127.0.0.1:9030:9000"
+ environment:
+ LISTMONK_app__address: 0.0.0.0:9000
+ LISTMONK_app__admin_username: ${LISTMONK_ADMIN_USER:-admin}
+ LISTMONK_app__admin_password: ${LISTMONK_ADMIN_PASSWORD:?set LISTMONK_ADMIN_PASSWORD}
+ LISTMONK_db__host: postgres-int
+ LISTMONK_db__port: 5432
+ LISTMONK_db__user: ${POSTGRES_USER:-nomadro}
+ LISTMONK_db__password: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
+ LISTMONK_db__database: listmonk
+ LISTMONK_db__ssl_mode: disable
+ command: [sh, -c, "./listmonk --install --idempotent --yes && ./listmonk"]
+ mem_limit: 128m
+
+volumes:
+ pg-int:
diff --git a/docker/integrations/init-dbs.sql b/docker/integrations/init-dbs.sql
new file mode 100644
index 0000000..b736b11
--- /dev/null
+++ b/docker/integrations/init-dbs.sql
@@ -0,0 +1 @@
+-- 已由 compose POSTGRES_DB=listmonk 初始化,此文件保留兼容旧卷
diff --git a/docker/integrations/nginx/listmonk.nomadro.cn.conf b/docker/integrations/nginx/listmonk.nomadro.cn.conf
new file mode 100644
index 0000000..8688f48
--- /dev/null
+++ b/docker/integrations/nginx/listmonk.nomadro.cn.conf
@@ -0,0 +1,50 @@
+upstream nomadro_listmonk {
+ server 127.0.0.1:9030;
+ keepalive 8;
+}
+
+server {
+ listen 80;
+ server_name listmonk.nomadro.cn;
+ root /www/wwwroot/www.nomadro.cn;
+
+ location ^~ /.well-known/acme-challenge/ {
+ allow all;
+ root /www/wwwroot/www.nomadro.cn;
+ }
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+
+ access_log /www/wwwlogs/listmonk.nomadro.cn.log;
+ error_log /www/wwwlogs/listmonk.nomadro.cn.error.log;
+}
+
+server {
+ listen 443 ssl http2;
+ server_name listmonk.nomadro.cn;
+ root /www/wwwroot/www.nomadro.cn;
+
+ ssl_certificate /etc/letsencrypt/live/listmonk.nomadro.cn/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/listmonk.nomadro.cn/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+ ssl_prefer_server_ciphers on;
+ ssl_session_cache shared:SSL:10m;
+ ssl_session_timeout 10m;
+
+ client_max_body_size 25m;
+
+ location / {
+ proxy_pass http://nomadro_listmonk;
+ 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;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ access_log /www/wwwlogs/listmonk.nomadro.cn.log;
+ error_log /www/wwwlogs/listmonk.nomadro.cn.error.log;
+}
diff --git a/docker/integrations/nginx/ntfy.nomadro.cn.conf b/docker/integrations/nginx/ntfy.nomadro.cn.conf
new file mode 100644
index 0000000..0d6a29e
--- /dev/null
+++ b/docker/integrations/nginx/ntfy.nomadro.cn.conf
@@ -0,0 +1,54 @@
+upstream nomadro_ntfy {
+ server 127.0.0.1:9180;
+ keepalive 8;
+}
+
+server {
+ listen 80;
+ server_name ntfy.nomadro.cn;
+ root /www/wwwroot/www.nomadro.cn;
+
+ location ^~ /.well-known/acme-challenge/ {
+ allow all;
+ root /www/wwwroot/www.nomadro.cn;
+ }
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+
+ access_log /www/wwwlogs/ntfy.nomadro.cn.log;
+ error_log /www/wwwlogs/ntfy.nomadro.cn.error.log;
+}
+
+server {
+ listen 443 ssl http2;
+ server_name ntfy.nomadro.cn;
+ root /www/wwwroot/www.nomadro.cn;
+
+ ssl_certificate /etc/letsencrypt/live/ntfy.nomadro.cn/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/ntfy.nomadro.cn/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+ ssl_prefer_server_ciphers on;
+ ssl_session_cache shared:SSL:10m;
+ ssl_session_timeout 10m;
+
+ client_max_body_size 25m;
+
+ location / {
+ proxy_pass http://nomadro_ntfy;
+ 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;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_read_timeout 300s;
+ proxy_send_timeout 300s;
+ }
+
+ access_log /www/wwwlogs/ntfy.nomadro.cn.log;
+ error_log /www/wwwlogs/ntfy.nomadro.cn.error.log;
+}
diff --git a/docker/integrations/pull-images-cn.sh b/docker/integrations/pull-images-cn.sh
new file mode 100644
index 0000000..f5a463f
--- /dev/null
+++ b/docker/integrations/pull-images-cn.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# 国内 VPS:仅拉取 Listmonk 栈(Go + Postgres Alpine)
+set -eu
+
+MIRRORS=(docker.1ms.run dockerproxy.net)
+
+pull_one() {
+ local src="$1"
+ local dst="$2"
+ for m in "${MIRRORS[@]}"; do
+ echo "==> pull ${m}/${src}"
+ if docker pull "${m}/${src}"; then
+ docker tag "${m}/${src}" "${dst}"
+ return 0
+ fi
+ done
+ docker pull "${dst}"
+}
+
+pull_one "library/postgres:16-alpine" "postgres:16-alpine"
+pull_one "listmonk/listmonk:v5.0.0" "listmonk/listmonk:v5.0.0"
+docker images | grep -E 'postgres|listmonk' || true
diff --git a/scripts/ssh-cleanup-heavy.py b/scripts/ssh-cleanup-heavy.py
new file mode 100644
index 0000000..a1c33de
--- /dev/null
+++ b/scripts/ssh-cleanup-heavy.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""Stop and remove Cal.com / Owncast / PeerTube from China VPS."""
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+import paramiko
+
+HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
+USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
+PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
+REMOTE_DIR = "/opt/nomadro-services/integrations"
+NGINX_DIR = "/www/server/panel/vhost/nginx"
+ROOT = Path(__file__).resolve().parent.parent
+HEAVY_DOMAINS = ["cal.nomadro.cn", "owncast.nomadro.cn", "peertube.nomadro.cn"]
+
+
+def run(c: paramiko.SSHClient, cmd: str, timeout: int = 300) -> None:
+ print(f"\n$ {cmd.replace(PASSWORD, '***')}")
+ _, o, e = c.exec_command(f"echo '{PASSWORD}' | sudo -S bash -lc \"{cmd}\"", timeout=timeout, get_pty=True)
+ sys.stdout.buffer.write(o.read())
+ sys.stdout.buffer.write(e.read())
+
+
+def main() -> None:
+ c = paramiko.SSHClient()
+ c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
+ try:
+ run(c, f"cd {REMOTE_DIR} && docker compose stop calcom owncast peertube redis-int 2>/dev/null || true")
+ run(c, f"cd {REMOTE_DIR} && docker compose rm -f calcom owncast peertube redis-int 2>/dev/null || true")
+ run(c, "docker rmi calcom/cal.com:v4.6.1 owncast/owncast:latest chocobozzz/peertube:production-bullseye 2>/dev/null || true")
+
+ for domain in HEAVY_DOMAINS:
+ run(c, f"rm -f {NGINX_DIR}/{domain}.conf")
+
+ sftp = c.open_sftp()
+ sftp.put(str(ROOT / "docker" / "integrations" / "docker-compose.yml"), f"{REMOTE_DIR}/docker-compose.yml")
+ sftp.close()
+
+ run(c, f"cd {REMOTE_DIR} && docker compose up -d postgres-listmonk listmonk 2>/dev/null || "
+ f"cd {REMOTE_DIR} && docker compose up -d postgres-int listmonk 2>/dev/null || true")
+ run(c, "free -h | head -2")
+ run(c, f"cd {REMOTE_DIR} && docker compose ps -a 2>/dev/null || docker ps -a")
+ run(c, "nginx -t && nginx -s reload")
+ run(c, "curl -sI https://listmonk.nomadro.cn/ | head -3")
+ print("\n=== Heavy services removed, Listmonk kept ===")
+ finally:
+ c.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/ssh-fix-ntfy.py b/scripts/ssh-fix-ntfy.py
new file mode 100644
index 0000000..e861115
--- /dev/null
+++ b/scripts/ssh-fix-ntfy.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+"""Fix ntfy HTTPS nginx + verify push endpoint."""
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+import paramiko
+
+HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
+USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
+PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
+NGINX_DIR = "/www/server/panel/vhost/nginx"
+WEBROOT = "/www/wwwroot/www.nomadro.cn"
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def run(c: paramiko.SSHClient, cmd: str, timeout: int = 120) -> None:
+ print(f"\n$ {cmd.replace(PASSWORD, '***')}")
+ _, o, e = c.exec_command(f"echo '{PASSWORD}' | sudo -S bash -lc \"{cmd}\"", timeout=timeout, get_pty=True)
+ sys.stdout.buffer.write(o.read())
+ sys.stdout.buffer.write(e.read())
+
+
+def main() -> None:
+ c = paramiko.SSHClient()
+ c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
+ try:
+ # HTTP temp for certbot if needed
+ http_only = f"""server {{
+ listen 80;
+ server_name ntfy.nomadro.cn;
+ root {WEBROOT};
+ location ^~ /.well-known/acme-challenge/ {{
+ allow all;
+ root {WEBROOT};
+ }}
+ location / {{
+ proxy_pass http://127.0.0.1:9180;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ }}
+}}
+"""
+ sftp = c.open_sftp()
+ with sftp.file("/tmp/ntfy.http.conf", "w") as f:
+ f.write(http_only)
+ sftp.put(str(ROOT / "docker" / "integrations" / "nginx" / "ntfy.nomadro.cn.conf"), "/tmp/ntfy.https.conf")
+ sftp.close()
+
+ run(c, f"cp /tmp/ntfy.http.conf {NGINX_DIR}/ntfy.nomadro.cn.conf")
+ run(c, "nginx -t && nginx -s reload")
+
+ run(
+ c,
+ f"certbot certonly --webroot -w {WEBROOT} -d ntfy.nomadro.cn "
+ f"--non-interactive --agree-tos -m xiaoshuang.eric@gmail.com || true",
+ )
+
+ run(c, f"cp /tmp/ntfy.https.conf {NGINX_DIR}/ntfy.nomadro.cn.conf")
+ run(c, "nginx -t && nginx -s reload")
+
+ # Recreate ntfy with base URL (optional env)
+ run(c, "docker inspect nomadro-ntfy --format '{{.Config.Env}}' | head -1")
+ run(c, "curl -sI http://127.0.0.1:9180/ | head -5")
+ run(c, "curl -sI https://ntfy.nomadro.cn/ | head -8")
+ run(c, "curl -s -X POST https://ntfy.nomadro.cn/nomadcna-healthcheck -H 'Title: NomadCNA' -d 'ntfy https ok'")
+ print("\n=== ntfy HTTPS fix done ===")
+ finally:
+ c.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/ssh-git-pull-prod.py b/scripts/ssh-git-pull-prod.py
new file mode 100644
index 0000000..6017194
--- /dev/null
+++ b/scripts/ssh-git-pull-prod.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""Git pull on production VPS and restart API."""
+from __future__ import annotations
+
+import os
+import sys
+
+import paramiko
+
+HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
+USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
+SSH_PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
+REPO = "/home/ubuntu/gitlab-instance-0a899031_cnomadcna"
+GIT_USER = os.getenv("GIT_USER", "eric")
+GIT_PASSWORD = os.getenv("GIT_PASSWORD", "")
+GIT_REMOTE = os.getenv(
+ "GIT_REMOTE",
+ "https://gitea.dsx2020.com/eric/gitlab-instance-0a899031_cnomadcna.git",
+)
+BRANCH = os.getenv("GIT_BRANCH", "test2")
+
+
+def run(c: paramiko.SSHClient, cmd: str, timeout: int = 180) -> int:
+ safe = cmd.replace(GIT_PASSWORD, "***").replace(SSH_PASSWORD, "***")
+ print(f"\n$ {safe}")
+ _, o, e = c.exec_command(f"echo '{SSH_PASSWORD}' | sudo -S bash -lc \"{cmd}\"", timeout=timeout, get_pty=True)
+ out = o.read().decode("utf-8", errors="replace")
+ err = e.read().decode("utf-8", errors="replace")
+ code = o.channel.recv_exit_status()
+ if out:
+ sys.stdout.buffer.write(out.encode("utf-8", errors="replace"))
+ if err:
+ sys.stdout.buffer.write(err.encode("utf-8", errors="replace"))
+ return code
+
+
+def main() -> int:
+ auth_url = GIT_REMOTE.replace("https://", f"https://{GIT_USER}:{GIT_PASSWORD}@")
+ c = paramiko.SSHClient()
+ c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ c.connect(HOST, username=USER, password=SSH_PASSWORD, timeout=30)
+ try:
+ run(c, f"cd {REPO} && git fetch {auth_url} {BRANCH}")
+ run(c, f"cd {REPO} && git checkout {BRANCH}")
+ run(c, f"cd {REPO} && git pull {auth_url} {BRANCH}")
+ run(c, "systemctl restart cnomadcna-api.service")
+ run(c, "systemctl is-active cnomadcna-api.service")
+ run(c, f"cd {REPO} && git log -1 --oneline")
+ return 0
+ finally:
+ c.close()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/ssh-restart-listmonk.py b/scripts/ssh-restart-listmonk.py
new file mode 100644
index 0000000..62fd2af
--- /dev/null
+++ b/scripts/ssh-restart-listmonk.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+import paramiko
+
+HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
+USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
+PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
+REMOTE_DIR = "/opt/nomadro-services/integrations"
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def run(c: paramiko.SSHClient, cmd: str) -> None:
+ print(f"\n$ {cmd.replace(PASSWORD, '***')}")
+ _, o, e = c.exec_command(f"echo '{PASSWORD}' | sudo -S bash -lc \"{cmd}\"", timeout=120, get_pty=True)
+ sys.stdout.buffer.write(o.read())
+ sys.stdout.buffer.write(e.read())
+
+
+def main() -> None:
+ c = paramiko.SSHClient()
+ c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
+ try:
+ sftp = c.open_sftp()
+ sftp.put(str(ROOT / "docker" / "integrations" / "docker-compose.yml"), f"{REMOTE_DIR}/docker-compose.yml")
+ sftp.close()
+ run(c, f"cd {REMOTE_DIR} && docker compose rm -f postgres-listmonk listmonk 2>/dev/null || true")
+ run(c, f"cd {REMOTE_DIR} && docker compose up -d postgres-int listmonk")
+ run(c, "sleep 6")
+ run(c, "docker logs integrations-listmonk-1 --tail 10")
+ run(c, "curl -sI http://127.0.0.1:9030/ | head -3")
+ run(c, "curl -sI https://listmonk.nomadro.cn/ | head -3")
+ run(c, f"cd {REMOTE_DIR} && docker compose ps")
+ run(c, "free -h | head -2")
+ finally:
+ c.close()
+
+
+if __name__ == "__main__":
+ main()