Skip to content
Clone

feat(push): deliver chat and inbox notifications to the OS via Web Push

Notifications now reach the operating system notification centre, not just an
open tab: `user_notifications` writes fan out over Redis to a worker inside
apihost, which signs with VAPID and encrypts with aes128gcm (RFC8030/8292)
before handing the payload to the browser's push service.

Backend
- `lib/push` implements the transport half: VAPID key handling, payload
  encryption, HTTP submission, and 2xx/404-410/4xx/5xx outcome classification.
  `web-push-native` supplies the crypto (pure Rust, no OpenSSL bindings) while
  reqwest keeps the existing rustls HTTP stack.
- `export::push` owns the queue payload and the enqueue gate. `export::notify`
  enqueues on every `notification` user event, so all nine writers (chat,
  issues, space, agent) gained push delivery without touching their own code
  and the websocket and push fan-outs cannot drift apart.
- The worker skips users with a live chat socket (`chat:ws:{uid}`, a refcounted
  heartbeat marker the im gateway maintains) and categories the user disabled
  via `user_preferences.push_flags`, which is a disable mask so the stored
  default of 0 keeps existing accounts receiving everything.
- Chat semantics filled in: replies now notify the parent author and thread
  followers (deduplicated against mentions, which win), and DMs/group DMs push
  their members without writing inbox rows.
- `[push]` config section, closed by default; the worker refuses to start if the
  VAPID pair does not actually derive, rather than signing headers every device
  would reject.

Frontend
- `web/public/sw.js` shows the notification, focuses an existing window on
  click, and re-subscribes on `pushsubscriptionchange`.
- A manifest and touch icon make the site installable, which is what iOS
  requires before it will deliver push at all.
- The settings page gains a per-device switch (with distinct copy for denied and
  unsupported browsers) and per-category toggles, driven by the new public
  `GET /api/v1/push/config`.
ZhenYi committed
parent 59a958b commit c038fd4 Browse files

39 files changed

