Add ntfy push and Listmonk newsletter with lightweight Docker deploy tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric
2026-06-08 02:24:55 -05:00
parent 47b1ae8514
commit 22a960799c
24 changed files with 846 additions and 3 deletions

View File

@@ -54,3 +54,15 @@ DEV_AUTO_PAY=true
# 视频会议 / 群聊
NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn
NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn
# 邮件通讯ListmonkGo 轻量服务)
NEXT_PUBLIC_LISTMONK_URL=https://listmonk.nomadro.cn
LISTMONK_API_URL=https://listmonk.nomadro.cn
LISTMONK_API_KEY=
LISTMONK_DEFAULT_LIST_IDS=1
# 手机推送ntfyGo 轻量服务)
NEXT_PUBLIC_NTFY_URL=https://ntfy.nomadro.cn
NTFY_URL=https://ntfy.nomadro.cn
NTFY_ENABLED=true
NTFY_AUTH_TOKEN=

View File

@@ -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
</ul>
</section>
<NewsletterSubscribe locale="zh" citySlug={slug} list="city" className="mb-6" />
<div className="flex flex-col sm:flex-row gap-3">
<Link
href="/join"

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Link, useLocale, useTranslation } from "@/i18n/navigation";
import NewsletterSubscribe from "@/app/components/NewsletterSubscribe";
import { apiFetch } from "@/app/lib/api-client";
import {
buildMeetupChatUrl,
@@ -707,6 +708,16 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
{canJoin && (
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">{c.chatHint}</p>
)}
{meetup.replayUrl ? (
<a
href={meetup.replayUrl}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex text-sm font-semibold text-emerald-700 underline dark:text-emerald-300"
>
{locale === "zh" ? "观看活动回放" : "Watch replay"}
</a>
) : null}
{embedOpen && canJoin && (
<iframe
title={`MiroTalk ${meetup.city}`}
@@ -903,6 +914,8 @@ export default function MeetupsPage() {
</div>
</div>
<NewsletterSubscribe locale={locale} list="meetup" className="mb-6" />
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{c.countPrefix}
<span className="mx-1 font-semibold text-gray-700 dark:text-gray-300">{displayedMeetups.length}</span>

View File

@@ -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<PreferenceRecord>(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() {
</label>
))}
</div>
{prefs.channels.push && pushSubscribeUrl ? (
<div className="mt-4 rounded-2xl border border-violet-200 bg-violet-50/80 p-4 dark:border-violet-900/50 dark:bg-violet-950/30">
<p className="text-sm font-semibold text-violet-900 dark:text-violet-200">ntfy </p>
<p className="mt-1 text-xs text-violet-800/80 dark:text-violet-300/80">
ntfy App
</p>
<a
href={pushSubscribeUrl}
target="_blank"
rel="noreferrer"
className="mt-2 block break-all text-sm font-medium text-violet-700 underline dark:text-violet-300"
>
{pushSubscribeUrl}
</a>
</div>
) : null}
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800">

View File

@@ -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 (
<form
onSubmit={handleSubmit}
className={`rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900 ${className}`}
>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{c.title}</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{c.hint}</p>
<div className="mt-4 grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={c.email}
className="rounded-xl border border-gray-200 px-4 py-2.5 text-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={c.name}
className="rounded-xl border border-gray-200 px-4 py-2.5 text-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<button
type="submit"
disabled={status === "loading"}
className="rounded-xl bg-[#ff4d4f] px-5 py-2.5 text-sm font-semibold text-white hover:bg-[#ff7a45] disabled:opacity-60"
>
{c.submit}
</button>
</div>
{status === "success" ? <p className="mt-3 text-sm text-emerald-600 dark:text-emerald-400">{c.success}</p> : null}
{status === "error" ? <p className="mt-3 text-sm text-red-600 dark:text-red-400">{c.error}</p> : null}
</form>
);
}

6
app/lib/listmonk.ts Normal file
View File

@@ -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(/\/$/, "");
}

View File

@@ -39,6 +39,7 @@ export interface MeetupSession {
displayName: string;
videoUrl: string;
chatUrl: string;
replayUrl?: string;
loungeChannel: string;
mirotalkRoom: string;
meetingProvider: string;

5
app/lib/ntfy.ts Normal file
View File

@@ -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(/\/$/, "");
}

41
backend/integrations.py Normal file
View File

@@ -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")}

View File

@@ -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 "")),

105
backend/ntfy_push.py Normal file
View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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;
/** 当前是否为「本地前端 + 远程服务」开发模式 */

View File

@@ -0,0 +1,47 @@
# Listmonk 邮件通讯(轻量 / Go
NomadCNA 仅自托管 **Listmonk**Go 编写,~128MB用于城市/活动邮件订阅。
| 服务 | 域名 | 内网端口 |
| --- | --- | --- |
| Listmonk | https://listmonk.nomadro.cn | 9030 |
**不自托管**(内存占用高,已移除):
- Cal.comNode预约改由站内消息协调
- 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如需重建
```

View File

@@ -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"
]
}

View File

@@ -0,0 +1,43 @@
# 轻量集成:仅 ListmonkGo+ 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:

View File

@@ -0,0 +1 @@
-- 已由 compose POSTGRES_DB=listmonk 初始化,此文件保留兼容旧卷

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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()

76
scripts/ssh-fix-ntfy.py Normal file
View File

@@ -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()

View File

@@ -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())

View File

@@ -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()