From f3d66edd41a22471a830224453716ba65eba564a Mon Sep 17 00:00:00 2001 From: undefined Date: Thu, 22 May 2025 22:51:00 +0800 Subject: [PATCH 01/13] core&ui: add events channel --- packages/hydrooj/src/handler/connection.ts | 89 +++++++++++++++++++ packages/hydrooj/src/model/message.ts | 15 ++-- packages/hydrooj/src/model/user.ts | 15 ++-- packages/hydrooj/src/service/bus.ts | 2 +- .../ui-default/components/message/worker.ts | 47 +++++----- 5 files changed, 132 insertions(+), 36 deletions(-) create mode 100644 packages/hydrooj/src/handler/connection.ts diff --git a/packages/hydrooj/src/handler/connection.ts b/packages/hydrooj/src/handler/connection.ts new file mode 100644 index 000000000..6dc333dce --- /dev/null +++ b/packages/hydrooj/src/handler/connection.ts @@ -0,0 +1,89 @@ +import { Context } from '../context'; +import avatar from '../lib/avatar'; +import { PRIV } from '../model/builtin'; +import * as SystemModel from '../model/system'; +import TokenModel from '../model/token'; +import UserModel from '../model/user'; +import { ConnectionHandler } from '../service/server'; + +class WebsocketEventsConnectionManagerHandler extends ConnectionHandler { + category = '#gateway'; + private privileged = false; + private channels: Set = new Set(); + + async prepare() { + const secret = SystemModel.get('websocket.secret'); + if (this.request.headers['x-hydro-websocket-gateway'] === secret) { + this.privileged = true; + this.ctx.on('user/message', async (uid, mdoc) => { + await this.notifyMessage(uid, mdoc); + }); + } + } + + async notifyMessage(uid: number[], mdoc: any) { + const udoc = (await UserModel.getById('system', mdoc.from))!; + udoc.avatarUrl = avatar(udoc.avatar, 64); + this.send({ + operation: 'event', + channels: uid.map((u) => `message:${u}`), + payload: { udoc, mdoc }, + }); + } + + async accept(channel: string) { + if (this.channels.has(channel)) return; + this.channels.add(channel); + if (this.privileged) return; + if (channel.startsWith('message:')) { + const [, uid] = channel.split(':'); + this.ctx.on('user/message', async (uids, mdoc) => { + if (!uids.includes(+uid)) return; + await this.notifyMessage([+uid], mdoc); + }); + } + } + + async message(payload: any) { + try { + payload = JSON.parse(payload); + } catch (e) { + console.error('Invalid payload', e); + return; + } + const success = []; + const failed = []; + const session = payload?.credential + ? await TokenModel.get(payload.credential, TokenModel.TYPE_SESSION) + : null; + if (!session) return; + const user = await UserModel.getById('system', session.uid); + for (const channel of payload.channels) { + if (channel === 'message' && user.hasPriv(PRIV.PRIV_USER_PROFILE)) { + success.push(`message:${user._id}`); + // eslint-disable-next-line no-await-in-loop + await this.accept(`message:${user._id}`); + continue; + } + failed.push(channel); + } + if (success.length) { + this.send({ + operation: 'accept', + channels: success, + connection_id: payload.connection_id, + }); + } + if (failed.length) { + this.send({ + operation: 'reject', + channels: failed, + connection_id: payload.connection_id, + }); + } + } +} + +export function apply(ctx: Context) { + ctx.Connection('websocket_gateway', '/websocket', WebsocketEventsConnectionManagerHandler); +} diff --git a/packages/hydrooj/src/model/message.ts b/packages/hydrooj/src/model/message.ts index df96a1e86..68ee01ac5 100644 --- a/packages/hydrooj/src/model/message.ts +++ b/packages/hydrooj/src/model/message.ts @@ -18,17 +18,18 @@ class MessageModel { @ArgMethod static async send( - from: number, to: number, + from: number, to: number | number[], content: string, flag: number = MessageModel.FLAG_UNREAD, ) { const _id = new ObjectId(); - const mdoc: MessageDoc = { - _id, from, to, content, flag, + if (!Array.isArray(to)) to = [to]; + const base = { + _id, from, content, flag, }; - await MessageModel.coll.insertOne(mdoc); - if (from !== to) bus.broadcast('user/message', to, mdoc); + await MessageModel.coll.insertMany(to.map((t) => ({ ...base, to: t }))); + bus.broadcast('user/message', to, base); if (flag & MessageModel.FLAG_UNREAD) await user.inc(to, 'unreadMsg', 1); - return mdoc; + return base; } static async sendInfo(to: number, content: string) { @@ -36,7 +37,7 @@ class MessageModel { const mdoc: MessageDoc = { _id, from: 1, to, content, flag: MessageModel.FLAG_INFO | MessageModel.FLAG_I18N, }; - bus.broadcast('user/message', to, mdoc); + bus.broadcast('user/message', [to], mdoc); } static async get(_id: ObjectId) { diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index fb2a0f048..6837fdb0d 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -295,13 +295,14 @@ class UserModel { } @ArgMethod - static async inc(_id: number, field: string, n: number = 1) { - if (_id < -999) return null; - const udoc = await coll.findOne({ _id }); - if (!udoc) throw new UserNotFoundError(_id); - await coll.updateOne({ _id }, { $inc: { [field]: n } }); - deleteUserCache(udoc); - return udoc; + static async inc(_id: number | number[], field: string, n: number = 1) { + const ids = (Array.isArray(_id) ? _id : [_id]).filter((i) => i >= -999); + if (!ids.length) return null; + const udocs = await coll.find({ _id: { $in: ids } }).toArray(); + if (udocs.length !== ids.length) throw new UserNotFoundError(_id); + await coll.updateMany({ _id: { $in: ids } }, { $inc: { [field]: n } }); + for (const udoc of udocs) deleteUserCache(udoc); + return udocs; } @ArgMethod diff --git a/packages/hydrooj/src/service/bus.ts b/packages/hydrooj/src/service/bus.ts index bee4aff45..d499e6d11 100644 --- a/packages/hydrooj/src/service/bus.ts +++ b/packages/hydrooj/src/service/bus.ts @@ -36,7 +36,7 @@ export interface EventMap { 'task/daily': () => void; 'task/daily/finish': (pref: Record) => void; - 'user/message': (uid: number, mdoc: MessageDoc) => void + 'user/message': (uid: number[], mdoc: Omit) => void 'user/get': (udoc: User) => void 'user/delcache': (content: string | true) => void diff --git a/packages/ui-default/components/message/worker.ts b/packages/ui-default/components/message/worker.ts index ff7bc27b6..c553133a9 100644 --- a/packages/ui-default/components/message/worker.ts +++ b/packages/ui-default/components/message/worker.ts @@ -28,6 +28,31 @@ const ack = {}; function broadcastMsg(message: any) { for (const p of ports) p.postMessage(message); } + +function onMessage(payload: any) { + broadcastMsg({ type: 'message', payload }); + let acked = false; + ack[payload.mdoc._id] = () => { acked = true; }; + setTimeout(() => { + delete ack[payload.mdoc._id]; + if (acked) return; + if (payload.mdoc.flag & FLAG_INFO) return; + if (Notification?.permission !== 'granted') { + console.log('Notification permission denied'); + return; + } + // eslint-disable-next-line no-new + new Notification( + payload.udoc.uname || 'Hydro Notification', + { + tag: `message-${payload.mdoc._id}`, + icon: payload.udoc.avatarUrl || '/android-chrome-192x192.png', + body: payload.mdoc.content, + }, + ); + }, 5000); +} + function initConn(path: string, port: MessagePort, cookie: any) { ports.add(port); if (cookie !== lcookie) conn?.close(); @@ -55,27 +80,7 @@ function initConn(path: string, port: MessagePort, cookie: any) { broadcastMsg({ type: 'close', error: payload.error }); conn.close(); } else { - broadcastMsg({ type: 'message', payload }); - let acked = false; - ack[payload.mdoc._id] = () => { acked = true; }; - setTimeout(() => { - delete ack[payload.mdoc._id]; - if (acked) return; - if (payload.mdoc.flag & FLAG_INFO) return; - if (Notification?.permission !== 'granted') { - console.log('Notification permission denied'); - return; - } - // eslint-disable-next-line no-new - new Notification( - payload.udoc.uname || 'Hydro Notification', - { - tag: `message-${payload.mdoc._id}`, - icon: payload.udoc.avatarUrl || '/android-chrome-192x192.png', - body: payload.mdoc.content, - }, - ); - }, 5000); + onMessage(payload); } }; } From 2f726008722634f4d199d5a63123795c0ce42ebc Mon Sep 17 00:00:00 2001 From: undefined Date: Tue, 10 Jun 2025 00:24:55 +0800 Subject: [PATCH 02/13] fix connection event push --- packages/hydrooj/src/handler/connection.ts | 6 ---- .../components/message/index.page.ts | 18 +++++++---- .../ui-default/components/message/worker.ts | 30 ++++++++++--------- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/hydrooj/src/handler/connection.ts b/packages/hydrooj/src/handler/connection.ts index 6dc333dce..f9e01b6f1 100644 --- a/packages/hydrooj/src/handler/connection.ts +++ b/packages/hydrooj/src/handler/connection.ts @@ -45,12 +45,6 @@ class WebsocketEventsConnectionManagerHandler extends ConnectionHandler { } async message(payload: any) { - try { - payload = JSON.parse(payload); - } catch (e) { - console.error('Invalid payload', e); - return; - } const success = []; const failed = []; const session = payload?.credential diff --git a/packages/ui-default/components/message/index.page.ts b/packages/ui-default/components/message/index.page.ts index 2c45b18a9..04c580b69 100644 --- a/packages/ui-default/components/message/index.page.ts +++ b/packages/ui-default/components/message/index.page.ts @@ -59,14 +59,14 @@ const onmessage = (msg) => { return true; }; -const initWorkerMode = (endpoint) => { +const initWorkerMode = () => { console.log('Messages: using SharedWorker'); const worker = new SharedWorker(new URL('./worker.ts', import.meta.url), { name: 'HydroMessagesWorker' }); worker.port.start(); window.addEventListener('beforeunload', () => { worker.port.postMessage({ type: 'unload' }); }); - worker.port.postMessage({ type: 'conn', path: endpoint, cookie: document.cookie }); + worker.port.postMessage({ type: 'conn', cookie: document.cookie }); worker.port.onmessage = async (message) => { if (process.env.NODE_ENV !== 'production') console.log('onmessage: ', message); const { payload, type } = message.data; @@ -89,13 +89,11 @@ const messagePage = new AutoloadPage('messagePage', (pagename) => { action: () => window.open('/home/messages', '_blank'), }).show(); } - const url = new URL(`${UiContext.ws_prefix}home/messages-conn`, window.location.href); - // TODO handle a better way for cookie - if (url.host !== window.location.host) url.searchParams.append('sid', document.cookie.split('sid=')[1].split(';')[0]); + const url = new URL(`${UiContext.ws_prefix}websocket`, window.location.href); const endpoint = url.toString().replace('http', 'ws'); if (window.SharedWorker) { try { - initWorkerMode(endpoint); + initWorkerMode(); return; } catch (e) { console.error('SharedWorker init fail: ', e.message); @@ -129,6 +127,14 @@ const messagePage = new AutoloadPage('messagePage', (pagename) => { localStorage.setItem('page.master', selfId); const masterChannel = new BroadcastChannel('hydro-messages'); const sock = new Sock(endpoint); + sock.onopen = () => { + sock.send(JSON.stringify({ + operation: 'subscribe', + request_id: Math.random().toString(16).substring(2), + credential: document.cookie.split('sid=')[1].split(';')[0], + channels: ['message'], + })); + }; sock.onmessage = async (message) => { const payload = JSON.parse(message.data); masterChannel.postMessage({ type: 'message', payload }); diff --git a/packages/ui-default/components/message/worker.ts b/packages/ui-default/components/message/worker.ts index c553133a9..24b6377f7 100644 --- a/packages/ui-default/components/message/worker.ts +++ b/packages/ui-default/components/message/worker.ts @@ -13,7 +13,6 @@ const ports: Set = new Set(); interface RequestInitSharedConnPayload { type: 'conn'; cookie: string; - path: string; } interface RequestAckPayload { type: 'ack'; @@ -53,19 +52,22 @@ function onMessage(payload: any) { }, 5000); } -function initConn(path: string, port: MessagePort, cookie: any) { +function initConn(port: MessagePort, cookie: any) { ports.add(port); - if (cookie !== lcookie) conn?.close(); - else if (conn && conn.readyState === conn.OPEN) return; - const url = new URL(path, location.origin); - if (cookie) url.searchParams.set('sid', cookie.split('sid=')[1].split(';')[0]); - lcookie = cookie; - console.log('Init connection for', path); - conn?.close(); - conn = new ReconnectingWebsocket(url.toString()); + console.log('Init connection'); + lcookie = cookie.split('sid=')[1].split(';')[0]; + if (conn) return; + const url = new URL('/websocket', location.origin); + conn = new ReconnectingWebsocket(url.toString().replace('http', 'ws')); conn.onopen = () => { - console.log('Connected to', path); + console.log('Connected'); broadcastMsg({ type: 'open' }); + conn.send(JSON.stringify({ + 'operation': 'subscribe', + 'request_id': Math.random().toString(16).substring(2), + 'credential': lcookie, + 'channels': ['message'], + })); }; conn.onerror = () => broadcastMsg({ type: 'error' }); conn.onclose = (ev) => broadcastMsg({ type: 'close', error: ev.reason }); @@ -79,8 +81,8 @@ function initConn(path: string, port: MessagePort, cookie: any) { if (['PermissionError', 'PrivilegeError'].includes(payload.error)) { broadcastMsg({ type: 'close', error: payload.error }); conn.close(); - } else { - onMessage(payload); + } else if (payload.operation === 'event') { + onMessage(payload.payload); } }; } @@ -89,7 +91,7 @@ self.onconnect = function (e) { const port = e.ports[0]; port.addEventListener('message', (msg: { data: RequestPayload }) => { - if (msg.data.type === 'conn') initConn(msg.data.path, port, msg.data.cookie); + if (msg.data.type === 'conn') initConn(port, msg.data.cookie); if (msg.data.type === 'ack') ack[msg.data.id]?.(); if (msg.data.type === 'unload') ports.delete(port); }); From 3e516f114d4060af31ce1c65eb2a163e95396aa6 Mon Sep 17 00:00:00 2001 From: undefined Date: Tue, 10 Jun 2025 01:08:00 +0800 Subject: [PATCH 03/13] fix UiContext.ws_prefix --- packages/ui-default/components/message/index.page.ts | 6 +++--- packages/ui-default/components/message/worker.ts | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/ui-default/components/message/index.page.ts b/packages/ui-default/components/message/index.page.ts index 04c580b69..e6af1bb25 100644 --- a/packages/ui-default/components/message/index.page.ts +++ b/packages/ui-default/components/message/index.page.ts @@ -59,14 +59,14 @@ const onmessage = (msg) => { return true; }; -const initWorkerMode = () => { +const initWorkerMode = (endpoint) => { console.log('Messages: using SharedWorker'); const worker = new SharedWorker(new URL('./worker.ts', import.meta.url), { name: 'HydroMessagesWorker' }); worker.port.start(); window.addEventListener('beforeunload', () => { worker.port.postMessage({ type: 'unload' }); }); - worker.port.postMessage({ type: 'conn', cookie: document.cookie }); + worker.port.postMessage({ type: 'conn', path: endpoint, cookie: document.cookie }); worker.port.onmessage = async (message) => { if (process.env.NODE_ENV !== 'production') console.log('onmessage: ', message); const { payload, type } = message.data; @@ -93,7 +93,7 @@ const messagePage = new AutoloadPage('messagePage', (pagename) => { const endpoint = url.toString().replace('http', 'ws'); if (window.SharedWorker) { try { - initWorkerMode(); + initWorkerMode(url); return; } catch (e) { console.error('SharedWorker init fail: ', e.message); diff --git a/packages/ui-default/components/message/worker.ts b/packages/ui-default/components/message/worker.ts index 24b6377f7..552f52865 100644 --- a/packages/ui-default/components/message/worker.ts +++ b/packages/ui-default/components/message/worker.ts @@ -13,6 +13,7 @@ const ports: Set = new Set(); interface RequestInitSharedConnPayload { type: 'conn'; cookie: string; + path: string; } interface RequestAckPayload { type: 'ack'; @@ -52,12 +53,12 @@ function onMessage(payload: any) { }, 5000); } -function initConn(port: MessagePort, cookie: any) { +function initConn(path: string, port: MessagePort, cookie: any) { ports.add(port); console.log('Init connection'); lcookie = cookie.split('sid=')[1].split(';')[0]; if (conn) return; - const url = new URL('/websocket', location.origin); + const url = new URL(path, location.origin); conn = new ReconnectingWebsocket(url.toString().replace('http', 'ws')); conn.onopen = () => { console.log('Connected'); @@ -91,7 +92,7 @@ self.onconnect = function (e) { const port = e.ports[0]; port.addEventListener('message', (msg: { data: RequestPayload }) => { - if (msg.data.type === 'conn') initConn(port, msg.data.cookie); + if (msg.data.type === 'conn') initConn(msg.data.path, port, msg.data.cookie); if (msg.data.type === 'ack') ack[msg.data.id]?.(); if (msg.data.type === 'unload') ports.delete(port); }); From e8b1159cdac40503769fab83a57319d9e3bcc767 Mon Sep 17 00:00:00 2001 From: undefined Date: Tue, 10 Jun 2025 03:55:52 +0800 Subject: [PATCH 04/13] add connection log --- packages/hydrooj/src/handler/connection.ts | 36 ++++++++----------- .../components/message/index.page.ts | 6 ++-- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/packages/hydrooj/src/handler/connection.ts b/packages/hydrooj/src/handler/connection.ts index f9e01b6f1..8605b5ac5 100644 --- a/packages/hydrooj/src/handler/connection.ts +++ b/packages/hydrooj/src/handler/connection.ts @@ -13,7 +13,8 @@ class WebsocketEventsConnectionManagerHandler extends ConnectionHandler { async prepare() { const secret = SystemModel.get('websocket.secret'); - if (this.request.headers['x-hydro-websocket-gateway'] === secret) { + if (secret && this.request.headers['x-hydro-websocket-gateway'] === secret) { + this.ctx.logger('connection').info('Gateway websocket connection from %s', this.request.ip); this.privileged = true; this.ctx.on('user/message', async (uid, mdoc) => { await this.notifyMessage(uid, mdoc); @@ -31,7 +32,7 @@ class WebsocketEventsConnectionManagerHandler extends ConnectionHandler { }); } - async accept(channel: string) { + accept(channel: string) { if (this.channels.has(channel)) return; this.channels.add(channel); if (this.privileged) return; @@ -45,8 +46,8 @@ class WebsocketEventsConnectionManagerHandler extends ConnectionHandler { } async message(payload: any) { - const success = []; - const failed = []; + const accept = []; + const reject = []; const session = payload?.credential ? await TokenModel.get(payload.credential, TokenModel.TYPE_SESSION) : null; @@ -54,27 +55,18 @@ class WebsocketEventsConnectionManagerHandler extends ConnectionHandler { const user = await UserModel.getById('system', session.uid); for (const channel of payload.channels) { if (channel === 'message' && user.hasPriv(PRIV.PRIV_USER_PROFILE)) { - success.push(`message:${user._id}`); - // eslint-disable-next-line no-await-in-loop - await this.accept(`message:${user._id}`); + accept.push(`message:${user._id}`); + this.accept(`message:${user._id}`); continue; } - failed.push(channel); - } - if (success.length) { - this.send({ - operation: 'accept', - channels: success, - connection_id: payload.connection_id, - }); - } - if (failed.length) { - this.send({ - operation: 'reject', - channels: failed, - connection_id: payload.connection_id, - }); + reject.push(channel); } + this.send({ + operation: 'verify', + accept, + reject, + connection_id: payload.connection_id, + }); } } diff --git a/packages/ui-default/components/message/index.page.ts b/packages/ui-default/components/message/index.page.ts index e6af1bb25..9a3727e85 100644 --- a/packages/ui-default/components/message/index.page.ts +++ b/packages/ui-default/components/message/index.page.ts @@ -93,7 +93,7 @@ const messagePage = new AutoloadPage('messagePage', (pagename) => { const endpoint = url.toString().replace('http', 'ws'); if (window.SharedWorker) { try { - initWorkerMode(url); + initWorkerMode(endpoint); return; } catch (e) { console.error('SharedWorker init fail: ', e.message); @@ -137,7 +137,9 @@ const messagePage = new AutoloadPage('messagePage', (pagename) => { }; sock.onmessage = async (message) => { const payload = JSON.parse(message.data); - masterChannel.postMessage({ type: 'message', payload }); + if (payload.operation === 'event') { + masterChannel.postMessage({ type: 'message', payload: payload.payload }); + } }; } From a973ca2a30ceb000d17c0aa9f4812ccb194137af Mon Sep 17 00:00:00 2001 From: undefined Date: Tue, 10 Jun 2025 03:59:35 +0800 Subject: [PATCH 05/13] core: use message.send(from, uids) --- packages/hydrooj/src/handler/contest.ts | 8 ++++---- packages/hydrooj/src/handler/discussion.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 8f95c93cb..ca77e73e5 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -234,11 +234,11 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler { await this.limitRate('add_discussion', 3600, 60); await contest.addClarification(domainId, tid, this.user._id, content, this.request.ip, subject); if (!this.user.own(this.tdoc)) { - await Promise.all([this.tdoc.owner, ...this.tdoc.maintainer].map((uid) => message.send(1, uid, JSON.stringify({ + await message.send(1, this.tdoc.maintainer.concat(this.tdoc.owner), JSON.stringify({ message: 'Contest {0} has a new clarification about {1}, please go to contest management to reply.', params: [this.tdoc.title, subject > 0 ? `#${this.tdoc.pids.indexOf(subject) + 1}` : 'the contest'], url: this.url('contest_manage', { tid }), - }), message.FLAG_I18N | message.FLAG_UNREAD))); + }), message.FLAG_I18N | message.FLAG_UNREAD); } this.back(); } @@ -462,11 +462,11 @@ export class ContestManagementHandler extends ContestManagementBaseHandler { const flag = contest.isOngoing(this.tdoc) ? message.FLAG_ALERT : message.FLAG_UNREAD; await Promise.all([ contest.addClarification(domainId, tid, 0, content, this.request.ip, subject), - ...uids.map((uid) => message.send(1, uid, JSON.stringify({ + message.send(1, uids, JSON.stringify({ message: 'Broadcast message from contest {0}:\n{1}', params: [this.tdoc.title, content], url: this.url('contest_problemlist', { tid }), - }), flag | message.FLAG_I18N)), + }), flag | message.FLAG_I18N), ]); } this.back(); diff --git a/packages/hydrooj/src/handler/discussion.ts b/packages/hydrooj/src/handler/discussion.ts index 2c8939258..fbed8ad50 100644 --- a/packages/hydrooj/src/handler/discussion.ts +++ b/packages/hydrooj/src/handler/discussion.ts @@ -238,7 +238,7 @@ class DiscussionDetailHandler extends DiscussionHandler { message: 'User {0} mentioned you in {1:link}', params: [this.user.uname, `/d/${domainId}${this.request.path}`], }); - await Promise.all(uids.map((i) => message.send(1, i, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD))); + await message.send(1, uids, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD); const drid = await discussion.addReply(domainId, did, this.user._id, content, this.request.ip); this.back({ drid }); } @@ -255,7 +255,7 @@ class DiscussionDetailHandler extends DiscussionHandler { message: 'User {0} mentioned you in {1:link}', params: [this.user.uname, `/d/${domainId}${this.request.path}`], }); - await Promise.all(uids.map((i) => message.send(1, i, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD))); + await message.send(1, uids, msg, message.FLAG_RICHTEXT | message.FLAG_UNREAD); await discussion.addTailReply(domainId, drid, this.user._id, content, this.request.ip); this.back(); } From b14200e44c9ad5ee26b2572d3fe10dcf06f825f7 Mon Sep 17 00:00:00 2001 From: undefined Date: Wed, 11 Jun 2025 11:42:22 +0800 Subject: [PATCH 06/13] core: fix message send when no rcpts --- packages/hydrooj/src/model/message.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/hydrooj/src/model/message.ts b/packages/hydrooj/src/model/message.ts index 68ee01ac5..291a64afd 100644 --- a/packages/hydrooj/src/model/message.ts +++ b/packages/hydrooj/src/model/message.ts @@ -26,6 +26,7 @@ class MessageModel { const base = { _id, from, content, flag, }; + if (!to.length) return base; await MessageModel.coll.insertMany(to.map((t) => ({ ...base, to: t }))); bus.broadcast('user/message', to, base); if (flag & MessageModel.FLAG_UNREAD) await user.inc(to, 'unreadMsg', 1); From 1ee09ad47b5c42eb5558dafe5c313dac0f54866f Mon Sep 17 00:00:00 2001 From: undefined Date: Thu, 12 Jun 2025 13:27:11 +0800 Subject: [PATCH 07/13] core: connection: resume & support hooks --- packages/hydrooj/src/handler/connection.ts | 89 ++++++++++++++-------- packages/hydrooj/src/service/bus.ts | 4 + 2 files changed, 62 insertions(+), 31 deletions(-) diff --git a/packages/hydrooj/src/handler/connection.ts b/packages/hydrooj/src/handler/connection.ts index 8605b5ac5..b33c0d4e8 100644 --- a/packages/hydrooj/src/handler/connection.ts +++ b/packages/hydrooj/src/handler/connection.ts @@ -1,10 +1,14 @@ import { Context } from '../context'; +import { ForbiddenError } from '../error'; import avatar from '../lib/avatar'; import { PRIV } from '../model/builtin'; import * as SystemModel from '../model/system'; import TokenModel from '../model/token'; import UserModel from '../model/user'; import { ConnectionHandler } from '../service/server'; +import { Logger } from '../utils'; + +const logger = new Logger('connection'); class WebsocketEventsConnectionManagerHandler extends ConnectionHandler { category = '#gateway'; @@ -13,36 +17,19 @@ class WebsocketEventsConnectionManagerHandler extends ConnectionHandler { async prepare() { const secret = SystemModel.get('websocket.secret'); - if (secret && this.request.headers['x-hydro-websocket-gateway'] === secret) { - this.ctx.logger('connection').info('Gateway websocket connection from %s', this.request.ip); + const token = this.request.headers['x-hydro-websocket-gateway']; + if (token) { + if (!secret || token !== secret) throw new ForbiddenError('Invalid token'); + logger.info('Gateway websocket connection from %s', this.request.ip); this.privileged = true; - this.ctx.on('user/message', async (uid, mdoc) => { - await this.notifyMessage(uid, mdoc); - }); } + await this.ctx.parallel('subscription/init', this, this.privileged); } - async notifyMessage(uid: number[], mdoc: any) { - const udoc = (await UserModel.getById('system', mdoc.from))!; - udoc.avatarUrl = avatar(udoc.avatar, 64); - this.send({ - operation: 'event', - channels: uid.map((u) => `message:${u}`), - payload: { udoc, mdoc }, - }); - } - - accept(channel: string) { + async accept(channel: string) { if (this.channels.has(channel)) return; this.channels.add(channel); - if (this.privileged) return; - if (channel.startsWith('message:')) { - const [, uid] = channel.split(':'); - this.ctx.on('user/message', async (uids, mdoc) => { - if (!uids.includes(+uid)) return; - await this.notifyMessage([+uid], mdoc); - }); - } + await this.ctx.parallel('subscription/enable', channel, this, this.privileged); } async message(payload: any) { @@ -51,15 +38,21 @@ class WebsocketEventsConnectionManagerHandler extends ConnectionHandler { const session = payload?.credential ? await TokenModel.get(payload.credential, TokenModel.TYPE_SESSION) : null; - if (!session) return; - const user = await UserModel.getById('system', session.uid); + if (!session && payload.operation !== 'resume') return; + if (payload.operation === 'resume' && !this.privileged) return; + const user = payload.operation === 'resume' ? null : await UserModel.getById('system', session.uid); for (const channel of payload.channels) { - if (channel === 'message' && user.hasPriv(PRIV.PRIV_USER_PROFILE)) { - accept.push(`message:${user._id}`); - this.accept(`message:${user._id}`); - continue; + try { + const result = payload.operation === 'resume' + ? { ok: true, channel } + // eslint-disable-next-line no-await-in-loop + : await this.ctx.bail('subscription/subscribe', channel, user); + if (result?.ok) accept.push(result.channel); + else reject.push(channel); + } catch (e) { + logger.error('Error subscribing to channel %s for user %s: %s', channel, user._id, e); + reject.push(channel); } - reject.push(channel); } this.send({ operation: 'verify', @@ -72,4 +65,38 @@ class WebsocketEventsConnectionManagerHandler extends ConnectionHandler { export function apply(ctx: Context) { ctx.Connection('websocket_gateway', '/websocket', WebsocketEventsConnectionManagerHandler); + + async function notifyMessage(uid: number[], mdoc: any, h) { + const udoc = (await UserModel.getById('system', mdoc.from))!; + return { + operation: 'event', + channels: uid.map((u) => `message:${u}`), + payload: { udoc: { ...udoc.serialize(h) as any, avatarUrl: avatar(udoc.avatar, 128) }, mdoc }, + }; + } + + ctx.on('subscription/init', (h, privileged) => { + if (!privileged) return; + h.ctx.on('user/message', async (uid, mdoc) => { + h.send(await notifyMessage(uid, mdoc, h)); + }); + }); + + ctx.on('subscription/enable', (channel, h, privileged) => { + if (!channel.startsWith('message:') || privileged) return; + const uid = +channel.split(':')[1]; + h.ctx.on('user/message', async (uids, mdoc) => { + if (!uids.includes(uid)) return; + h.send(await notifyMessage([uid], mdoc, h)); + }); + }); + + ctx.on('subscription/subscribe', (channel, user) => { // eslint-disable-line consistent-return + if (channel === 'message' && user.hasPriv(PRIV.PRIV_USER_PROFILE)) { + return { + ok: true, + channel: `message:${user._id}`, + }; + } + }); } diff --git a/packages/hydrooj/src/service/bus.ts b/packages/hydrooj/src/service/bus.ts index d499e6d11..8d8444a59 100644 --- a/packages/hydrooj/src/service/bus.ts +++ b/packages/hydrooj/src/service/bus.ts @@ -22,6 +22,10 @@ export interface EventMap { 'app/before-reload': (entries: Set) => VoidReturn 'app/reload': (entries: Set) => VoidReturn + 'subscription/init': (h: ConnectionHandler, privileged: boolean) => VoidReturn + 'subscription/subscribe': (channel: string, user: User) => VoidReturn + 'subscription/enable': (channel: string, h: ConnectionHandler, privileged: boolean) => VoidReturn + 'app/watch/change': (path: string) => VoidReturn 'app/watch/unlink': (path: string) => VoidReturn From d6061a73bfa0db17bea4311d2bf5665b8b5b6f21 Mon Sep 17 00:00:00 2001 From: undefined Date: Sun, 15 Jun 2025 09:04:57 +0800 Subject: [PATCH 08/13] fix ci --- .github/workflows/build.yml | 2 +- framework/eslint-config/package.json | 4 ++-- package.json | 4 ++-- packages/hydrooj/package.json | 2 +- packages/ui-default/package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c1f63bb6f..8c1d579b7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,9 +60,9 @@ jobs: .github/workflows/** - name: Build And Lint run: | + yarn build:ui:iconfont # icon font file needed for types if [[ ${{ steps.ui-changed-files.outputs.any_changed }} == true ]] || [[ $(npm info @hydrooj/ui-default version) != $(node -e 'console.log(require("./packages/ui-default/package.json").version)') ]] then - yarn build:ui:iconfont parallel --tty -j+0 yarn ::: lint:ci build build:ui:production:webpack test else parallel --tty -j+0 yarn ::: lint:ci build test diff --git a/framework/eslint-config/package.json b/framework/eslint-config/package.json index d032dcc56..d012f584a 100644 --- a/framework/eslint-config/package.json +++ b/framework/eslint-config/package.json @@ -4,8 +4,8 @@ "license": "MIT", "main": "base.mjs", "dependencies": { - "@eslint-react/eslint-plugin": "^1.51.3", - "@eslint/compat": "^1.2.9", + "@eslint-react/eslint-plugin": "^1.52.2", + "@eslint/compat": "^1.3.0", "@eslint/eslintrc": "^3.3.1", "@stylistic/eslint-plugin": "^4.4.1", "@typescript-eslint/eslint-plugin": "^8.34.0", diff --git a/package.json b/package.json index 072dcb53d..debfce4c9 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "cross-env": "^7.0.3", "cross-spawn": "^7.0.6", "esbuild": "0.25.2", - "eslint": "^9.28.0", + "eslint": "^9.29.0", "eslint-import-resolver-typescript": "^4.4.3", "eslint-import-resolver-webpack": "^0.13.10", "fs-extra": "^11.3.0", @@ -53,7 +53,7 @@ "mongodb-memory-server": "10.1.4", "nyc": "^17.1.0", "ora": "^8.2.0", - "oxlint": "^1.0.0", + "oxlint": "^1.1.0", "package-json": "^10.0.1", "semver": "^7.7.2", "simple-git": "^3.28.0", diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 207eb8954..22e929774 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -37,7 +37,7 @@ "lodash": "^4.17.21", "lru-cache": "^11.1.0", "mime-types": "^3.0.1", - "moment-timezone": "^0.5.48", + "moment-timezone": "^0.6.0", "mongodb": "^6.17.0", "mongodb-uri": "^0.9.7", "nanoid": "^5.1.5", diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index b733cf7c5..b8d82b070 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -21,7 +21,7 @@ "@fontsource/source-code-pro": "^5.2.6", "@fontsource/ubuntu-mono": "^5.2.6", "@hydrooj/utils": "workspace:^", - "@sentry/browser": "^9.28.0", + "@sentry/browser": "^9.29.0", "@sentry/webpack-plugin": "^3.5.0", "@simplewebauthn/browser": "13.1.0", "@svgr/webpack": "^8.1.0", From 436733f62107f94e543813c7f56922451f8b3488 Mon Sep 17 00:00:00 2001 From: undefined Date: Sun, 15 Jun 2025 19:09:37 +0800 Subject: [PATCH 09/13] ui: fix contest_sidebar --- packages/ui-default/templates/partials/contest_sidebar.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui-default/templates/partials/contest_sidebar.html b/packages/ui-default/templates/partials/contest_sidebar.html index 7d85aaa21..9c3998c44 100644 --- a/packages/ui-default/templates/partials/contest_sidebar.html +++ b/packages/ui-default/templates/partials/contest_sidebar.html @@ -27,7 +27,7 @@ {% elif not handler.user._dudoc.join %} - {% else %}