+2223 −25

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 39
Modified app/apihost/src/main.rs View app/apihost/src/main.rs
@@ -4,6 +4,7 @@
44use axum::Router;
55use config::scoped::{ApihostConfig, ScopedConfig};
66use tokio::net::TcpListener;
7use tokio_util::sync::CancellationToken;
78use tracing::{info, warn};
89use utoipa_swagger_ui::SwaggerUi;
910use webapi::{openapi::api_doc, router};
@@ -30,6 +31,21 @@
3031 tokio::spawn(chat::worker::run_background_tasks(state.clone()));
3132 tokio::spawn(auth::worker::run_deletion_executor(state.clone()));
3233
34 // Web Push 消费者:与其它后台任务一起挂在 apihost 上,不为它单开进程。
35 // 配置非法(密钥缺失或公私钥不成对)时直接报错退出——静默不投递比启动
36 // 失败更难被发现。
37 if cfg.push.enabled {
38 for _ in 0..cfg.push.workers.max(1) {
39 let worker = push::PushWorker::new(state.clone(), CancellationToken::new())
40 .map_err(|err| anyhow::anyhow!("failed to start push worker: {err}"))?;
41 tokio::spawn(worker.run());
42 }
43 info!(
44 workers = cfg.push.workers.max(1),
45 "web push workers started"
46 );
47 }
48
3349 let api_router: Router = router(state)
3450 .await
3551 .map_err(|err| anyhow::anyhow!("failed to build router: {err}"))?;
Modified app/apihost/Cargo.toml View app/apihost/Cargo.toml
@@ -8,11 +8,13 @@
88webapi = { workspace = true }
99auth = { workspace = true }
1010chat = { workspace = true }
11push = { workspace = true }
1112export = { workspace = true }
1213session = { workspace = true }
1314config = { workspace = true }
1415axum = { workspace = true }
1516tokio = { workspace = true, features = ["signal", "net", "macros", "rt-multi-thread"] }
17tokio-util = { workspace = true }
1618tracing = { workspace = true }
1719tracing-subscriber = { workspace = true }
1820anyhow = { workspace = true }
Modified app/im/src/socket.rs View app/im/src/socket.rs
@@ -94,6 +94,11 @@
9494 return;
9595 }
9696
97 // 在线标记:推送 worker 用它决定"这个用户已经有实时通道,不需要系统
98 // 通知"。同一个账号可以同时开多个连接,所以这里维护的是引用计数,值
99 // 只用于计数,是否在线只看键在不在。
100 let online = OnlineMarker::claim(&service, uid).await;
101
97102 let heartbeat_secs = service.config().chat.ws.heartbeat_secs.max(5);
98103 let idle_limit = Duration::from_secs(heartbeat_secs * 3);
99104 let mut last_activity = tokio::time::Instant::now();
@@ -191,6 +196,10 @@
191196 }).await;
192197 break;
193198 }
199 // 续期必须在 tick 里做而不是只在 ready 时写一次:TTL 到期而
200 // 进程还活着(例如用户只是在读、没有发帧)时,键消失会让推送
201 // worker 把他当成离线,于是同一个事件既弹 toast 又弹系统通知。
202 online.refresh().await;
194203 tick_count = tick_count.wrapping_add(1);
195204 if tick_count % 2 == 0 {
196205 let mut revoked: Vec<Uuid> = Vec::new();
@@ -221,6 +230,7 @@
221230 }
222231 }
223232
233 online.release().await;
224234 let mut teardown: Vec<(Uuid, tokio::task::JoinHandle<()>)> = Vec::new();
225235 for (room_id, sub) in subscriptions {
226236 sub.cancel.cancel();
@@ -240,6 +250,49 @@
240250 service.realtime.unsubscribe_user(uid).await;
241251}
242252
253/// 一条 WS 连接的"在线"占位。
254///
255/// 值是引用计数:多标签页连同一个账号时只有全部断开才算离线。TTL 是安全网,
256/// 进程被强杀来不及 `release` 时键会自行消失,不会留下永久在线的幽灵。
257struct OnlineMarker {
258 key: String,
259 ttl_secs: u64,
260 redis: export::AppRedis,
261}
262
263impl OnlineMarker {
264 async fn claim(service: &ChatService, uid: Uuid) -> Self {
265 // TTL 取心跳的两倍:一次 tick 丢失不会掉线,而进程崩溃后最多两个
266 // 心跳周期就能恢复正常推送。
267 let ttl_secs = service
268 .config()
269 .chat
270 .ws
271 .heartbeat_secs
272 .max(5)
273 .saturating_mul(2);
274 let key = export::push::online_key(uid);
275 let marker = Self {
276 key,
277 ttl_secs,
278 redis: service.state.redis.clone(),
279 };
280 marker.refresh().await;
281 marker
282 }
283
284 async fn refresh(&self) {
285 if let Err(error) = export::redis::incr_touch(&self.redis, &self.key, self.ttl_secs).await {
286 tracing::warn!(%error, key = %self.key, "online marker refresh failed");
287 }
288 }
289
290 async fn release(&self) {
291 if let Err(error) = export::redis::decr_delete(&self.redis, &self.key).await {
292 tracing::warn!(%error, key = %self.key, "online marker release failed");
293 }
294 }
295}
243296fn check_seq_gap(
244297 payload: &str,
245298 last_seq: &mut HashMap<Uuid, i64>,
Modified chart/values.yaml View chart/values.yaml
@@ -36,6 +36,11 @@
3636 APP_SEARCH__URL: "http://meilisearch:7700"
3737 APP_SEARCH__INDEX_PREFIX: "gitdata"
3838 APP_IMPORT__ENABLED: "false"
39 # Web Push:默认关闭;开启前必须同时提供 APP_PUSH__VAPID_PUBLIC_KEY(此处)
40 # 与 APP_PUSH__VAPID_PRIVATE_KEY(secret.env)。密钥生成见 config/default.toml。
41 APP_PUSH__ENABLED: "false"
42 APP_PUSH__VAPID_PUBLIC_KEY: ""
43 APP_PUSH__VAPID_SUBJECT: "mailto:ops@example.com"
3944 # 自建 forge 的内网地址需显式加白,例如:
4045 # APP_IMPORT__ALLOWED_HOSTS: '["gitea.internal","gitlab.svc.cluster.local"]'
4146 # 多副本必须置 0:axum_session 默认在每个 pod 内存缓存会话最长 60 分钟,
@@ -64,6 +69,7 @@
6469 APP_ADMIN__SEED_USERNAME: "" # 首次启动种子管理员账号(admin_user 为空时创建)
6570 APP_ADMIN__SEED_PASSWORD: "" # 种子管理员口令,首次登录强制修改
6671 APP_CI__SECRET_ENCRYPTION_KEY: "" # base64 >=32 字节,CI secrets / webhook 加密密钥
72 APP_PUSH__VAPID_PRIVATE_KEY: "" # base64url 32 字节 P-256 标量,Web Push 签名私钥
6773
6874podSecurityContext:
6975 fsGroup: 10001
Modified config/default.toml View config/default.toml
@@ -387,6 +387,28 @@
387387keep_workspace = false
388388once = false
389389
390# ---------------------------------------------------------------------------
391# Web Push(RFC8030/RFC8292):聊天与站内通知的操作系统级推送。
392# 默认关闭;启用前必须用 P-256 密钥对填好 VAPID 公钥/私钥:
393# openssl ecparam -genkey -name prime256v1 -out vapid.pem
394# openssl ec -in vapid.pem -outform DER | tail -c 32 | base64 | tr '+/' '-_' | tr -d '=' # 私钥
395# openssl ec -in vapid.pem -pubout -outform DER | tail -c 65 | base64 | tr '+/' '-_' | tr -d '=' # 公钥
396# 密钥材料均为 base64url(无填充):私钥 = 32 字节标量,公钥 = 65 字节非压缩点。
397# ---------------------------------------------------------------------------
398[push]
399enabled = false
400vapid_public_key = ""
401vapid_private_key = ""
402vapid_subject = "mailto:ops@example.com"
403
404# apihost 内的消费者数量;推送是"尽力而为"通知,不做多副本协同。
405workers = 2
406max_attempts = 3
407retry_backoff_base_secs = 30
408request_timeout_secs = 10
409# 推送服务保留期(HTTP TTL 头);设备离线超过该时长则丢弃。
410ttl_secs = 86400
411
390412[livekit]
391413enabled = false
392414url = "ws://127.0.0.1:7880"
Modified docs/业务架构.md View docs/业务架构.md
@@ -354,7 +354,7 @@
354354|---|---|
355355| 资源配额 | 仓库 `storage_limit_size`、LFS 尺寸与 `git.max_blob_size`(100 MiB)、聊天附件 100 MiB、头像/空间图 5 MiB、目录节点 5000、请求体 2 MiB、GraphQL complexity 1000、WS 帧 256 KiB |
356356| 反滥用 | PoW + 每日配额 + 多维限流键;**读接口无限流**(对外服务需前置网关) |
357| 通知触达 | 站内 `user_notifications`(6 类:mention/reply/follow/invite/assignment/system)+ 邮件 + WS 用户频道 + 预留 Web Push(订阅可存,**无实际推送发送器**) |
357| 通知触达 | 站内 `user_notifications`(6 类:mention/reply/follow/invite/assignment/system)+ 邮件 + WS 用户频道 + Web Push(VAPID/aes128gcm,经 Redis `push:jobs` 队列由 apihost worker 投递到系统通知中心;在线用户跳过,`push_flags` 为禁用位掩码) |
358358| 国际化 | `user.locale/language`、`email.default_locale`、`timezone` 字段齐备;渲染文本目前中文注释 + 英文消息,`render_and_send` 显式丢弃 `locale` |
359359| 变现预留 | `products{kind,price,price_currency,is_published,nums_installs}` + `product_versions/installs/reviews` ⇒ 应用市场与分成模型的数据面;**无支付、无计费、无订阅实体** |
360360| 多租户隔离 | 逻辑隔离(同库同行级 `space_id`/`dir_id` + 应用层 ACL),**无库级/Schema 级租户隔离**;对象存储为单桶扁平前缀 |
Modified lib/chat/src/services/messages.rs View lib/chat/src/services/messages.rs
@@ -2,8 +2,9 @@
22use models::chat::message_edits;
33use models::chat::messages::{self, AuthorType, ChatMessageType};
44use models::chat::reactions;
5use models::chat::thread_members::{self, ThreadNotifyLevel};
56use models::chat::threads;
6use models::room::rooms;
7use models::room::rooms::{self, RoomKind};
78use models::users::user_notifications::NotificationType;
89use sea_orm::sea_query::Expr;
910use sea_orm::{
@@ -36,6 +37,68 @@
3637
3738const NONCE_CONFLICT: &str = "chat-nonce-conflict";
3839
40/// Which push copy a space-less room deserves; `None` for spaces and threads.
41fn direct_message_kind(room: &rooms::Model) -> Option<DirectMessageKind> {
42 match room.kind {
43 RoomKind::DirectMessage => Some(DirectMessageKind::Direct),
44 RoomKind::GroupDm => Some(DirectMessageKind::Group),
45 _ => None,
46 }
47}
48
49#[derive(Clone, Copy)]
50pub(crate) enum DirectMessageKind {
51 Direct,
52 Group,
53}
54
55/// Fan a DM message out as OS pushes.
56///
57/// Runs after commit, best-effort: a push that cannot be enqueued never fails
58/// the message write. The per-recipient gate is deliberately the same
59/// `notification_decision` the mention path uses, so "muted" and "mentions
60/// only" mean the same thing here as everywhere else.
61#[allow(clippy::too_many_arguments)]
62async fn broadcast_direct_message_push(
63 service: &ChatService,
64 kind: DirectMessageKind,
65 room_id: Uuid,
66 space_id: Option<Uuid>,
67 message_id: Uuid,
68 actor: Uuid,
69 body: String,
70 recipients: &[Uuid],
71) {
72 for &uid in recipients {
73 match notification_decision(service, room_id, space_id, uid, false, false).await {
74 Ok(NotifyDecision::Notify) => {}
75 _ => continue,
76 }
77 let locale = service.recipient_locale(uid).await;
78 let title = match kind {
79 DirectMessageKind::Direct => i18n::catalog::notification_direct_message_title(locale),
80 DirectMessageKind::Group => i18n::catalog::notification_group_dm_title(locale),
81 };
82 export::push::enqueue(
83 &service.state.redis,
84 &export::push::PushJob {
85 user_id: uid,
86 category: export::push::PushCategory::Dm,
87 title: title.to_string(),
88 body: Some(body.clone()),
89 data: Some(json!({
90 "room_id": room_id,
91 "message_id": message_id,
92 "space_name": space_slug_for(service, space_id).await,
93 })),
94 actor_id: Some(actor),
95 notification_id: None,
96 },
97 )
98 .await;
99 }
100}
101
39102#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
40103pub struct MessageDto {
41104 pub id: Uuid,
@@ -370,14 +433,19 @@
370433 thread = Some(t);
371434 }
372435
373 if let Some(reply_to) = cmd.reply_to_id {
436 // The parent author is a notification target in its own right: replying
437 // to someone is the same kind of address as mentioning them, and the
438 // `reply` notification type exists precisely for it.
439 let reply_parent_author = if let Some(reply_to) = cmd.reply_to_id {
374440 let parent = messages::Entity::find_by_id(reply_to)
375441 .one(self.db().read())
376442 .await?
377443 .filter(|p| p.room_id == room_id && p.deleted_at.is_none())
378444 .ok_or(ChatError::NotFound)?;
379 let _ = parent;
380 }
445 Some(parent.author_id)
446 } else {
447 None
448 };
381449
382450 let parsed = parse_mentions(self, &ctx, &cmd.content, caller).await?;
383451 let mention_uids = parsed.users.clone();
@@ -442,6 +510,8 @@
442510 let thread_clone = thread.clone();
443511 let mention_uids_for_txn = mention_uids.clone();
444512 let mention_locales_for_txn = mention_locales.clone();
513 let reply_parent_for_txn = reply_parent_author;
514 let thread_for_replies = thread.clone();
445515 let content_for_email = content.clone();
446516 let copy_ids_for_txn = copy_ids.clone();
447517 let svc = self.clone();
@@ -578,6 +648,60 @@
578648 .await?;
579649 }
580650
651 // Reply notifications: the parent's author plus everyone
652 // following the thread. A person who was also @-mentioned
653 // gets one row, not two — the mention is the more specific
654 // address, so it wins.
655 let mut reply_targets: Vec<Uuid> = Vec::new();
656 if let Some(author) = reply_parent_for_txn
657 && author != caller
658 && !mention_uids_for_txn.contains(&author)
659 {
660 reply_targets.push(author);
661 }
662 if let Some(thread) = &thread_for_replies {
663 let watchers = thread_members::Entity::find()
664 .filter(thread_members::Column::ThreadId.eq(thread.id))
665 .filter(thread_members::Column::NotifyLevel.eq(ThreadNotifyLevel::All))
666 .all(txn)
667 .await
668 .unwrap_or_default();
669 for watcher in watchers {
670 if watcher.user_id != caller
671 && !mention_uids_for_txn.contains(&watcher.user_id)
672 && !reply_targets.contains(&watcher.user_id)
673 {
674 reply_targets.push(watcher.user_id);
675 }
676 }
677 }
678 if !reply_targets.is_empty() {
679 let reply_locales =
680 i18n::resolve_user_locales(txn, &reply_targets, fallback_locale).await;
681 for &uid in &reply_targets {
682 if notification_decision(&svc, room.id, space_id, uid, true, false)
683 .await?
684 == NotifyDecision::Skip
685 {
686 continue;
687 }
688 notify_user(
689 txn,
690 &pending_txn,
691 uid,
692 NotificationType::Reply,
693 i18n::catalog::notification_reply_title(
694 reply_locales.get(&uid).copied().unwrap_or(fallback_locale),
695 )
696 .to_string(),
697 Some(body.clone()),
698 Some(data.clone()),
699 Some(caller),
700 )
701 .await?;
702 }
703 }
704
581705 Ok(())
582706 })
583707 })
@@ -659,6 +783,35 @@
659783 .await;
660784 }
661785
786 // DMs and group DMs get an OS push but no inbox row: the inbox is for
787 // things that ask something of you, and a direct message is already
788 // counted by the chat unread badge. Mute and notification level still
789 // apply, so a muted conversation stays silent.
790 if let Some(kind) = direct_message_kind(&room) {
791 let svc = self.clone();
792 let recipients = crate::share::notify::dm_recipients(self, room_id, caller).await;
793 let body = if content_for_email.chars().count() > 120 {
794 content_for_email.chars().take(120).collect::<String>()
795 } else {
796 content_for_email.clone()
797 };
798 if !recipients.is_empty() {
799 tokio::spawn(async move {
800 broadcast_direct_message_push(
801 &svc,
802 kind,
803 room_id,
804 space_id,
805 message_id,
806 caller,
807 body,
808 &recipients,
809 )
810 .await;
811 });
812 }
813 }
814
662815 if !mention_uids.is_empty() {
663816 let room_name = ctx.room.name.clone();
664817 let email_recipients: Vec<Uuid> = mention_uids
Modified lib/chat/src/share/notify.rs View lib/chat/src/share/notify.rs
@@ -1,5 +1,6 @@
11use models::chat::room_seq;
22use models::chat::room_user_settings::{self, RoomNotificationLevel};
3use models::room::room_members;
34use models::space::space_members::{self, MemberStatus, MessageNotifications};
45use models::users::user_notifications::{self, NotificationType};
56use models::users::user_recent_mentions;
@@ -14,6 +15,21 @@
1415use crate::services::service::ChatService;
1516use crate::share::realtime::RoomEvent;
1617
18/// The recipient set of a DM / group DM message: current room members minus the
19/// author. Used for OS pushes, which (unlike mentions) do not write inbox rows.
20pub async fn dm_recipients(service: &ChatService, room_id: Uuid, author: Uuid) -> Vec<Uuid> {
21 room_members::Entity::find()
22 .filter(room_members::Column::RoomId.eq(room_id))
23 .filter(room_members::Column::LeftAt.is_null())
24 .all(service.db().read())
25 .await
26 .unwrap_or_default()
27 .into_iter()
28 .map(|member| member.user_id)
29 .filter(|uid| *uid != author)
30 .collect()
31}
32
1733pub async fn next_room_seq(db: &impl ConnectionTrait, room_id: Uuid) -> Result<i64, ChatError> {
1834 let now = OffsetDateTime::now_utc();
1935 let row = db
@@ -173,8 +189,6 @@
173189 actor: Uuid,
174190 explicit: &[Uuid],
175191) {
176 use models::room::room_members;
177
178192 let candidates: Vec<Uuid> = if is_private || space_id.is_none() {
179193 room_members::Entity::find()
180194 .filter(room_members::Column::RoomId.eq(room_id))
Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -17,6 +17,7 @@
1717pub mod issues;
1818pub mod livekit;
1919pub mod pow;
20pub mod push;
2021pub mod quota;
2122pub mod redis;
2223pub mod runner;
@@ -53,6 +54,7 @@
5354use crate::issues::IssueConfig;
5455use crate::livekit::LiveKitConfig;
5556use crate::pow::PowConfig;
57use crate::push::PushConfig;
5658use crate::redis::RedisConfig;
5759use crate::runner::RunnerConfig;
5860use crate::s3::S3Config;
@@ -96,6 +98,7 @@
9698 pub runner: RunnerConfig,
9799 pub ai: AiConfig,
98100 pub aihost: AiHostServerConfig,
101 pub push: PushConfig,
99102}
100103
101104impl AppConfig {
@@ -127,6 +130,7 @@
127130 self.ai.validate().map_err(ConfigError::Validation)?;
128131 self.aihost.is_valid().map_err(ConfigError::Validation)?;
129132 self.livekit.is_valid().map_err(ConfigError::Validation)?;
133 self.push.is_valid().map_err(ConfigError::Validation)?;
130134 Ok(())
131135 }
132136}
Added lib/config/src/push.rs View lib/config/src/push.rs
@@ -0,0 +1,93 @@
1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use serde::Deserialize;
4
5/// Web Push(RFC8030 + RFC8292)投递配置。
6///
7/// `vapid_private_key` / `vapid_public_key` 都是 base64url(无填充)的 P-256
8/// 密钥材料:私钥是 32 字节标量,公钥是 65 字节非压缩点。这里只做形状校验
9/// (长度与编码),密钥本身能否构成一对由 `push` crate 在 worker 启动时用
10/// 真正的曲线运算确认——那一步失败会让 apihost 直接退出,不静默丢推送。
11#[derive(Clone, Debug, Deserialize)]
12#[serde(default)]
13pub struct PushConfig {
14 pub enabled: bool,
15 pub vapid_public_key: String,
16 pub vapid_private_key: String,
17 /// VAPID `sub` 声明:通常是 `mailto:ops@example.com` 或站点 URL。
18 pub vapid_subject: String,
19 pub workers: usize,
20 pub max_attempts: u32,
21 pub retry_backoff_base_secs: u64,
22 pub request_timeout_secs: u64,
23 /// 推送消息在推送服务上的存活时间(TTL 头)。
24 pub ttl_secs: u64,
25}
26
27impl Default for PushConfig {
28 fn default() -> Self {
29 Self {
30 enabled: false,
31 vapid_public_key: String::new(),
32 vapid_private_key: String::new(),
33 vapid_subject: String::new(),
34 workers: 2,
35 max_attempts: 3,
36 retry_backoff_base_secs: 30,
37 request_timeout_secs: 10,
38 ttl_secs: 86_400,
39 }
40 }
41}
42
43impl PushConfig {
44 pub fn is_valid(&self) -> Result<(), String> {
45 if !self.enabled {
46 return Ok(());
47 }
48 let public = decode_key(&self.vapid_public_key)
49 .ok_or_else(|| "push.vapid_public_key must be base64url".to_string())?;
50 if public.len() != 65 || public.first() != Some(&4) {
51 return Err(
52 "push.vapid_public_key must be a 65-byte uncompressed P-256 point".to_string(),
53 );
54 }
55 let private = decode_key(&self.vapid_private_key)
56 .ok_or_else(|| "push.vapid_private_key must be base64url".to_string())?;
57 if private.len() != 32 {
58 return Err("push.vapid_private_key must decode to a 32-byte scalar".to_string());
59 }
60 if !self.vapid_subject.trim().starts_with("mailto:")
61 && !self.vapid_subject.trim().starts_with("https://")
62 {
63 return Err("push.vapid_subject must be a mailto: address or https:// URL".to_string());
64 }
65 if self.workers == 0
66 || self.max_attempts == 0
67 || self.request_timeout_secs == 0
68 || self.ttl_secs == 0
69 {
70 return Err("push numeric settings must be greater than zero".to_string());
71 }
72 Ok(())
73 }
74
75 pub fn public_key_bytes(&self) -> Option<Vec<u8>> {
76 decode_key(&self.vapid_public_key)
77 }
78
79 pub fn private_key_bytes(&self) -> Option<Vec<u8>> {
80 decode_key(&self.vapid_private_key)
81 }
82}
83
84fn decode_key(raw: &str) -> Option<Vec<u8>> {
85 let trimmed = raw.trim();
86 if trimmed.is_empty() {
87 return None;
88 }
89 URL_SAFE_NO_PAD
90 .decode(trimmed.trim_end_matches('='))
91 .ok()
92 .filter(|bytes| !bytes.is_empty())
93}
Modified lib/config/src/scoped.rs View lib/config/src/scoped.rs
@@ -21,6 +21,7 @@
2121use crate::issues::IssueConfig;
2222use crate::livekit::LiveKitConfig;
2323use crate::pow::PowConfig;
24use crate::push::PushConfig;
2425use crate::quota::QuotaConfig;
2526use crate::redis::RedisConfig;
2627use crate::s3::S3Config;
@@ -213,6 +214,7 @@
213214 pub import: ImportConfig,
214215 pub ai: AiConfig,
215216 pub livekit: LiveKitConfig,
217 pub push: PushConfig,
216218}
217219
218220impl ApihostConfig {
@@ -239,6 +241,7 @@
239241 import: self.import,
240242 ai: self.ai,
241243 livekit: self.livekit,
244 push: self.push,
242245 ..Default::default()
243246 }
244247 }
@@ -259,7 +262,8 @@
259262 self.import.validate()?;
260263 self.apihost.is_valid()?;
261264 self.ai.validate()?;
262 self.livekit.is_valid()
265 self.livekit.is_valid()?;
266 self.push.is_valid()
263267 }
264268}
265269
Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -6,6 +6,7 @@
66pub mod middleware;
77pub mod notify;
88pub mod pagination;
9pub mod push;
910pub mod redis;
1011pub mod rest;
1112pub mod serde_patch;
@@ -44,7 +45,10 @@
4445
4546 pub async fn with_config(config: AppConfig) -> Result<Self, ExportError> {
4647 let database = Arc::new(AppDatabase::connect(config.database.clone()).await?);
47 let redis = AppRedis::connect(config.redis.clone()).await?;
48 let mut redis = AppRedis::connect(config.redis.clone()).await?;
49 // 入队点遍布各业务 crate,但只有这里有配置:把开关写进句柄,让
50 // `export::push::enqueue` 能自己判断该不该产生任务。
51 redis.set_push_enabled(config.push.enabled);
4852 let storage = Arc::new(AppStorage::connect(config.s3.clone())?);
4953 let redis_url = redis.config().url().to_string();
5054 let builder = email::EmailServiceBuilder::new(config.email.clone())
Modified lib/export/src/notify.rs View lib/export/src/notify.rs
@@ -30,6 +30,15 @@
3030 if let Err(error) = redis.publish(&channel, &payload).await {
3131 tracing::warn!(%error, %channel, "user event publish failed");
3232 }
33 // Every `notification` user event is also a candidate for an OS push.
34 // Enqueueing here — rather than at each of the nine writers — is what makes
35 // "websocket fan-out and push fan-out never drift" true by construction.
36 if event.get("kind").and_then(Value::as_str) == Some("notification")
37 && let Some(notification) = event.get("notification")
38 && let Some(job) = crate::push::PushJob::from_notification(uid, notification)
39 {
40 crate::push::enqueue(redis, &job).await;
41 }
3342}
3443
3544pub async fn publish_notification(redis: &AppRedis, uid: Uuid, notification: Value) {
Added lib/export/src/push.rs View lib/export/src/push.rs
@@ -0,0 +1,179 @@
1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use uuid::Uuid;
4
5use crate::error::ExportError;
6use crate::redis::AppRedis;
7
8/// 推送任务队列(LPUSH/BRPOP)与死信队列的固定键名,与 `chat:user:{uid}`
9/// 一类键同属服务内部约定,不做配置化——入队点遍布各业务 crate,只有固定
10/// 键名才能让它们无需感知配置。
11pub const QUEUE_TOPIC: &str = "push:jobs";
12pub const DLQ_TOPIC: &str = "push:jobs:dlq";
13
14/// 活跃聊天 WS 连接的在线标记;由 im 网关续期,推送 worker 用它跳过在线用户。
15pub fn online_key(uid: Uuid) -> String {
16 format!("chat:ws:{uid}")
17}
18
19/// 推送任务的分类,决定 `user_preferences.push_flags` 里哪一位能把它关掉。
20///
21/// 位语义是"禁用位":`push_flags == 0`(存量默认)表示四类全开,只有用户
22/// 显式关掉某一类时才置位,所以新老账号都不会因为新增字段而静默收不到推送。
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum PushCategory {
26 Mention,
27 Reply,
28 Dm,
29 Reminder,
30 /// 工单、邀请、AI 评审、系统公告等:不受逐类开关控制。
31 Other,
32}
33
34pub const PUSH_FLAG_MENTION: i32 = 1 << 0;
35pub const PUSH_FLAG_REPLY: i32 = 1 << 1;
36pub const PUSH_FLAG_DM: i32 = 1 << 2;
37pub const PUSH_FLAG_REMINDER: i32 = 1 << 3;
38
39impl PushCategory {
40 pub fn as_str(self) -> &'static str {
41 match self {
42 Self::Mention => "mention",
43 Self::Reply => "reply",
44 Self::Dm => "dm",
45 Self::Reminder => "reminder",
46 Self::Other => "other",
47 }
48 }
49
50 /// 该类通知对应的禁用位;`Other` 不受任何位控制。
51 pub fn disable_flag(self) -> Option<i32> {
52 match self {
53 Self::Mention => Some(PUSH_FLAG_MENTION),
54 Self::Reply => Some(PUSH_FLAG_REPLY),
55 Self::Dm => Some(PUSH_FLAG_DM),
56 Self::Reminder => Some(PUSH_FLAG_REMINDER),
57 Self::Other => None,
58 }
59 }
60
61 pub fn is_disabled_by(self, push_flags: i32) -> bool {
62 self.disable_flag()
63 .is_some_and(|flag| push_flags & flag != 0)
64 }
65
66 /// 由站内通知的形状反推分类:通知类型定大类,`data.kind` 细分提醒。
67 pub fn from_notification(notification_type: &str, data_kind: Option<&str>) -> Self {
68 match notification_type {
69 "mention" => Self::Mention,
70 "reply" => Self::Reply,
71 _ => match data_kind {
72 Some("reminder") => Self::Reminder,
73 _ => Self::Other,
74 },
75 }
76 }
77}
78
79/// 一个待投递的推送任务;队列载荷就是它的 JSON 序列化结果。
80///
81/// 标题与正文在入队前就按收件人语言渲染好(业务侧已经持有 DB 上下文),
82/// 头像、作者显示名与点击 URL 则由 worker 组装:前者需要预签名,后者只需要
83/// `data` 里的路由 slug,两者都不值得让每个写入方各做一遍。
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct PushJob {
86 pub user_id: Uuid,
87 pub category: PushCategory,
88 /// 站内通知的标题,worker 会在前面拼上作者显示名。
89 pub title: String,
90 pub body: Option<String>,
91 #[serde(default)]
92 pub data: Option<Value>,
93 #[serde(default)]
94 pub actor_id: Option<Uuid>,
95 /// 站内通知主键;仅用于日志关联,不参与投递决策。
96 #[serde(default)]
97 pub notification_id: Option<Uuid>,
98}
99
100impl PushJob {
101 /// 把一个 `user_notifications` 事件载荷转成推送任务;形状不对时返回 `None`。
102 pub fn from_notification(user_id: Uuid, notification: &Value) -> Option<Self> {
103 let notification_type = notification.get("notification_type")?.as_str()?;
104 let title = notification.get("title")?.as_str()?.to_string();
105 let body = notification
106 .get("body")
107 .and_then(Value::as_str)
108 .map(str::to_string);
109 let data = notification.get("data").cloned();
110 let actor_id = notification
111 .get("actor_id")
112 .and_then(Value::as_str)
113 .and_then(|raw| Uuid::parse_str(raw).ok());
114 let notification_id = notification
115 .get("id")
116 .and_then(Value::as_str)
117 .and_then(|raw| Uuid::parse_str(raw).ok());
118 let data_kind = data
119 .as_ref()
120 .and_then(|value| value.get("kind"))
121 .and_then(Value::as_str);
122 Some(Self {
123 user_id,
124 category: PushCategory::from_notification(notification_type, data_kind),
125 title,
126 body,
127 data,
128 actor_id,
129 notification_id,
130 })
131 }
132
133 pub fn encode(&self) -> Result<Vec<u8>, ExportError> {
134 serde_json::to_vec(self).map_err(|error| ExportError::Unsupported(error.to_string()))
135 }
136
137 pub fn decode(payload: &[u8]) -> Result<Self, ExportError> {
138 serde_json::from_slice(payload)
139 .map_err(|error| ExportError::Unsupported(format!("invalid push job: {error}")))
140 }
141}
142
143/// 入队一个推送任务;队列不可用时只记日志,绝不影响调用方已经提交的业务写入。
144///
145/// `[push] enabled = false` 时这里直接返回(标志由 `AppState` 在启动时写进
146/// `AppRedis`):关闭推送的部署不该往队列里堆积没有消费者的任务。调用方不需要
147/// 知道这个开关,所以入队点可以遍布各业务 crate。
148pub async fn enqueue(redis: &AppRedis, job: &PushJob) {
149 if !redis.push_enabled() {
150 return;
151 }
152 let payload = match job.encode() {
153 Ok(payload) => payload,
154 Err(error) => {
155 tracing::warn!(%error, user = %job.user_id, "push job encode failed");
156 return;
157 }
158 };
159 if let Err(error) = redis
160 .lpush_capped_bytes(QUEUE_TOPIC, &payload, MAX_QUEUE_DEPTH)
161 .await
162 {
163 tracing::warn!(%error, user = %job.user_id, "push job enqueue failed");
164 }
165}
166
167/// 队列长度上限。
168///
169/// 推送是"尽力而为":消费者长时间不可用(部署中断、worker 崩溃)时,真正有
170/// 价值的做法是丢弃最旧的任务而不是让 Redis 一直涨。这里按条数设硬上限,
171/// 超出的旧任务由 `LPUSH` 后的 `LTRIM` 直接丢掉。
172pub const MAX_QUEUE_DEPTH: i64 = 50_000;
173
174/// 死信:重试耗尽或明确不可重试的任务留在 DLQ 里供人工排查。
175pub async fn enqueue_dlq(redis: &AppRedis, payload: &[u8]) {
176 if let Err(error) = redis.lpush_bytes(DLQ_TOPIC, payload).await {
177 tracing::warn!(%error, "push job dlq enqueue failed");
178 }
179}
Modified lib/export/src/redis.rs View lib/export/src/redis.rs
@@ -32,6 +32,8 @@
3232 /// Connection parameters (auth, tls) of the first configured node,
3333 /// reused to open per-node connections for cluster-wide operations.
3434 node_info: Option<ConnectionInfo>,
35 /// Web Push 是否启用;入队点用它避免往无人消费的队列里写任务。
36 push_enabled: bool,
3537}
3638
3739impl AppRedis {
@@ -86,6 +88,7 @@
8688 connections,
8789 cursor: Arc::new(AtomicUsize::new(0)),
8890 node_info,
91 push_enabled: false,
8992 })
9093 }
9194
@@ -132,6 +135,179 @@
132135 result.map(|_| ()).map_err(ExportError::from)
133136 }
134137
138 /// 列表左端入队(`LPUSH`);与 `AppRedis::publish` 一样按 standalone/cluster
139 /// 双分支实现,payload 是二进制安全的字节串。
140 pub async fn lpush_bytes(&self, key: &str, payload: &[u8]) -> Result<i64, ExportError> {
141 let mut conn = self.connection_clone();
142 let result: Result<i64, redis::RedisError> = match &mut conn {
143 RedisConnection::Standalone(c) => {
144 redis::cmd("LPUSH")
145 .arg(key)
146 .arg(payload)
147 .query_async(c)
148 .await
149 }
150 RedisConnection::Cluster(c) => {
151 redis::cmd("LPUSH")
152 .arg(key)
153 .arg(payload)
154 .query_async(c)
155 .await
156 }
157 };
158 result.map_err(ExportError::from)
159 }
160 /// 推送队列是否有人消费;由 `AppState` 在构建时从 `[push] enabled` 写入。
161 ///
162 /// 放在 `AppRedis` 上是因为它是唯一"已经流到每个入队点"的句柄:否则要么
163 /// 每个业务 crate 都拿一次配置,要么关闭推送的部署会无限堆积死任务。
164 pub fn set_push_enabled(&mut self, enabled: bool) {
165 self.push_enabled = enabled;
166 }
167
168 #[must_use]
169 pub fn push_enabled(&self) -> bool {
170 self.push_enabled
171 }
172
173 /// `LPUSH` 后把列表裁到 `max_len`,超出部分丢最旧的任务。
174 pub async fn lpush_capped_bytes(
175 &self,
176 key: &str,
177 payload: &[u8],
178 max_len: i64,
179 ) -> Result<i64, ExportError> {
180 let mut conn = self.connection_clone();
181 let result: Result<i64, redis::RedisError> = match &mut conn {
182 RedisConnection::Standalone(c) => {
183 let len: i64 = redis::cmd("LPUSH")
184 .arg(key)
185 .arg(payload)
186 .query_async(c)
187 .await?;
188 if len > max_len {
189 let _: Result<redis::Value, redis::RedisError> = redis::cmd("LTRIM")
190 .arg(key)
191 .arg(0)
192 .arg(max_len - 1)
193 .query_async(c)
194 .await;
195 }
196 Ok(len)
197 }
198 RedisConnection::Cluster(c) => {
199 let len: i64 = redis::cmd("LPUSH")
200 .arg(key)
201 .arg(payload)
202 .query_async(c)
203 .await?;
204 if len > max_len {
205 let _: Result<redis::Value, redis::RedisError> = redis::cmd("LTRIM")
206 .arg(key)
207 .arg(0)
208 .arg(max_len - 1)
209 .query_async(c)
210 .await;
211 }
212 Ok(len)
213 }
214 };
215 result.map_err(ExportError::from)
216 }
217
218 /// 阻塞式右端出队(`BRPOP key timeout`);超时返回 `None`。
219 ///
220 /// 走独立连接而不是连接池:BRPOP 会独占 socket 整个超时窗口,池化连接被
221 /// 它占住会让同一进程里的其它 Redis 调用一起排长队。
222 pub async fn brpop_bytes(
223 &self,
224 key: &str,
225 timeout_secs: u64,
226 ) -> Result<Option<Vec<u8>>, ExportError> {
227 let mut conn = self.blocking_consumer_connection(key).await?;
228 let result: Option<(String, Vec<u8>)> = redis::cmd("BRPOP")
229 .arg(key)
230 .arg(timeout_secs.max(1))
231 .query_async(&mut conn)
232 .await
233 .map_err(ExportError::from)?;
234 Ok(result.map(|(_, payload)| payload))
235 }
236
237 /// 打开一条专供阻塞命令的连接。
238 ///
239 /// cluster 下 BRPOP 落在持有该 key slot 的 master 上,否则会收到 MOVED;
240 /// standalone/sentinel 直接用首个节点。
241 async fn blocking_consumer_connection(
242 &self,
243 key: &str,
244 ) -> Result<MultiplexedConnection, ExportError> {
245 let client = if self.is_cluster() {
246 let node_info = self.node_info.clone().ok_or_else(|| {
247 ExportError::InvalidConfig("cluster connection info is unavailable".into())
248 })?;
249 let mut conn = self.connection_clone();
250 let RedisConnection::Cluster(cluster) = &mut conn else {
251 return Err(ExportError::InvalidConfig(
252 "cluster connection missing".into(),
253 ));
254 };
255 let slot: u16 = redis::cmd("CLUSTER")
256 .arg("KEYSLOT")
257 .arg(key)
258 .query_async(cluster)
259 .await
260 .map_err(ExportError::from)?;
261 let slots: Value = redis::cmd("CLUSTER")
262 .arg("SLOTS")
263 .query_async(cluster)
264 .await
265 .map_err(ExportError::from)?;
266 let (host, port) = cluster_master_for_slot(&slots, slot)
267 .ok_or_else(|| ExportError::InvalidConfig(format!("no master owns slot {slot}")))?;
268 let addr = match node_info.addr() {
269 ConnectionAddr::Tcp(_, _) => ConnectionAddr::Tcp(host, port),
270 ConnectionAddr::TcpTls {
271 insecure,
272 tls_params,
273 ..
274 } => ConnectionAddr::TcpTls {
275 host,
276 port,
277 insecure: *insecure,
278 tls_params: tls_params.clone(),
279 },
280 ConnectionAddr::Unix(_) => {
281 return Err(ExportError::InvalidConfig(
282 "unix sockets are not supported for cluster connections".into(),
283 ));
284 }
285 _ => {
286 return Err(ExportError::InvalidConfig(
287 "unsupported connection address type for cluster connections".into(),
288 ));
289 }
290 };
291 redis::Client::open(node_info.set_addr(addr)).map_err(ExportError::from)?
292 } else {
293 let url = self
294 .config
295 .url()
296 .split(',')
297 .map(str::trim)
298 .find(|node| !node.is_empty())
299 .ok_or_else(|| ExportError::InvalidConfig("redis url contains no nodes".into()))?;
300 redis::Client::open(url).map_err(ExportError::from)?
301 };
302 let config = redis::AsyncConnectionConfig::new()
303 .set_response_timeout(None)
304 .set_connection_timeout(Some(self.config.connection_timeout()));
305 client
306 .get_multiplexed_async_connection_with_config(&config)
307 .await
308 .map_err(ExportError::from)
309 }
310
135311 /// `SET key 1 NX EX ttl`; returns false when the key already exists
136312 /// (already consumed / inside an active window) or redis fails.
137313 pub async fn set_nx_ex(&self, key: &str, ttl_secs: u64) -> bool {
@@ -308,6 +484,37 @@
308484///
309485/// Each slot group is `[start, end, [ip, port, node_id, ...], replicas...]`,
310486/// where the first entry of the node list is the master.
487/// Resolves the master node that owns `slot` straight from `CLUSTER SLOTS`.
488///
489/// Unlike [`cluster_master_addrs`] this needs no de-duplication: the range that
490/// contains the slot names its own master in the same group.
491fn cluster_master_for_slot(slots: &Value, slot: u16) -> Option<(String, u16)> {
492 let Value::Array(groups) = slots else {
493 return None;
494 };
495 for group in groups {
496 let Value::Array(fields) = group else {
497 continue;
498 };
499 let (Some(Value::Int(start)), Some(Value::Int(end))) = (fields.first(), fields.get(1))
500 else {
501 continue;
502 };
503 if !(*start..=*end).contains(&(slot as i64)) {
504 continue;
505 }
506 let Some(Value::Array(nodes)) = fields.get(2) else {
507 continue;
508 };
509 let (Some(Value::BulkString(host)), Some(Value::Int(port))) = (nodes.first(), nodes.get(1))
510 else {
511 continue;
512 };
513 return Some((String::from_utf8_lossy(host).into_owned(), *port as u16));
514 }
515 None
516}
517
311518fn cluster_master_addrs(slots: &Value) -> Vec<(String, u16)> {
312519 let Value::Array(groups) = slots else {
313520 return Vec::new();
@@ -402,6 +609,57 @@
402609 dispatch!(conn, |c| redis::cmd("DEL").arg(key).exec_async(c).await)
403610}
404611
612const INCR_TOUCH_SCRIPT: &str = r#"local c = redis.call('INCR', KEYS[1])
613redis.call('EXPIRE', KEYS[1], ARGV[1])
614return c"#;
615
616/// `INCR` 并把存活时间重置为 `ttl_secs`,返回递增后的计数。
617///
618/// 与 [`incr_expire`] 的区别是**每次**都续期:那个是为限流窗口写的(窗口
619/// 从第一次计数开始算),这个是给心跳占位写的(每次心跳都要把到期时间往后推)。
620pub async fn incr_touch(
621 redis: &AppRedis,
622 key: &str,
623 ttl_secs: u64,
624) -> Result<i64, redis::RedisError> {
625 let mut conn = redis.connection_clone();
626 dispatch!(conn, |c| {
627 redis::cmd("EVAL")
628 .arg(INCR_TOUCH_SCRIPT)
629 .arg(1)
630 .arg(key)
631 .arg(ttl_secs.max(1))
632 .query_async(c)
633 .await
634 })
635}
636
637const DECR_DELETE_SCRIPT: &str = r#"local c = redis.call('DECR', KEYS[1])
638if c <= 0 then
639 redis.call('DEL', KEYS[1])
640 return 0
641end
642return c"#;
643
644/// 引用计数递减;归零时删键,返回剩余计数。
645///
646/// 一个用户可以同时开多个连接(多标签页、多设备同源会话),而"在线"是以键
647/// 存在与否表达的二值状态,所以计数减到 0 时必须把键删掉——否则最后一条连接
648/// 关闭后用户会一直显示在线,推送也就一直跳过。
649///
650/// 递减与删除放在一段脚本里:分开做的话,两次调用之间其它连接的心跳可能把
651/// 计数从 0 又推到 1,用户就会永远停在"在线"。
652pub async fn decr_delete(redis: &AppRedis, key: &str) -> Result<i64, redis::RedisError> {
653 let mut conn = redis.connection_clone();
654 dispatch!(conn, |c| {
655 redis::cmd("EVAL")
656 .arg(DECR_DELETE_SCRIPT)
657 .arg(1)
658 .arg(key)
659 .query_async(c)
660 .await
661 })
662}
405663pub async fn exists(redis: &AppRedis, key: &str) -> Result<bool, redis::RedisError> {
406664 let mut conn = redis.connection_clone();
407665 dispatch!(conn, |c| redis::cmd("EXISTS").arg(key).query_async(c).await)
Modified lib/i18n/src/catalog.rs View lib/i18n/src/catalog.rs
@@ -189,6 +189,21 @@
189189 }
190190}
191191
192/// 私信与群组私信:OS 推送标题只说明"是谁给你发了消息",作者名由推送
193/// worker 拼在前面;这两类不写站内收件箱,未读由聊天自己的未读计数承担。
194pub fn notification_direct_message_title(locale: UiLocale) -> &'static str {
195 match locale {
196 UiLocale::ZhCn => "给你发来一条私信",
197 UiLocale::En => "sent you a direct message",
198 }
199}
200
201pub fn notification_group_dm_title(locale: UiLocale) -> &'static str {
202 match locale {
203 UiLocale::ZhCn => "在群组私信中发了一条消息",
204 UiLocale::En => "sent a message in your group chat",
205 }
206}
192207pub fn notification_reply_title(locale: UiLocale) -> &'static str {
193208 match locale {
194209 UiLocale::ZhCn => "有人回复了你的消息",
Added lib/push/src/client.rs View lib/push/src/client.rs
@@ -0,0 +1,172 @@
1//! Web Push 投递核心:VAPID 签名、载荷加密、HTTP 提交与失败分级。
2//!
3//! 这一层只负责"把一段 JSON 变成系统通知":业务闸门(偏好位、在线状态、
4//! 静音)都在 `worker` 里,这里拿到的就是"确定要投"的载荷。
5
6use std::time::Duration;
7
8use base64::Engine as _;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use serde::{Deserialize, Serialize};
11use web_push_native::jwt_simple::algorithms::ES256KeyPair;
12use web_push_native::jwt_simple::prelude::ECDSAP256PublicKeyLike as _;
13use web_push_native::p256::elliptic_curve::sec1::FromEncodedPoint;
14use web_push_native::p256::{EncodedPoint, PublicKey};
15use web_push_native::{Auth, WebPushBuilder};
16
17use crate::error::PushError;
18
19/// 系统通知携带的载荷;Service Worker 的 `showNotification` 直接消费。
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct NotificationPayload {
22 pub title: String,
23 #[serde(default)]
24 pub body: Option<String>,
25 #[serde(default)]
26 pub icon: Option<String>,
27 /// 通知合并键;同一房间的连续消息只保留最后一条横幅。
28 #[serde(default)]
29 pub tag: Option<String>,
30 /// 点击通知要打开的绝对 URL。
31 pub url: String,
32}
33
34/// 服务端 VAPID 密钥对,来自 `[push]` 配置。
35///
36/// `ES256KeyPair` 不实现 `Clone`,所以共享的是 `Arc<VapidKeys>`;签名是纯计算,
37/// 多 worker 共用一份密钥没有任何并发问题。
38pub struct VapidKeys {
39 key_pair: ES256KeyPair,
40 subject: String,
41}
42
43impl VapidKeys {
44 /// 用真实曲线运算确认公私钥成对:配置校验只看形状,配错私钥时应该让进程
45 /// 在启动阶段就停下,而不是给每台设备都签出无效的 VAPID 头。
46 pub fn from_config(config: &config::push::PushConfig) -> Result<Self, PushError> {
47 let private = config
48 .private_key_bytes()
49 .ok_or_else(|| PushError::Config("push.vapid_private_key is unreadable".into()))?;
50 let public = config
51 .public_key_bytes()
52 .ok_or_else(|| PushError::Config("push.vapid_public_key is unreadable".into()))?;
53 let key_pair = ES256KeyPair::from_bytes(&private)
54 .map_err(|error| PushError::Config(format!("invalid VAPID private key: {error}")))?;
55 let derived = key_pair.public_key().public_key().to_bytes_uncompressed();
56 if derived != public {
57 return Err(PushError::Config(
58 "push.vapid_private_key does not match push.vapid_public_key".into(),
59 ));
60 }
61 Ok(Self {
62 key_pair,
63 subject: config.vapid_subject.trim().to_string(),
64 })
65 }
66
67 pub fn subject(&self) -> &str {
68 &self.subject
69 }
70}
71
72/// 一条浏览器订阅的密钥材料(`user_push_subscription` 的三列)。
73pub struct SubscriptionKeys {
74 pub endpoint: String,
75 pub p256dh_key: String,
76 pub auth_key: String,
77}
78
79/// 浏览器给的 `p256dh` / `auth` 是 base64url(可能带填充),这里解成曲线点与密钥。
80fn subscription_material(p256dh: &str, auth: &str) -> Result<(PublicKey, Auth), PushError> {
81 let decoded = URL_SAFE_NO_PAD
82 .decode(p256dh.trim().trim_end_matches('='))
83 .map_err(|error| PushError::Subscription(format!("invalid p256dh: {error}")))?;
84 let point = EncodedPoint::from_bytes(&decoded)
85 .map_err(|_| PushError::Subscription("p256dh is not a SEC1 point".into()))?;
86 let ua_public = Option::<PublicKey>::from(PublicKey::from_encoded_point(&point))
87 .ok_or_else(|| PushError::Subscription("p256dh is not on the curve".into()))?;
88 let auth_bytes = URL_SAFE_NO_PAD
89 .decode(auth.trim().trim_end_matches('='))
90 .map_err(|error| PushError::Subscription(format!("invalid auth key: {error}")))?;
91 let mut ua_auth = Auth::default();
92 if auth_bytes.len() != ua_auth.len() {
93 return Err(PushError::Subscription(format!(
94 "auth key must be {} bytes, got {}",
95 ua_auth.len(),
96 auth_bytes.len()
97 )));
98 }
99 ua_auth.copy_from_slice(&auth_bytes);
100 Ok((ua_public, ua_auth))
101}
102
103/// 把一段载荷加密成可以提交给推送服务的请求。
104///
105/// 生成的请求已经带上 RFC8030/8292 要求的全部头部(`TTL`、
106/// `Content-Encoding`、`Content-Type`、VAPID `Authorization`),调用方只需把
107/// 它原样送出去:自己再拼一遍头部只会让两处约定漂移。
108pub fn build_request(
109 keys: &VapidKeys,
110 subscription: &SubscriptionKeys,
111 payload: &NotificationPayload,
112 ttl_secs: u64,
113) -> Result<http::Request<Vec<u8>>, PushError> {
114 let (ua_public, ua_auth) =
115 subscription_material(&subscription.p256dh_key, &subscription.auth_key)?;
116 let endpoint: http::Uri = subscription
117 .endpoint
118 .trim()
119 .parse()
120 .map_err(|_| PushError::Subscription("endpoint is not a valid URL".into()))?;
121 let body = serde_json::to_vec(payload)
122 .map_err(|error| PushError::Internal(format!("payload encode: {error}")))?;
123 WebPushBuilder::new(endpoint, ua_public, ua_auth)
124 .with_valid_duration(Duration::from_secs(ttl_secs.max(1)))
125 .with_vapid(&keys.key_pair, keys.subject())
126 .build(body)
127 .map_err(|error| PushError::Internal(format!("web push build: {error}")))
128}
129
130/// 加密并提交一条推送;返回值是推送服务的判定。
131pub async fn deliver(
132 client: &reqwest::Client,
133 keys: &VapidKeys,
134 subscription: &SubscriptionKeys,
135 payload: &NotificationPayload,
136 ttl_secs: u64,
137) -> Result<DeliveryOutcome, PushError> {
138 let request = build_request(keys, subscription, payload, ttl_secs)?;
139 let mut outbound = client.post(request.uri().to_string());
140 for (name, value) in request.headers() {
141 outbound = outbound.header(name.as_str(), value.as_bytes());
142 }
143 let response = outbound
144 .body(request.into_body())
145 .send()
146 .await
147 .map_err(|error| PushError::Transport(error.to_string()))?;
148
149 Ok(classify_status(response.status().as_u16()))
150}
151
152/// 推送服务对一条投递的判定;worker 据此决定重试、丢弃还是删除订阅。
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum DeliveryOutcome {
155 Delivered,
156 /// 订阅已失效(`404` / `410`):调用方删除订阅行,不再重试。
157 Gone,
158 /// 暂时性失败(网络错误、`408`、`429`、`5xx`):可以重试。
159 Retry,
160 /// 永久性失败(其余 `4xx`):重试没有意义,直接进 DLQ。
161 Rejected,
162}
163
164pub fn classify_status(status: u16) -> DeliveryOutcome {
165 match status {
166 200..=299 => DeliveryOutcome::Delivered,
167 404 | 410 => DeliveryOutcome::Gone,
168 408 | 429 => DeliveryOutcome::Retry,
169 400..=499 => DeliveryOutcome::Rejected,
170 _ => DeliveryOutcome::Retry,
171 }
172}
Added lib/push/src/error.rs View lib/push/src/error.rs
@@ -0,0 +1,31 @@
1use std::fmt;
2
3#[derive(Debug)]
4pub enum PushError {
5 /// 配置不合法:缺密钥、密钥不成对、subject 格式错误等。
6 Config(String),
7 /// 订阅数据本身坏掉了(endpoint/密钥解析失败),该订阅不可修复。
8 Subscription(String),
9 /// 推送服务或网络层的暂时性失败。
10 Transport(String),
11 Internal(String),
12}
13
14impl fmt::Display for PushError {
15 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16 match self {
17 Self::Config(msg) => write!(f, "push config error: {msg}"),
18 Self::Subscription(msg) => write!(f, "push subscription error: {msg}"),
19 Self::Transport(msg) => write!(f, "push transport error: {msg}"),
20 Self::Internal(msg) => write!(f, "push internal error: {msg}"),
21 }
22 }
23}
24
25impl std::error::Error for PushError {}
26
27impl From<web_push_native::Error> for PushError {
28 fn from(error: web_push_native::Error) -> Self {
29 Self::Internal(error.to_string())
30 }
31}
Added lib/push/src/lib.rs View lib/push/src/lib.rs
@@ -0,0 +1,14 @@
1//! Web Push(RFC8030 + RFC8292)服务端实现。
2//!
3//! 分工:本 crate 负责"投递"(签名、加密、HTTP、重试分级),入队点分散在
4//! 各业务 crate,统一入口是 [`export::push::enqueue`];`export::notify` 在
5//! 发布站内通知事件时会自动入队,因此新写的通知天然带上推送,不需要各处
6//! 记得补一行。
7
8pub mod client;
9pub mod error;
10pub mod worker;
11
12pub use client::{DeliveryOutcome, NotificationPayload, SubscriptionKeys, VapidKeys};
13pub use error::PushError;
14pub use worker::PushWorker;
Added lib/push/src/worker.rs View lib/push/src/worker.rs
@@ -0,0 +1,348 @@
1//! Push worker:从 Redis 队列取任务,过闸门,逐设备投递。
2//!
3//! 闸门顺序是刻意的——先看偏好位(纯内存),再看在线状态(一次 Redis
4//! EXISTS),最后才回查数据库组装文案。用户关掉某类推送或已经在线时,
5//! worker 不做任何数据库往返。
6
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9use std::time::Duration;
10
11use export::AppState;
12use export::push::{PushJob, online_key};
13use models::users::user;
14use models::users::user_preferences;
15use models::users::user_push_subscriptions::{self, PushSubscriptionPlatform};
16use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
17use uuid::Uuid;
18
19use crate::client::{DeliveryOutcome, NotificationPayload, SubscriptionKeys, VapidKeys, deliver};
20
21use crate::error::PushError;
22
23/// 队列空转的阻塞时长;同时决定进程响应 shutdown 的粒度。
24const CONSUME_TIMEOUT_SECS: u64 = 5;
25
26pub struct PushWorker {
27 state: AppState,
28 keys: Arc<VapidKeys>,
29 client: reqwest::Client,
30 cancel: tokio_util::sync::CancellationToken,
31 /// 重试计数按任务指纹累计。
32 ///
33 /// 计数留在进程内是刻意的:持久化它需要再开一张表或改载荷字段,而代价
34 /// 只是进程重启后多投一次——对"尽力而为"的通知完全可接受。
35 attempts: Arc<Mutex<HashMap<String, u32>>>,
36}
37
38impl PushWorker {
39 pub fn new(
40 state: AppState,
41 cancel: tokio_util::sync::CancellationToken,
42 ) -> Result<Self, PushError> {
43 let keys = VapidKeys::from_config(&state.config.push)?;
44 let timeout = Duration::from_secs(state.config.push.request_timeout_secs.max(1));
45 let client = reqwest::Client::builder()
46 .timeout(timeout)
47 .build()
48 .map_err(|error| PushError::Internal(format!("building http client: {error}")))?;
49 Ok(Self {
50 state,
51 keys: Arc::new(keys),
52 client,
53 cancel,
54 attempts: Arc::new(Mutex::new(HashMap::new())),
55 })
56 }
57
58 pub async fn run(self) {
59 let topic = export::push::QUEUE_TOPIC.to_string();
60 loop {
61 let payload = tokio::select! {
62 _ = self.cancel.cancelled() => break,
63 result = self.state.redis.brpop_bytes(&topic, CONSUME_TIMEOUT_SECS) => {
64 match result {
65 Ok(Some(payload)) => payload,
66 Ok(None) => continue,
67 Err(error) => {
68 tracing::warn!(%error, "push queue consume failed");
69 tokio::time::sleep(Duration::from_secs(2)).await;
70 continue;
71 }
72 }
73 }
74 };
75
76 let job = match PushJob::decode(&payload) {
77 Ok(job) => job,
78 Err(error) => {
79 tracing::warn!(%error, "dropping malformed push job");
80 export::push::enqueue_dlq(&self.state.redis, &payload).await;
81 continue;
82 }
83 };
84
85 if let Err(error) = self.handle(&job).await {
86 tracing::warn!(
87 %error,
88 user = %job.user_id,
89 category = job.category.as_str(),
90 "push job failed"
91 );
92 }
93 }
94 tracing::debug!("push worker stopped");
95 }
96
97 /// 一条任务的完整流程:闸门 → 组装 → 逐订阅投递。
98 async fn handle(&self, job: &PushJob) -> Result<(), PushError> {
99 if self.skip_by_preferences(job).await? {
100 self.forget(job);
101 return Ok(());
102 }
103 if self.skip_online(job.user_id).await {
104 self.forget(job);
105 return Ok(());
106 }
107
108 let subscriptions = user_push_subscriptions::Entity::find()
109 .filter(user_push_subscriptions::Column::UserId.eq(job.user_id))
110 .filter(user_push_subscriptions::Column::Platform.eq(PushSubscriptionPlatform::WebPush))
111 .all(self.state.database.read())
112 .await
113 .map_err(|error| PushError::Internal(format!("subscription lookup failed: {error}")))?;
114 if subscriptions.is_empty() {
115 self.forget(job);
116 return Ok(());
117 }
118
119 let payload = self.build_payload(job).await?;
120 let ttl_secs = self.state.config.push.ttl_secs;
121 let mut retry = false;
122 for subscription in subscriptions {
123 let keys = SubscriptionKeys {
124 endpoint: subscription.endpoint.clone(),
125 p256dh_key: subscription.p256dh_key.clone(),
126 auth_key: subscription.auth_key.clone(),
127 };
128 match deliver(&self.client, &self.keys, &keys, &payload, ttl_secs).await {
129 Ok(DeliveryOutcome::Delivered) => {
130 tracing::debug!(user = %job.user_id, "push delivered");
131 }
132 Ok(DeliveryOutcome::Gone) => {
133 // 用户清了浏览器数据或卸载了站点:一次性清理,不是错误。
134 tracing::debug!(user = %job.user_id, "push subscription expired; removing");
135 if let Err(error) =
136 user_push_subscriptions::Entity::delete_by_id(subscription.id)
137 .exec(self.state.database.write())
138 .await
139 {
140 tracing::warn!(%error, "failed to remove expired push subscription");
141 }
142 }
143 Ok(DeliveryOutcome::Rejected) => {
144 tracing::warn!(
145 user = %job.user_id,
146 endpoint = %subscription.endpoint,
147 "push rejected by service; sending to dlq"
148 );
149 self.to_dlq(job).await;
150 self.forget(job);
151 return Ok(());
152 }
153 Ok(DeliveryOutcome::Retry) => retry = true,
154 Err(PushError::Subscription(message)) => {
155 // 订阅本身不可修(endpoint 或密钥解析失败),重投没有意义。
156 tracing::warn!(user = %job.user_id, %message, "dropping broken subscription");
157 if let Err(error) =
158 user_push_subscriptions::Entity::delete_by_id(subscription.id)
159 .exec(self.state.database.write())
160 .await
161 {
162 tracing::warn!(%error, "failed to remove broken push subscription");
163 }
164 }
165 Err(error) => {
166 tracing::warn!(%error, user = %job.user_id, "push delivery error");
167 retry = true;
168 }
169 }
170 }
171
172 if retry {
173 self.retry_or_drop(job).await;
174 } else {
175 self.forget(job);
176 }
177 Ok(())
178 }
179
180 /// 用户偏好里的禁用位;`Other` 类不受位控制,只受"是否有订阅"约束。
181 async fn skip_by_preferences(&self, job: &PushJob) -> Result<bool, PushError> {
182 let Some(flags) = user_preferences::Entity::find_by_id(job.user_id)
183 .one(self.state.database.read())
184 .await
185 .map_err(|error| PushError::Internal(format!("preferences lookup failed: {error}")))?
186 else {
187 // 没有偏好行 = 默认全开。
188 return Ok(false);
189 };
190 Ok(job.category.is_disabled_by(flags.push_flags))
191 }
192
193 /// "用户在线"= 任意标签页持有聊天 WS 连接;此时站内 toast 已经送达,
194 /// 再弹系统通知就是重复提醒。
195 async fn skip_online(&self, uid: Uuid) -> bool {
196 export::redis::exists(&self.state.redis, &online_key(uid))
197 .await
198 .unwrap_or(false)
199 }
200
201 /// 文案与深链的最终组装:作者名、头像、点击 URL 都需要数据库或存储。
202 async fn build_payload(&self, job: &PushJob) -> Result<NotificationPayload, PushError> {
203 let (title, icon) = match job.actor_id {
204 Some(actor_id) => {
205 let actor = user::Entity::find_by_id(actor_id)
206 .one(self.state.database.read())
207 .await
208 .map_err(|error| {
209 PushError::Internal(format!("actor lookup failed: {error}"))
210 })?;
211 match actor {
212 Some(actor) => (
213 format!("{} · {}", actor.display_name, job.title),
214 self.avatar_url(actor_id, &actor.avatar_url).await,
215 ),
216 None => (job.title.clone(), None),
217 }
218 }
219 None => (job.title.clone(), None),
220 };
221
222 Ok(NotificationPayload {
223 title,
224 body: job.body.clone(),
225 icon,
226 tag: job
227 .data
228 .as_ref()
229 .and_then(|data| data.get("room_id"))
230 .and_then(|value| value.as_str())
231 .map(|room_id| format!("chat:{room_id}")),
232 url: self.target_url(job),
233 })
234 }
235
236 /// 头像在库里存的是 `storage://` locator,预签名后才是浏览器可取到的
237 /// 地址;外链头像(导入数据)原样透传,空值表示前端渲染首字母。
238 async fn avatar_url(&self, uid: Uuid, stored: &str) -> Option<String> {
239 if stored.is_empty() {
240 return None;
241 }
242 if !stored.starts_with("storage://") {
243 return Some(stored.to_string());
244 }
245 let key = stored.strip_prefix("storage://")?;
246 if key.is_empty() {
247 return None;
248 }
249 match self
250 .state
251 .storage
252 .client()
253 .presign_get(key, 7 * 24 * 60 * 60)
254 {
255 Ok(url) => Some(url),
256 Err(error) => {
257 tracing::warn!(%error, %uid, "avatar presign failed");
258 None
259 }
260 }
261 }
262
263 /// 点击行为:与前端 `notificationTarget` 的规则保持一致,避免通知落到
264 /// 一个前端不认识的地址上;`data` 缺字段时退到站内通知中心。
265 fn target_url(&self, job: &PushJob) -> String {
266 let base = self.state.config.web.web_base_url.trim_end_matches('/');
267 let fallback = format!("{base}/app/@me/notify");
268 let Some(data) = job.data.as_ref() else {
269 return fallback;
270 };
271 let field = |key: &str| data.get(key).and_then(|value| value.as_str());
272 let scope = field("space_name").unwrap_or("@me");
273 if let Some(room_id) = field("room_id") {
274 let jump = field("message_id")
275 .map(|message_id| format!("?jump={message_id}"))
276 .unwrap_or_default();
277 return format!("{base}/app/{scope}/chat/{room_id}{jump}");
278 }
279 if let (Some(board_id), Some(issue_number)) = (field("board_id"), field("autoincrement_id"))
280 {
281 return format!("{base}/app/{scope}/board/{board_id}?issue={issue_number}");
282 }
283 if scope != "@me" {
284 return format!("{base}/app/{scope}");
285 }
286 fallback
287 }
288
289 /// 暂时性失败:退避后重投,超过上限进 DLQ。
290 ///
291 /// 退避在独立任务里等待而不是就地 `sleep`:队列是单个 FIFO,就地等待会让
292 /// 一条坏任务把整个消费者按分钟级卡住,后面所有用户的推送跟着排长队。
293 async fn retry_or_drop(&self, job: &PushJob) {
294 let config = self.state.config.push.clone();
295 let attempt = {
296 let mut attempts = match self.attempts.lock() {
297 Ok(guard) => guard,
298 Err(_) => return,
299 };
300 let entry = attempts.entry(job_key(job)).or_insert(0);
301 *entry += 1;
302 *entry
303 };
304 if attempt >= config.max_attempts.max(1) {
305 tracing::warn!(user = %job.user_id, attempt, "push permanently failed; sending to dlq");
306 self.to_dlq(job).await;
307 self.forget(job);
308 return;
309 }
310 let backoff = config.retry_backoff_base_secs.max(1) * u64::from(attempt);
311 let redis = self.state.redis.clone();
312 let cancel = self.cancel.clone();
313 let Ok(payload) = job.encode() else {
314 return;
315 };
316 let user_id = job.user_id;
317 tokio::spawn(async move {
318 tokio::select! {
319 _ = cancel.cancelled() => return,
320 _ = tokio::time::sleep(Duration::from_secs(backoff)) => {}
321 }
322 if let Err(error) = redis.lpush_bytes(export::push::QUEUE_TOPIC, &payload).await {
323 tracing::warn!(%error, %user_id, "push requeue failed");
324 }
325 });
326 }
327
328 async fn to_dlq(&self, job: &PushJob) {
329 if let Ok(payload) = job.encode() {
330 export::push::enqueue_dlq(&self.state.redis, &payload).await;
331 }
332 }
333
334 fn forget(&self, job: &PushJob) {
335 if let Ok(mut attempts) = self.attempts.lock() {
336 attempts.remove(&job_key(job));
337 }
338 }
339}
340
341/// 同一任务的稳定指纹:一条站内通知的重投必须落回同一个计数桶。私信推送
342/// 没有通知行,退化成"收件人 + 分类 + 标题",对同一房间的消息足够唯一。
343fn job_key(job: &PushJob) -> String {
344 match job.notification_id {
345 Some(id) => id.to_string(),
346 None => format!("{}:{}:{}", job.user_id, job.category.as_str(), job.title),
347 }
348}
Added lib/push/Cargo.toml View lib/push/Cargo.toml
@@ -0,0 +1,22 @@
1[package]
2name = "push"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7config = { workspace = true }
8export = { workspace = true }
9models = { workspace = true }
10base64 = { workspace = true }
11serde = { workspace = true, features = ["derive"] }
12serde_json = { workspace = true }
13tracing = { workspace = true }
14uuid = { workspace = true }
15sea-orm = { workspace = true }
16reqwest = { workspace = true }
17tokio = { workspace = true, features = ["time", "sync", "rt"] }
18tokio-util = { workspace = true }
19# `web-push-native` 只提供无 io 的"生成密文请求"能力,HTTP 用本仓既有的
20# reqwest(rustls);两者都必须落在 http 1.x 上,否则 `http::Uri` 不是同一个类型。
21http = "1"
22web-push-native = "0.5"
Modified lib/user/src/handlers/mod.rs View lib/user/src/handlers/mod.rs
@@ -16,6 +16,7 @@
1616pub mod my_reports;
1717pub mod my_spaces;
1818pub mod my_suspensions;
19pub mod push_config;
1920pub mod recent_mentions;
2021pub mod revoke_all_sessions;
2122pub mod revoke_session;
Added lib/user/src/handlers/push_config.rs View lib/user/src/handlers/push_config.rs
@@ -0,0 +1,38 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use serde::Serialize;
4use utoipa::ToSchema;
5
6use crate::services::service::UserService;
7
8/// 前端注册 Service Worker 所需的公开参数。
9///
10/// 只有公钥是公开的(它就是浏览器 `applicationServerKey` 要的东西),私钥永远
11/// 不出现在任何响应里。`enabled=false` 时前端把整个"系统通知"开关藏起来,
12/// 而不是让用户点了才发现服务端没开。
13#[derive(Debug, Clone, Serialize, ToSchema)]
14pub struct PushConfigDto {
15 pub enabled: bool,
16 pub public_key: String,
17}
18
19#[utoipa::path(
20 get,
21 path = "/push/config",
22 tag = "push",
23 description = "读取 Web Push 是否启用及其 VAPID 公钥(注册推送订阅所需,无会话要求)。",
24 responses(
25 (status = 200, description = "web push configuration", body = AppResponseData<PushConfigDto>)
26 )
27)]
28pub async fn push_config(State(service): State<UserService>) -> AppResponseData<PushConfigDto> {
29 let push = &service.config().push;
30 AppResponseData::ok(PushConfigDto {
31 enabled: push.enabled,
32 public_key: if push.enabled {
33 push.vapid_public_key.trim().to_string()
34 } else {
35 String::new()
36 },
37 })
38}
Modified lib/user/src/openapi.rs View lib/user/src/openapi.rs
@@ -50,6 +50,7 @@
5050 handlers::save_message::save_message,
5151 handlers::unsave_message::unsave_message,
5252 handlers::my_suspensions::my_suspensions,
53 handlers::push_config::push_config,
5354 ),
5455 components(schemas(
5556 export::pagination::PageQuery,
@@ -79,6 +80,7 @@
7980 SaveMessageCmd,
8081 SuspensionDto,
8182 IncludeRevokedQuery,
83 crate::handlers::push_config::PushConfigDto,
8284 ProfileVisibility,
8385 SessionKind,
8486 PushSubscriptionPlatform,
Modified lib/user/src/routes.rs View lib/user/src/routes.rs
@@ -32,8 +32,12 @@
3232use crate::share::guard::user_guard;
3333
3434pub fn router(service: UserService) -> Router<UserService> {
35 let public: Router<UserService> =
36 Router::new().route("/users/{user_id}/profile-detail", get(get_profile_detail));
35 let public: Router<UserService> = Router::new()
36 .route("/users/{user_id}/profile-detail", get(get_profile_detail))
37 .route(
38 "/push/config",
39 get(crate::handlers::push_config::push_config),
40 );
3741
3842 let protected = Router::new()
3943 .route("/users/{user_id}/avatar", get(get_user_avatar))
Modified lib/webapi/src/spa.rs View lib/webapi/src/spa.rs
@@ -7,6 +7,10 @@
77const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable";
88const CACHE_NO_CACHE: &str = "no-cache";
99const CACHE_REVALIDATE: &str = "public, max-age=3600, must-revalidate";
10/// Service worker path, mirrored from `web/public/sw.js`.
11const SERVICE_WORKER: &str = "sw.js";
12/// PWA manifest path, mirrored from `web/public/manifest.webmanifest`.
13const WEB_MANIFEST: &str = "manifest.webmanifest";
1014
1115fn etags() -> &'static HashMap<&'static str, HeaderValue> {
1216 static ETAGS: OnceLock<HashMap<&'static str, HeaderValue>> = OnceLock::new();
@@ -34,7 +38,11 @@
3438fn cache_control(path: &str) -> &'static str {
3539 if path.starts_with("assets/") {
3640 CACHE_IMMUTABLE
37 } else if path == "index.html" {
41 } else if path == "index.html" || path == SERVICE_WORKER || path == WEB_MANIFEST {
42 // The service worker is the push receiver: it must never be served from
43 // an hour-long cache, or a fixed push handler keeps running old logic.
44 // The manifest is what makes iOS treat the site as installable, so it
45 // rides along on the same rule.
3846 CACHE_NO_CACHE
3947 } else {
4048 CACHE_REVALIDATE
Added web/public/manifest.webmanifest View web/public/manifest.webmanifest
@@ -0,0 +1,18 @@
1{
2 "name": "GitDataAI",
3 "short_name": "GitData",
4 "description": "Repositories, issues, pull requests, team communication and AI code review in one engineering workspace.",
5 "start_url": "/app/@me",
6 "scope": "/",
7 "display": "standalone",
8 "background_color": "#090b0d",
9 "theme_color": "#090b0d",
10 "icons": [
11 {
12 "src": "/favicon.png",
13 "sizes": "400x400",
14 "type": "image/png",
15 "purpose": "any"
16 }
17 ]
18}
Added web/public/sw.js View web/public/sw.js
@@ -0,0 +1,94 @@
1/*
2 * GitData Service Worker — Web Push 接收端。
3 *
4 * 只处理推送与通知点击,不做离线缓存:这个站点是实时协作工具,缓存旧版本
5 * 的界面比直接报网络错误更糟。
6 */
7
8self.addEventListener('install', () => {
9 // 新版本立刻接管,推送处理逻辑没有需要等待的迁移步骤。
10 self.skipWaiting()
11})
12
13self.addEventListener('activate', event => {
14 event.waitUntil(self.clients.claim())
15})
16
17self.addEventListener('push', event => {
18 let payload = {}
19 try {
20 payload = event.data ? event.data.json() : {}
21 } catch {
22 // 推送服务只保证把字节送到;解析失败时退化成一条通用提醒,而不是静默丢弃。
23 payload = {}
24 }
25 const title = typeof payload.title === 'string' && payload.title ? payload.title : 'GitDataAI'
26 const url = typeof payload.url === 'string' && payload.url ? payload.url : '/app/@me/notify'
27
28 event.waitUntil(
29 self.registration.showNotification(title, {
30 body: typeof payload.body === 'string' ? payload.body : undefined,
31 icon: typeof payload.icon === 'string' ? payload.icon : '/favicon.png',
32 tag: typeof payload.tag === 'string' ? payload.tag : undefined,
33 // 同一 tag 的新通知要能再次打断用户:聊天场景里"上一条已经被顶掉"比
34 // 静默替换更容易错过。
35 renotify: true,
36 data: { url },
37 }),
38 )
39})
40
41self.addEventListener('notificationclick', event => {
42 event.notification.close()
43 const target = new URL(event.notification.data?.url || '/app/@me/notify', self.location.origin)
44
45 event.waitUntil(
46 (async () => {
47 const clientList = await self.clients.matchAll({
48 type: 'window',
49 includeUncontrolled: true,
50 })
51 // 优先复用已经打开的窗口:直接 openWindow 会让同一个应用出现两个标签页,
52 // 而用户期望的是"通知把我带回刚才那个界面"。
53 for (const client of clientList) {
54 if (new URL(client.url).origin !== target.origin) continue
55 await client.focus()
56 if ('navigate' in client && client.url !== target.href) {
57 await client.navigate(target.href).catch(() => {})
58 }
59 return
60 }
61 await self.clients.openWindow(target.href)
62 })(),
63 )
64})
65
66self.addEventListener('pushsubscriptionchange', event => {
67 // 浏览器会不定期轮换订阅(尤其是长期未使用的设备)。不重新注册的话,服务端
68 // 存着的 endpoint 会失效,推送从此静默丢失。
69 event.waitUntil(
70 (async () => {
71 const applicationServerKey =
72 event.oldSubscription?.options?.applicationServerKey ??
73 (await self.registration.pushManager.getSubscription())?.options?.applicationServerKey
74 if (!applicationServerKey) return
75 const subscription = await self.registration.pushManager.subscribe({
76 userVisibleOnly: true,
77 applicationServerKey,
78 })
79 const json = subscription.toJSON()
80 await fetch('/api/v1/me/push-subscriptions', {
81 method: 'POST',
82 credentials: 'include',
83 headers: { 'content-type': 'application/json' },
84 body: JSON.stringify({
85 endpoint: subscription.endpoint,
86 p256dh_key: json.keys?.p256dh ?? '',
87 auth_key: json.keys?.auth ?? '',
88 platform: 'web_push',
89 user_agent: self.navigator.userAgent,
90 }),
91 })
92 })(),
93 )
94})
Added web/src/features/user-settings/model/push.ts View web/src/features/user-settings/model/push.ts
@@ -0,0 +1,138 @@
1/**
2 * Browser system-notification plumbing: Service Worker registration, the
3 * `PushSubscription` lifecycle, and the server-side subscription row.
4 *
5 * The module is deliberately framework-free. Features must not import each
6 * other, and both the settings section and any future "ask me again" surface
7 * need the same three operations, so they live here rather than in the UI.
8 */
9import Apis, { trackFetch } from '@/shared/api'
10import { unwrapApi } from '@/shared/lib'
11import type { PushSubscriptionPlatform } from '@/shared/api'
12
13const SERVICE_WORKER_URL = '/sw.js'
14
15export type PushPermission = 'unsupported' | 'default' | 'granted' | 'denied'
16
17/**
18 * `user_preferences.push_flags` bits, mirrored from `export::push`.
19 *
20 * The mask is a *disable* mask: the server treats a set bit as "do not push this
21 * category", so the stored default of `0` keeps everything on for accounts that
22 * never opened this page.
23 */
24export const PUSH_FLAG = {
25 mention: 1 << 0,
26 reply: 1 << 1,
27 dm: 1 << 2,
28 reminder: 1 << 3,
29} as const
30
31/** What the browser can actually do, independent of the server-side config. */
32export function pushSupport(): PushPermission {
33 if (typeof window === 'undefined') return 'unsupported'
34 if (!('serviceWorker' in navigator) || !('PushManager' in window)) return 'unsupported'
35 if (!('Notification' in window)) return 'unsupported'
36 return Notification.permission as PushPermission
37}
38
39/**
40 * base64url → bytes; `applicationServerKey` rejects a plain string.
41 *
42 * The buffer is allocated explicitly (`new ArrayBuffer`) instead of letting
43 * `Uint8Array` pick one: the DOM type only accepts a view over a real
44 * `ArrayBuffer`, while a bare `Uint8Array` is typed against `ArrayBufferLike`
45 * (which also admits `SharedArrayBuffer`).
46 */
47function decodeBase64Url(value: string): Uint8Array<ArrayBuffer> {
48 const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
49 const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
50 const binary = atob(padded)
51 const buffer = new ArrayBuffer(binary.length)
52 const bytes = new Uint8Array(buffer)
53 for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
54 return bytes
55}
56
57async function serviceWorkerRegistration(): Promise<ServiceWorkerRegistration> {
58 return navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' })
59}
60
61/** The browser's current subscription for this origin, if any. */
62export async function currentSubscription(): Promise<PushSubscription | null> {
63 if (pushSupport() !== 'granted') return null
64 const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_URL)
65 if (!registration) return null
66 return registration.pushManager.getSubscription()
67}
68
69/**
70 * Ask for permission and register this device.
71 *
72 * Returns `'denied'` when the user declines rather than throwing: a refusal is a
73 * normal outcome of a click, not an error to surface as a failure banner. The
74 * server-side config is checked first so a disabled deployment never triggers a
75 * permission prompt.
76 */
77export async function enablePush(publicKey: string): Promise<PushPermission> {
78 const support = pushSupport()
79 if (support === 'unsupported') return 'unsupported'
80 const permission =
81 Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission()
82 if (permission !== 'granted') return permission as PushPermission
83
84 const registration = await serviceWorkerRegistration()
85 await navigator.serviceWorker.ready
86 const existing = await registration.pushManager.getSubscription()
87 const subscription =
88 existing ??
89 (await registration.pushManager.subscribe({
90 userVisibleOnly: true,
91 applicationServerKey: decodeBase64Url(publicKey),
92 }))
93
94 await upsertSubscription(subscription)
95 return 'granted'
96}
97
98/** Unsubscribe this device and drop the matching server row. */
99export async function disablePush(): Promise<void> {
100 const subscription = await currentSubscription()
101 if (!subscription) return
102 const endpoint = subscription.endpoint
103 await subscription.unsubscribe()
104 const rows = await listSubscriptions()
105 const row = rows.find(item => item.endpoint === endpoint)
106 if (row) await unwrapApi(Apis.user.delete_push_subscription({ pathParams: { push_id: row.id } }))
107}
108
109/** Re-register an existing browser subscription after the server lost the row. */
110export async function syncExistingSubscription(): Promise<void> {
111 const subscription = await currentSubscription()
112 if (subscription) await upsertSubscription(subscription)
113}
114
115async function listSubscriptions() {
116 return (await unwrapApi(Apis.user.list_push_subscriptions({ params: { limit: 100 } }))) ?? []
117}
118
119/**
120 * The generated client types `platform` as an open string union, so the value is
121 * pinned here against the backend enum (`web_push`); the other two platforms are
122 * reserved for the mobile apps and never produced by the browser.
123 */
124async function upsertSubscription(subscription: PushSubscription): Promise<void> {
125 const json = subscription.toJSON()
126 const platform: PushSubscriptionPlatform = 'WebPush'
127 await trackFetch('/api/v1/me/push-subscriptions', {
128 method: 'POST',
129 headers: { 'content-type': 'application/json' },
130 body: JSON.stringify({
131 endpoint: subscription.endpoint,
132 p256dh_key: json.keys?.p256dh ?? '',
133 auth_key: json.keys?.auth ?? '',
134 platform,
135 user_agent: navigator.userAgent,
136 }),
137 })
138}
Modified web/src/features/user-settings/ui/sections/notifications-section.tsx View web/src/features/user-settings/ui/sections/notifications-section.tsx
@@ -1,25 +1,238 @@
1import { useState } from 'react'
1import { useCallback, useEffect, useState } from 'react'
2import Apis from '@/shared/api'
3import { unwrapApi } from '@/shared/lib'
24import { useT } from '@/shared/i18n'
35import * as api from '../../model/api'
46import { usePreferences } from '../../model/preferences'
5import { Busy, Field, SaveBar, SectionTitle, Toggle, inputCls, useSave } from '@/shared/ui'
7import {
8 PUSH_FLAG,
9 currentSubscription,
10 disablePush,
11 enablePush,
12 pushSupport,
13 syncExistingSubscription,
14 type PushPermission,
15} from '../../model/push'
16import { Alert, Busy, Field, SaveBar, SectionTitle, Toggle, inputCls, useSave } from '@/shared/ui'
17
18/** Page-level fetch of `/push/config`; `null` while loading. */
19function usePushConfig() {
20 const [config, setConfig] = useState<{ enabled: boolean; public_key: string } | null>(null)
21 useEffect(() => {
22 let cancelled = false
23 void (async () => {
24 try {
25 const value = await unwrapApi(Apis.push.push_config())
26 if (!cancelled) setConfig(value ?? { enabled: false, public_key: '' })
27 } catch {
28 // A deployment without push simply hides the block; failing the whole
29 // notifications section over an optional capability would be worse.
30 if (!cancelled) setConfig({ enabled: false, public_key: '' })
31 }
32 })()
33 return () => {
34 cancelled = true
35 }
36 }, [])
37 return config
38}
639
40/**
41 * Browser notification state.
42 *
43 * `permission` is the browser's answer; `subscribed` is whether a live
44 * subscription exists for this device. Both are needed: permission can be
45 * granted while this device was never subscribed (or was unsubscribed
46 * elsewhere), and only the pair tells the truth about the switch.
47 */
48function usePushDevice(config: { enabled: boolean; public_key: string } | null) {
49 // `pushSupport()` is a synchronous feature check, so it is read once as the
50 // initial state instead of being set from an effect (which would cost an
51 // extra render for a value that cannot change after load).
52 const [permission, setPermission] = useState<PushPermission>(() => pushSupport())
53 const [subscribed, setSubscribed] = useState(false)
54 const [busy, setBusy] = useState(false)
55 const [error, setError] = useState(false)
56
57 /** Re-read the browser's subscription state; used after every toggle. */
58 const refresh = useCallback(async () => {
59 try {
60 setSubscribed((await currentSubscription()) !== null)
61 } catch {
62 setSubscribed(false)
63 }
64 }, [])
65
66 useEffect(() => {
67 // The subscription lives in the browser, not in React, so reading it is a
68 // genuine external-system synchronisation. The read is inlined rather than
69 // routed through `refresh` so the linter can see the async boundary; the
70 // cancellation guard drops a state write that would outlive the mount.
71 //
72 // When a live subscription exists the server row is reasserted: the two can
73 // drift apart (a database reset, or a subscription created while the request
74 // failed), and the upsert is keyed on the endpoint so a healthy pair is
75 // simply rewritten. Without this, the switch would read "on" while nothing
76 // was ever deliverable.
77 let cancelled = false
78 void (async () => {
79 try {
80 const subscription = await currentSubscription()
81 if (cancelled) return
82 setSubscribed(subscription !== null)
83 if (subscription) await syncExistingSubscription()
84 } catch {
85 if (!cancelled) setSubscribed(false)
86 }
87 })()
88 return () => {
89 cancelled = true
90 }
91 }, [])
92
93 const toggle = useCallback(
94 async (next: boolean) => {
95 if (!config) return
96 setBusy(true)
97 setError(false)
98 try {
99 if (next) {
100 const result = await enablePush(config.public_key)
101 setPermission(result)
102 } else {
103 // Unsubscribing cannot change the browser's permission, so the state
104 // stays as it is; only the subscription flips.
105 await disablePush()
106 }
107 await refresh()
108 } catch {
109 setError(true)
110 } finally {
111 setBusy(false)
112 }
113 },
114 [config, refresh],
115 )
116
117 return { permission, subscribed, busy, error, toggle }
118}
119
7120export function NotificationsSection() {
8121 const { t } = useT()
9122 const prefs = usePreferences()
10123 const { busy, notice, error, run } = useSave()
11 const [form, setForm] = useState<{ digest: boolean; mute: string } | null>(null)
12 const current = form ?? (prefs.data ? { digest: prefs.data.email_digest, mute: prefs.data.mute_until?.slice(0, 16) ?? '' } : null)
13 if (prefs.loading || !current) return <><SectionTitle title={t('settings.notifications.title')} /><Busy /></>
124 const config = usePushConfig()
125 const pushDevice = usePushDevice(config)
126 const [form, setForm] = useState<{ digest: boolean; mute: string; pushFlags: number } | null>(null)
127 const current =
128 form ??
129 (prefs.data
130 ? {
131 digest: prefs.data.email_digest,
132 mute: prefs.data.mute_until?.slice(0, 16) ?? '',
133 pushFlags: prefs.data.push_flags ?? 0,
134 }
135 : null)
136 if (prefs.loading || !current) {
137 return (
138 <>
139 <SectionTitle title={t('settings.notifications.title')} />
140 <Busy />
141 </>
142 )
143 }
14144
145 // `push_flags` is a *disable* mask: a set bit turns that category off, and the
146 // stored default of 0 keeps every category on for accounts that never touched
147 // this page.
148 const flagOn = (bit: number) => (current.pushFlags & bit) === 0
149 const setFlag = (bit: number, on: boolean) =>
150 setForm({ ...current, pushFlags: on ? current.pushFlags & ~bit : current.pushFlags | bit })
151
152 const pushConfigured = config?.enabled === true
153 const pushState = pushDevice.subscribed ? (
154 <p className="mb-4 text-[12.5px] text-shell-ink-muted">
155 {t('settings.notifications.pushGranted')}
156 </p>
157 ) : pushDevice.permission === 'denied' ? (
158 <Alert tone="error">{t('settings.notifications.pushDenied')}</Alert>
159 ) : pushDevice.permission === 'unsupported' ? (
160 <Alert tone="info">{t('settings.notifications.pushUnsupported')}</Alert>
161 ) : null
162
15163 return (
16164 <section>
17165 <SectionTitle title={t('settings.notifications.title')} sub={t('settings.notifications.sub')} />
18 <Toggle checked={current.digest} onChange={v => setForm({ ...current, digest: v })} label={t('userSettings.notifications.digest')} />
166
167 {pushConfigured && pushDevice.permission !== 'unsupported' ? (
168 <div className="mb-6">
169 <Toggle
170 checked={pushDevice.subscribed}
171 disabled={pushDevice.busy || pushDevice.permission === 'denied'}
172 onChange={value => void pushDevice.toggle(value)}
173 label={t('settings.notifications.pushEnabled')}
174 />
175 {pushState}
176 {pushDevice.error ? (
177 <Alert tone="error">{t('settings.notifications.pushFailed')}</Alert>
178 ) : null}
179 {pushDevice.subscribed ? (
180 <>
181 <p className="mb-2 font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
182 {t('settings.notifications.pushTypes')}
183 </p>
184 <Toggle
185 checked={flagOn(PUSH_FLAG.mention)}
186 onChange={value => setFlag(PUSH_FLAG.mention, value)}
187 label={t('settings.notifications.pushMention')}
188 />
189 <Toggle
190 checked={flagOn(PUSH_FLAG.reply)}
191 onChange={value => setFlag(PUSH_FLAG.reply, value)}
192 label={t('settings.notifications.pushReply')}
193 />
194 <Toggle
195 checked={flagOn(PUSH_FLAG.dm)}
196 onChange={value => setFlag(PUSH_FLAG.dm, value)}
197 label={t('settings.notifications.pushDm')}
198 />
199 <Toggle
200 checked={flagOn(PUSH_FLAG.reminder)}
201 onChange={value => setFlag(PUSH_FLAG.reminder, value)}
202 label={t('settings.notifications.pushReminder')}
203 />
204 </>
205 ) : null}
206 </div>
207 ) : null}
208
209 <Toggle
210 checked={current.digest}
211 onChange={value => setForm({ ...current, digest: value })}
212 label={t('userSettings.notifications.digest')}
213 />
19214 <Field label={t('userSettings.notifications.muteUntil')} hint={t('userSettings.notifications.muteHint')}>
20 <input type="datetime-local" className={inputCls} value={current.mute} onChange={e => setForm({ ...current, mute: e.target.value })} />
215 <input
216 type="datetime-local"
217 className={inputCls}
218 value={current.mute}
219 onChange={event => setForm({ ...current, mute: event.target.value })}
220 />
21221 </Field>
22 <SaveBar busy={busy} notice={notice} error={error} onSave={() => void run(async () => { await api.updatePreferences({ email_digest: current.digest, mute_until: current.mute ? new Date(current.mute).toISOString() : null }) }, t('settings.notifications.saved'))} />
222 <SaveBar
223 busy={busy}
224 notice={notice}
225 error={error}
226 onSave={() =>
227 void run(async () => {
228 await api.updatePreferences({
229 email_digest: current.digest,
230 mute_until: current.mute ? new Date(current.mute).toISOString() : null,
231 push_flags: current.pushFlags,
232 })
233 }, t('settings.notifications.saved'))
234 }
235 />
23236 </section>
24237 )
25238}
Modified web/src/shared/api/apiDefinitions.ts View web/src/shared/api/apiDefinitions.ts
@@ -364,6 +364,7 @@
364364 'user.my_suspensions': ['GET', '/me/suspensions'],
365365 'account.update_username': ['PUT', '/me/username'],
366366 'pulls.list_my_pulls': ['GET', '/pulls/@me/{scope}'],
367 'push.push_config': ['GET', '/push/config'],
367368 'content.caller_signature': ['POST', '/repos/utils/commit-signature'],
368369 'patches.patch_parse': ['POST', '/repos/utils/patch/parse'],
369370 'repos.list_repos': ['GET', '/repos/{space_name}'],
Modified web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts

Diff too large to display.

Modified web/src/shared/i18n/messages/en/settings.ts View web/src/shared/i18n/messages/en/settings.ts
@@ -79,7 +79,17 @@
7979 'settings.notifications.push': 'Push notifications',
8080 'settings.notifications.digest': 'Email digest',
8181 'settings.notifications.saved': 'Notification settings saved.',
82 'settings.appearance.title': 'Appearance',
82 'settings.notifications.pushEnabled': 'System notifications on this device',
83 'settings.notifications.pushEnable': 'Turn on',
84 'settings.notifications.pushGranted': 'This device receives system notifications. Turning it off unsubscribes the browser.',
85 'settings.notifications.pushDenied': 'Notifications are blocked for this site. Re-allow them in the browser address bar, then try again.',
86 'settings.notifications.pushUnsupported': 'This browser cannot show system notifications.',
87 'settings.notifications.pushTypes': 'Push these to the system',
88 'settings.notifications.pushMention': 'Mentions',
89 'settings.notifications.pushReply': 'Replies',
90 'settings.notifications.pushDm': 'Direct messages',
91 'settings.notifications.pushReminder': 'Message reminders',
92 'settings.notifications.pushFailed': 'Could not change push notifications. Please try again.', 'settings.appearance.title': 'Appearance',
8393 'settings.appearance.sub': 'Palette, theme mode and how code differences are displayed.',
8494 'settings.appearance.themeMode': 'Theme mode',
8595 'settings.appearance.themeSystem': 'Follow system',
Modified web/src/shared/i18n/messages/zh-CN/settings.ts View web/src/shared/i18n/messages/zh-CN/settings.ts
@@ -79,7 +79,17 @@
7979 'settings.notifications.push': '推送通知',
8080 'settings.notifications.digest': '邮件摘要',
8181 'settings.notifications.saved': '通知设置已保存。',
82 'settings.appearance.title': '外观',
82 'settings.notifications.pushEnabled': '此设备的系统通知',
83 'settings.notifications.pushEnable': '开启',
84 'settings.notifications.pushGranted': '此设备会收到系统通知;关闭即退订浏览器订阅。',
85 'settings.notifications.pushDenied': '该站点已被禁止显示通知,请在地址栏重新允许后再试。',
86 'settings.notifications.pushUnsupported': '此浏览器不支持系统通知。',
87 'settings.notifications.pushTypes': '推送到系统的类型',
88 'settings.notifications.pushMention': '提及',
89 'settings.notifications.pushReply': '回复',
90 'settings.notifications.pushDm': '私信',
91 'settings.notifications.pushReminder': '消息提醒',
92 'settings.notifications.pushFailed': '系统通知设置未保存,请重试。', 'settings.appearance.title': '外观',
8393 'settings.appearance.sub': '配色、主题模式与代码差异展示方式。',
8494 'settings.appearance.themeMode': '主题模式',
8595 'settings.appearance.themeSystem': '跟随系统',
Modified web/index.html View web/index.html
@@ -4,6 +4,9 @@
44 <meta charset="UTF-8" />
55 <link rel="icon" type="image/svg+xml" href="/favicon.png" />
66 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 <!-- iOS 只为"添加到主屏"的站点投递 Web Push,manifest 与 touch icon 是前提。 -->
8 <link rel="manifest" href="/manifest.webmanifest" />
9 <link rel="apple-touch-icon" href="/favicon.png" />
710 <meta name="theme-color" content="#090b0d" />
811 <meta
912 name="description"
Modified web/openapi.json View web/openapi.json

Diff too large to display.

Modified Cargo.lock View Cargo.lock
@@ -133,6 +133,16 @@
133133]
134134
135135[[package]]
136name = "aes-keywrap"
137version = "0.9.0"
138source = "registry+https://github.com/rust-lang/crates.io-index"
139checksum = "10b6f24a1f796bc46415a1d0d18dc0a8203ccba088acf5def3291c4f61225522"
140dependencies = [
141 "aes 0.9.2",
142 "byteorder",
143]
144
145[[package]]
136146name = "agent"
137147version = "1.0.0"
138148dependencies = [
@@ -267,8 +277,10 @@
267277 "chat",
268278 "config",
269279 "export",
280 "push",
270281 "session",
271282 "tokio",
283 "tokio-util",
272284 "tracing",
273285 "tracing-subscriber",
274286 "utoipa",
@@ -701,7 +713,7 @@
701713 "config",
702714 "email",
703715 "export",
704 "hkdf",
716 "hkdf 0.13.0",
705717 "hmac 0.13.0",
706718 "i18n",
707719 "maud",
@@ -961,6 +973,12 @@
961973]
962974
963975[[package]]
976name = "binstring"
977version = "0.1.7"
978source = "registry+https://github.com/rust-lang/crates.io-index"
979checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef"
980
981[[package]]
964982name = "bisync"
965983version = "0.3.0"
966984source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1012,6 +1030,16 @@
10121030]
10131031
10141032[[package]]
1033name = "blake2b_simd"
1034version = "1.0.5"
1035source = "registry+https://github.com/rust-lang/crates.io-index"
1036checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff"
1037dependencies = [
1038 "arrayvec",
1039 "constant_time_eq",
1040]
1041
1042[[package]]
10151043name = "block-buffer"
10161044version = "0.10.4"
10171045source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1417,6 +1445,17 @@
14171445checksum = "d7ee2cfacbd29706479902b06d75ad8f1362900836aa32799eabc7e004bfd854"
14181446
14191447[[package]]
1448name = "coarsetime"
1449version = "0.1.37"
1450source = "registry+https://github.com/rust-lang/crates.io-index"
1451checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2"
1452dependencies = [
1453 "libc",
1454 "wasix",
1455 "wasm-bindgen",
1456]
1457
1458[[package]]
14201459name = "color"
14211460version = "0.3.3"
14221461source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1512,6 +1551,12 @@
15121551]
15131552
15141553[[package]]
1554name = "constant_time_eq"
1555version = "0.4.2"
1556source = "registry+https://github.com/rust-lang/crates.io-index"
1557checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
1558
1559[[package]]
15151560name = "cookie"
15161561version = "0.18.2"
15171562source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1717,6 +1762,12 @@
17171762]
17181763
17191764[[package]]
1765name = "ct-codecs"
1766version = "1.1.7"
1767source = "registry+https://github.com/rust-lang/crates.io-index"
1768checksum = "49fb0c6640b4507ebd99ff67677009e381ba5eee1d14df78de4a3d16eb123c39"
1769
1770[[package]]
17201771name = "ctr"
17211772version = "0.9.2"
17221773source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1921,6 +1972,7 @@
19211972checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
19221973dependencies = [
19231974 "const-oid 0.9.6",
1975 "pem-rfc7468 0.7.0",
19241976 "zeroize",
19251977]
19261978
@@ -2157,6 +2209,17 @@
21572209]
21582210
21592211[[package]]
2212name = "ece-native"
2213version = "0.5.0"
2214source = "registry+https://github.com/rust-lang/crates.io-index"
2215checksum = "6911cc97c62febd8cbcb2cb49ed21c58ccd418a80c43f20df4973ee08484e65d"
2216dependencies = [
2217 "aes-gcm 0.10.3",
2218 "hkdf 0.12.4",
2219 "sha2 0.10.9",
2220]
2221
2222[[package]]
21602223name = "ed25519"
21612224version = "3.0.0"
21622225source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2167,6 +2230,16 @@
21672230]
21682231
21692232[[package]]
2233name = "ed25519-compact"
2234version = "2.4.2"
2235source = "registry+https://github.com/rust-lang/crates.io-index"
2236checksum = "f05391a505666bdf2b5d2626f41b7f0f49052b1e33cceac960eaa818008141da"
2237dependencies = [
2238 "ct-codecs",
2239 "getrandom 0.4.3",
2240]
2241
2242[[package]]
21702243name = "ed25519-dalek"
21712244version = "3.0.0"
21722245source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2203,6 +2276,8 @@
22032276 "ff 0.13.1",
22042277 "generic-array 0.14.9",
22052278 "group 0.13.0",
2279 "hkdf 0.12.4",
2280 "pem-rfc7468 0.7.0",
22062281 "pkcs8 0.10.2",
22072282 "rand_core 0.6.4",
22082283 "sec1 0.7.3",
@@ -2222,7 +2297,7 @@
22222297 "digest 0.11.3",
22232298 "ff 0.14.0",
22242299 "group 0.14.0",
2225 "hkdf",
2300 "hkdf 0.13.0",
22262301 "hybrid-array",
22272302 "pem-rfc7468 1.0.0",
22282303 "pkcs8 0.11.0",
@@ -4224,6 +4299,15 @@
42244299
42254300[[package]]
42264301name = "hkdf"
4302version = "0.12.4"
4303source = "registry+https://github.com/rust-lang/crates.io-index"
4304checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
4305dependencies = [
4306 "hmac 0.12.1",
4307]
4308
4309[[package]]
4310name = "hkdf"
42274311version = "0.13.0"
42284312source = "registry+https://github.com/rust-lang/crates.io-index"
42294313checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
@@ -4250,6 +4334,30 @@
42504334]
42514335
42524336[[package]]
4337name = "hmac-sha1-compact"
4338version = "1.1.7"
4339source = "registry+https://github.com/rust-lang/crates.io-index"
4340checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823"
4341
4342[[package]]
4343name = "hmac-sha256"
4344version = "1.1.14"
4345source = "registry+https://github.com/rust-lang/crates.io-index"
4346checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
4347dependencies = [
4348 "digest 0.10.7",
4349]
4350
4351[[package]]
4352name = "hmac-sha512"
4353version = "1.1.12"
4354source = "registry+https://github.com/rust-lang/crates.io-index"
4355checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b"
4356dependencies = [
4357 "digest 0.10.7",
4358]
4359
4360[[package]]
42534361name = "http"
42544362version = "0.2.12"
42554363source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5325,6 +5433,46 @@
53255433]
53265434
53275435[[package]]
5436name = "jwt-simple"
5437version = "0.12.17"
5438source = "registry+https://github.com/rust-lang/crates.io-index"
5439checksum = "06a4207b9d1f423858853fb9ee8b34aebc2b661f3b446ca4efda46c4b3700a2f"
5440dependencies = [
5441 "anyhow",
5442 "binstring",
5443 "blake2b_simd",
5444 "coarsetime",
5445 "ct-codecs",
5446 "ed25519-compact",
5447 "hmac-sha1-compact",
5448 "hmac-sha256",
5449 "hmac-sha512",
5450 "k256",
5451 "p256 0.13.2",
5452 "p384 0.13.1",
5453 "rand 0.8.7",
5454 "serde",
5455 "serde_json",
5456 "superboring",
5457 "thiserror 2.0.20",
5458 "zeroize",
5459]
5460
5461[[package]]
5462name = "k256"
5463version = "0.13.4"
5464source = "registry+https://github.com/rust-lang/crates.io-index"
5465checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
5466dependencies = [
5467 "cfg-if",
5468 "ecdsa 0.16.9",
5469 "elliptic-curve 0.13.8",
5470 "once_cell",
5471 "sha2 0.10.9",
5472 "signature 2.2.0",
5473]
5474
5475[[package]]
53285476name = "keccak"
53295477version = "0.2.1"
53305478source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5866,6 +6014,22 @@
58666014]
58676015
58686016[[package]]
6017name = "ml-dsa"
6018version = "0.1.1"
6019source = "registry+https://github.com/rust-lang/crates.io-index"
6020checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
6021dependencies = [
6022 "const-oid 0.10.2",
6023 "crypto-common 0.2.2",
6024 "ctutils",
6025 "hybrid-array",
6026 "module-lattice",
6027 "pkcs8 0.11.0",
6028 "shake",
6029 "signature 3.0.0",
6030]
6031
6032[[package]]
58696033name = "ml-kem"
58706034version = "0.3.2"
58716035source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7408,6 +7572,26 @@
74087572]
74097573
74107574[[package]]
7575name = "push"
7576version = "1.0.0"
7577dependencies = [
7578 "base64 0.23.1",
7579 "config",
7580 "export",
7581 "http 1.5.0",
7582 "models",
7583 "reqwest 0.12.28",
7584 "sea-orm",
7585 "serde",
7586 "serde_json",
7587 "tokio",
7588 "tokio-util",
7589 "tracing",
7590 "uuid",
7591 "web-push-native",
7592]
7593
7594[[package]]
74117595name = "pxfm"
74127596version = "0.1.30"
74137597source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8727,6 +8911,17 @@
87278911]
87288912
87298913[[package]]
8914name = "shake"
8915version = "0.1.0"
8916source = "registry+https://github.com/rust-lang/crates.io-index"
8917checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a"
8918dependencies = [
8919 "digest 0.11.3",
8920 "keccak",
8921 "sponge-cursor",
8922]
8923
8924[[package]]
87308925name = "sharded-slab"
87318926version = "0.1.7"
87328927source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -9089,7 +9284,7 @@
90899284 "futures-core",
90909285 "futures-util",
90919286 "hex",
9092 "hkdf",
9287 "hkdf 0.13.0",
90939288 "hmac 0.13.0",
90949289 "itoa",
90959290 "log 0.4.33",
@@ -9351,6 +9546,23 @@
93519546checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
93529547
93539548[[package]]
9549name = "superboring"
9550version = "0.1.14"
9551source = "registry+https://github.com/rust-lang/crates.io-index"
9552checksum = "c378b1a556adcd6eeb6b56f0ee377763a088843581265f6ec821b232a571b2be"
9553dependencies = [
9554 "aes-gcm 0.11.0",
9555 "aes-keywrap",
9556 "getrandom 0.2.17",
9557 "getrandom 0.4.3",
9558 "hmac-sha256",
9559 "hmac-sha512",
9560 "ml-dsa",
9561 "rand 0.8.7",
9562 "rsa 0.9.10",
9563]
9564
9565[[package]]
93549566name = "syn"
93559567version = "1.0.109"
93569568source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10366,6 +10578,15 @@
1036610578]
1036710579
1036810580[[package]]
10581name = "wasix"
10582version = "0.13.2"
10583source = "registry+https://github.com/rust-lang/crates.io-index"
10584checksum = "ae86f02046da16a333a9129d31451423e1657737ecdafed4193838a5f54c5cfe"
10585dependencies = [
10586 "wasi 0.11.1+wasi-snapshot-preview1",
10587]
10588
10589[[package]]
1036910590name = "wasm-bindgen"
1037010591version = "0.2.127"
1037110592source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10426,6 +10647,23 @@
1042610647version = "0.1.0"
1042710648
1042810649[[package]]
10650name = "web-push-native"
10651version = "0.5.0"
10652source = "registry+https://github.com/rust-lang/crates.io-index"
10653checksum = "7628c93e39aeee4c0bca50ae333052b4f174c653e8fca1d0a8e34f9bc4879c0c"
10654dependencies = [
10655 "aes-gcm 0.10.3",
10656 "base64ct",
10657 "ece-native",
10658 "hkdf 0.13.0",
10659 "http 1.5.0",
10660 "jwt-simple",
10661 "p256 0.13.2",
10662 "serde",
10663 "sha2 0.11.0",
10664]
10665
10666[[package]]
1042910667name = "web-sys"
1043010668version = "0.3.104"
1043110669source = "registry+https://github.com/rust-lang/crates.io-index"
Modified Cargo.toml View Cargo.toml
@@ -29,6 +29,7 @@
2929export = { path = "lib/export" }
3030models = { path = "lib/models" }
3131pow-solver = { path = "lib/pow-solver" }
32push = { path = "lib/push" }
3233session = { path = "lib/session" }
3334storage = { path = "lib/storage" }
3435user = { path = "lib/user" }
Modified openapi.json View openapi.json

Diff too large to display.