Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ bash <(curl -fsSL https://raw.githubusercontent.com/HCGStudio/SecondDimensionWat
| `AI:Anthropic:ApiKey` / `BaseUrl` / `Model` / `MaxTokens` / `ApiVersion` | Anthropic 端点 |
| `AI:CodexAppServer:Endpoint` / `BearerToken` / `Model` / `PermissionProfile` / `TimeoutSeconds` | Codex app-server WebSocket 端点;空模型使用服务端默认模型;权限配置默认 `:read-only`,也可填写管理员定义的 profile id |
| `Inference:RateLimitDelayMs` | 推断 API 调用最小间隔(毫秒,默认 1000) |
| `Notifications:Webhook:Enabled` / `Url` | 通用 Webhook 通知渠道;完整 URL 按敏感配置处理,建议从网页设置中保存;远端地址必须使用 HTTPS |
| `Notifications:WebPush:Enabled` / `Subject` / `VapidPublicKey` / `VapidPrivateKey` | 浏览器 Web Push;首次在网页启用时可由服务端生成 VAPID 密钥对,私钥加密保存;浏览器订阅需要 HTTPS 或 localhost 安全来源 |
| `Notifications:Events` / `QuietHours` | 允许投递的领域事件与可选免打扰时段;启用且选中的事件会在核心操作完成后尽力写入持久化 Outbox,再异步重试投递 |
| `OutboundHttp:AllowedPrivateHosts` / `AllowedPrivateNetworks` | RSS、Webhook 与 Web Push 端点默认拒绝 loopback/私网目的地;确有需要时仅精确放行目标主机或 CIDR |
| `Valkey:ConnectionString` | Valkey / Redis 连接(单副本可选;多副本必须共享同一实例) |
| `ReverseProxy:KnownProxies` / `KnownNetworks` | 非 loopback 反向代理的受信地址/CIDR;仅填写代理,不填写客户端网段 |

Expand All @@ -118,7 +122,9 @@ bash <(curl -fsSL https://raw.githubusercontent.com/HCGStudio/SecondDimensionWat

### 网页运行时设置

登录后打开「设置」,可修改 AI 执行模式与 Provider、AI/TMDB 密钥、qBittorrent、媒体库扫描、异常检测和 NFS。保存值存入 PostgreSQL,并覆盖部署文件或环境变量中的默认值;密钥和密码使用持久化 Data Protection 密钥环加密,API 不会回显明文。可对单个敏感项选择保留、替换、清除或恢复部署默认值。
登录后打开「设置」,可修改 AI 执行模式与 Provider、AI/TMDB 密钥、qBittorrent、媒体库扫描、异常检测、通知和 NFS。保存值存入 PostgreSQL,并覆盖部署文件或环境变量中的默认值;密钥、密码、Webhook URL、VAPID 私钥及浏览器 PushSubscription 能力凭据使用持久化 Data Protection 密钥环加密,API 不会回显明文。可对单个敏感项选择保留、替换、清除或恢复部署默认值。

启用且订阅的通知会在核心操作完成后,以唯一去重键尽力写入 PostgreSQL Outbox,再由后台服务按至少一次语义投递。Webhook 和每个 Web Push 浏览器订阅拥有独立投递行、租约与重试状态,一个渠道失败不会重复投递另一个渠道;Webhook 请求带有稳定的 `X-SDW-Event-Id`,Web Push 也使用同一事件 ID 作为通知标签,接收端仍应按事件 ID 幂等。5xx、408、429 和网络错误会指数退避重试,失效的浏览器订阅会在 404/410 后撤销,永久失败可在「设置 → 通知」查看,且任何投递或入队失败都不会回滚订阅、下载、推断或异常处理。顶栏「待办中心」会按风险汇总待确认下载、异常、低置信度/失败元数据和磁盘预警,并支持已读、稍后提醒及无副作用批量操作。

数据库连接、JWT、下载存储根目录、登录密码文件、CORS 和 Valkey 仍属于启动/基础设施配置,不允许从网页修改。NFS 监听地址、端口和启用状态会保存,但需要重启应用才能切换;其余上述设置对后续请求和新任务热生效。后台定时任务的间隔变更不会中断已经开始的等待,最迟会在当前等待周期结束后采用新值。

Expand Down
276 changes: 275 additions & 1 deletion SecondDimensionWatcherReDive.Client/mock-server.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Mock API server for frontend development/testing.
// Run with: yarn mock (or: node mock-server.mjs)
// Then run: yarn start — the Parcel proxy forwards /api/* to this server.
import { randomBytes, randomUUID } from "node:crypto";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { createServer } from "node:http";

const PORT = parseInt(process.env.MOCK_PORT ?? "5097", 10);
Expand Down Expand Up @@ -1106,6 +1106,26 @@ let systemSettings = {
restartRequired: true,
pendingRestart: false,
},
notifications: {
webhookEnabled: false,
webPushEnabled: false,
webPushSubject: "",
vapidPublicKey: "",
vapidPrivateKey: { isConfigured: false, source: "none" },
events: [
"releaseMatched",
"downloadPendingConfirmation",
"downloadCompleted",
"downloadFailed",
"incidentOpened",
"metadataNeedsReview",
"diskSpaceLow",
],
quietHoursStart: null,
quietHoursEnd: null,
timeZoneId: "UTC",
webhookUrl: { isConfigured: false, source: "none" },
},
};

const deploymentSecrets = {
Expand All @@ -1114,8 +1134,14 @@ const deploymentSecrets = {
codex: { isConfigured: false, source: "none" },
tmdb: { isConfigured: true, source: "deployment" },
torrent: { isConfigured: false, source: "none" },
webhook: { isConfigured: false, source: "none" },
};

let notificationDeliveries = [];
let webPushSubscriptions = [];
const mockVapidPublicKey =
"BGb1EKTo02dge1GKm7kU8hSQowk4T8Qnpl8dOB1nrnSQJnrhc6OdQ3a4gtyGTkera6bMWIp9cKAlEdN_BA6gGQM";

function applySecretMutation(current, mutation, deploymentValue) {
if (!mutation || mutation.operation === "keep") return current;
if (mutation.operation === "set") {
Expand Down Expand Up @@ -1529,6 +1555,63 @@ function vfsResolve(rawPath) {
return { entry: match, isDirectory: false };
}

const mockTodoStates = new Map();

function currentMockTodos() {
const anime = [...animations.values()];
const base = [
anime[0] && {
key: `automation:${anime[0].id}`,
type: "ReleaseMatched",
priority: "Normal",
title: anime[0].title,
detail: "A notify-only subscription matched this release.",
deepLink: `/todo?focus=automation:${anime[0].id}`,
resourceId: anime[0].id,
occurredAt: anime[0].publishTime,
},
anime[1] && {
key: `automation:${anime[1].id}`,
type: "DownloadPendingConfirmation",
priority: "High",
title: anime[1].title,
detail: "A matched release is waiting for download confirmation.",
deepLink: `/todo?focus=automation:${anime[1].id}`,
resourceId: anime[1].id,
occurredAt: anime[1].publishTime,
},
...mockIncidents
.filter((incident) => !incident.resolvedAt)
.map((incident) => ({
key: `incident:${incident.id}`,
type: incident.type === "diskSpaceLow" ? "DiskSpaceLow" : "Incident",
priority: incident.severity === "critical" ? "Critical" : "High",
title: incident.title,
detail: incident.detail,
deepLink:
incident.type === "diskSpaceLow"
? "/incidents?type=diskSpaceLow"
: `/incidents?focus=${incident.id}`,
resourceId: incident.id,
occurredAt: incident.detectedAt,
})),
].filter(Boolean);

return base
.map((item) => ({
...item,
readAt: mockTodoStates.get(item.key)?.readAt ?? null,
snoozedUntil: mockTodoStates.get(item.key)?.snoozedUntil ?? null,
}))
.sort((left, right) => {
const rank = { Normal: 0, High: 1, Critical: 2 };
return (
rank[right.priority] - rank[left.priority] ||
new Date(right.occurredAt) - new Date(left.occurredAt)
);
});
}

// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1722,13 +1805,204 @@ async function route(method, pathname, searchParams, req, res) {
systemSettings.pendingRestart = systemSettings.nfs.pendingRestart;
}

if (body.notifications) {
const generateVapidKeys =
body.notifications.generateVapidKeys &&
!systemSettings.notifications.vapidPrivateKey.isConfigured;
systemSettings.notifications = {
webhookEnabled: body.notifications.webhookEnabled,
webPushEnabled: body.notifications.webPushEnabled,
webPushSubject: body.notifications.webPushSubject,
vapidPublicKey: generateVapidKeys
? mockVapidPublicKey
: systemSettings.notifications.vapidPublicKey,
vapidPrivateKey: generateVapidKeys
? { isConfigured: true, source: "runtime" }
: systemSettings.notifications.vapidPrivateKey,
events: [...body.notifications.events],
quietHoursStart: body.notifications.quietHoursStart,
quietHoursEnd: body.notifications.quietHoursEnd,
timeZoneId: body.notifications.timeZoneId,
webhookUrl: applySecretMutation(
systemSettings.notifications.webhookUrl,
body.notifications.webhookUrl,
deploymentSecrets.webhook,
),
};
}

systemSettings.revision += 1;
return json(res, systemSettings);
} catch (error) {
return json(res, { error: error.message }, 400);
}
}

if (
method === "GET" &&
pathname === "/api/notifications/web-push/config"
) {
return json(res, {
enabled: systemSettings.notifications.webPushEnabled,
vapidPublicKey: systemSettings.notifications.vapidPublicKey,
});
}

if (
method === "GET" &&
pathname === "/api/notifications/web-push/subscriptions"
) {
return json(
res,
webPushSubscriptions.map(({ endpoint: _endpoint, ...summary }) => summary),
);
}

if (
method === "POST" &&
pathname === "/api/notifications/web-push/subscriptions"
) {
if (!systemSettings.notifications.webPushEnabled)
return json(res, { message: "Enable Web Push first" }, 409);
const body = await readBody(req);
const now = new Date().toISOString();
let subscription = webPushSubscriptions.find(
(item) => item.endpoint === body.endpoint,
);
if (subscription) {
subscription.updatedAt = now;
subscription.lastError = null;
} else {
subscription = {
id: randomUUID(),
endpoint: body.endpoint,
endpointOrigin: new URL(body.endpoint).origin,
endpointHash: createHash("sha256").update(body.endpoint).digest("hex"),
createdAt: now,
updatedAt: now,
lastSuccessAt: null,
lastFailureAt: null,
lastError: null,
};
webPushSubscriptions.unshift(subscription);
}
const { endpoint: _endpoint, ...summary } = subscription;
return json(res, summary);
}

if (
method === "POST" &&
pathname ===
"/api/notifications/web-push/subscriptions/remove-current"
) {
const body = await readBody(req);
webPushSubscriptions = webPushSubscriptions.filter(
(item) => item.endpoint !== body.endpoint,
);
res.writeHead(204);
return res.end();
}

const webPushDeleteMatch = pathname.match(
/^\/api\/notifications\/web-push\/subscriptions\/([^/]+)$/,
);
if (method === "DELETE" && webPushDeleteMatch) {
const before = webPushSubscriptions.length;
webPushSubscriptions = webPushSubscriptions.filter(
(item) => item.id !== webPushDeleteMatch[1],
);
res.writeHead(before === webPushSubscriptions.length ? 404 : 204);
return res.end();
}

if (method === "POST" && pathname === "/api/notifications/test") {
const webhookReady =
systemSettings.notifications.webhookEnabled &&
systemSettings.notifications.webhookUrl.isConfigured;
const webPushReady =
systemSettings.notifications.webPushEnabled &&
webPushSubscriptions.length > 0;
if (!webhookReady && !webPushReady)
return json(res, { message: "Configure a destination first" }, 409);
const eventId = randomUUID();
const channels = [
...(webhookReady ? ["Webhook"] : []),
...webPushSubscriptions
.filter(() => webPushReady)
.map(() => "WebPush"),
];
notificationDeliveries.unshift(
...channels.map((channel, index) => ({
id: index === 0 ? eventId : randomUUID(),
eventId,
channel,
type: "test",
status: "Delivered",
attemptCount: 1,
occurredAt: new Date().toISOString(),
lastAttemptAt: new Date().toISOString(),
deliveredAt: new Date().toISOString(),
lastError: null,
})),
);
return json(res, { eventId }, 202);
}

if (method === "GET" && pathname === "/api/notifications/deliveries") {
const take = Math.min(
100,
Math.max(1, Number(searchParams.get("take")) || 20),
);
return json(res, notificationDeliveries.slice(0, take));
}

if (method === "GET" && pathname === "/api/todos") {
const includeRead = searchParams.get("includeRead") === "true";
const includeSnoozed = searchParams.get("includeSnoozed") === "true";
const skip = Math.max(0, Number(searchParams.get("skip")) || 0);
const take = Math.min(
200,
Math.max(1, Number(searchParams.get("take")) || 50),
);
const focus = searchParams.get("focus");
const now = Date.now();
const all = currentMockTodos();
const unreadCount = all.filter(
(item) =>
!item.readAt &&
(!item.snoozedUntil || new Date(item.snoozedUntil) <= now),
).length;
const visible = all.filter(
(item) =>
(includeRead || !item.readAt) &&
(includeSnoozed ||
!item.snoozedUntil ||
new Date(item.snoozedUntil) <= now),
);
const items = visible.slice(skip, skip + take);
const focused = focus && all.find((item) => item.key === focus);
if (focused && !items.some((item) => item.key === focused.key))
items.unshift(focused);
return json(res, { items, totalCount: visible.length, unreadCount });
}

if (method === "PATCH" && pathname === "/api/todos/state") {
const body = await readBody(req);
const now = new Date().toISOString();
for (const key of body.keys ?? []) {
const state = mockTodoStates.get(key) ?? {
readAt: null,
snoozedUntil: null,
};
if (body.action === "markRead") state.readAt = now;
if (body.action === "markUnread") state.readAt = null;
if (body.action === "snooze") state.snoozedUntil = body.snoozedUntil;
if (body.action === "unsnooze") state.snoozedUntil = null;
mockTodoStates.set(key, state);
}
return empty(res, 204);
}

// --- Playback continuity ---

if (method === "GET" && pathname === "/api/playback/continue") {
Expand Down
2 changes: 2 additions & 0 deletions SecondDimensionWatcherReDive.Client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Main } from "./Main";
import fetcher from "./auth/httpClient";
import { ToastProvider } from "./components/ToastProvider";
import i18n from "./i18n";
import { refreshWebPushServiceWorker } from "./notifications/webPush";
import "./styles.css";
import { setDayjsLocale } from "./utils/initDayjs";

Expand All @@ -15,6 +16,7 @@ i18n.on("languageChanged", (lng) => {
setDayjsLocale(lng);
document.documentElement.lang = lng;
});
void refreshWebPushServiceWorker().catch(() => undefined);

const root = createRoot(document.getElementById("app")!);
root.render(
Expand Down
13 changes: 13 additions & 0 deletions SecondDimensionWatcherReDive.Client/src/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
loadPlayerPage,
loadSettingsPage,
loadTasksPage,
loadTodoPage,
} from "./routes/pageLoaders";

const ChatPage = React.lazy(async () => ({
Expand Down Expand Up @@ -60,6 +61,9 @@ const SettingsPage = React.lazy(async () => ({
const TasksPage = React.lazy(async () => ({
default: (await loadTasksPage()).TasksPage,
}));
const TodoPage = React.lazy(async () => ({
default: (await loadTodoPage()).TodoPage,
}));

const router = createBrowserRouter([
{
Expand Down Expand Up @@ -125,6 +129,15 @@ const router = createBrowserRouter([
),
errorElement: <ErrorPage />,
},
{
path: "/todo",
element: (
<ProtectedRoute>
<TodoPage />
</ProtectedRoute>
),
errorElement: <ErrorPage />,
},
{
path: "/incidents",
element: (
Expand Down
Loading