Add ntfy push and Listmonk newsletter with lightweight Docker deploy tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
12
.env.example
12
.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=
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
97
app/components/NewsletterSubscribe.tsx
Normal file
97
app/components/NewsletterSubscribe.tsx
Normal 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
6
app/lib/listmonk.ts
Normal 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(/\/$/, "");
|
||||
}
|
||||
@@ -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
5
app/lib/ntfy.ts
Normal 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
41
backend/integrations.py
Normal 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")}
|
||||
@@ -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
105
backend/ntfy_push.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
/** 当前是否为「本地前端 + 远程服务」开发模式 */
|
||||
|
||||
47
docker/integrations/README.md
Normal file
47
docker/integrations/README.md
Normal file
@@ -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(如需重建)
|
||||
```
|
||||
9
docker/integrations/daemon.json.cn
Normal file
9
docker/integrations/daemon.json.cn
Normal 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"
|
||||
]
|
||||
}
|
||||
43
docker/integrations/docker-compose.yml
Normal file
43
docker/integrations/docker-compose.yml
Normal file
@@ -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:
|
||||
1
docker/integrations/init-dbs.sql
Normal file
1
docker/integrations/init-dbs.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- 已由 compose POSTGRES_DB=listmonk 初始化,此文件保留兼容旧卷
|
||||
50
docker/integrations/nginx/listmonk.nomadro.cn.conf
Normal file
50
docker/integrations/nginx/listmonk.nomadro.cn.conf
Normal 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;
|
||||
}
|
||||
54
docker/integrations/nginx/ntfy.nomadro.cn.conf
Normal file
54
docker/integrations/nginx/ntfy.nomadro.cn.conf
Normal 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;
|
||||
}
|
||||
22
docker/integrations/pull-images-cn.sh
Normal file
22
docker/integrations/pull-images-cn.sh
Normal 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
|
||||
55
scripts/ssh-cleanup-heavy.py
Normal file
55
scripts/ssh-cleanup-heavy.py
Normal 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
76
scripts/ssh-fix-ntfy.py
Normal 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()
|
||||
55
scripts/ssh-git-pull-prod.py
Normal file
55
scripts/ssh-git-pull-prod.py
Normal 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())
|
||||
45
scripts/ssh-restart-listmonk.py
Normal file
45
scripts/ssh-restart-listmonk.py
Normal 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()
|
||||
Reference in New Issue
Block a user