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..7c5f5f187 100644 --- a/framework/eslint-config/package.json +++ b/framework/eslint-config/package.json @@ -4,12 +4,12 @@ "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", - "@typescript-eslint/parser": "^8.34.0", + "@typescript-eslint/eslint-plugin": "^8.34.1", + "@typescript-eslint/parser": "^8.34.1", "eslint-config-airbnb": "^19.0.4", "eslint-plugin-de-morgan": "^1.3.0", "eslint-plugin-github": "^6.0.0", diff --git a/framework/framework/server.ts b/framework/framework/server.ts index 67e5b1469..aee61b47f 100644 --- a/framework/framework/server.ts +++ b/framework/framework/server.ts @@ -214,7 +214,6 @@ export class Handler extends HandlerCommon { static [kHandler] = 'Handler'; loginMethods: any; - noCheckPermView = false; notUsage = false; allowCors = false; __param: Record[]>; diff --git a/package.json b/package.json index 072dcb53d..a3fc487de 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.2.0", "package-json": "^10.0.1", "semver": "^7.7.2", "simple-git": "^3.28.0", diff --git a/packages/blog/index.ts b/packages/blog/index.ts index d03afca76..97aa367c1 100644 --- a/packages/blog/index.ts +++ b/packages/blog/index.ts @@ -134,7 +134,7 @@ class BlogUserHandler extends BlogHandler { class BlogDetailHandler extends BlogHandler { @param('did', Types.ObjectId) - async get(domainId: string, did: ObjectId) { + async get({ domainId }, did: ObjectId) { const dsdoc = this.user.hasPriv(PRIV.PRIV_USER_PROFILE) ? await BlogModel.getStatus(did, this.user._id) : null; @@ -156,13 +156,13 @@ class BlogDetailHandler extends BlogHandler { } @param('did', Types.ObjectId) - async postStar(domainId: string, did: ObjectId) { + async postStar({ }, did: ObjectId) { await BlogModel.setStar(did, this.user._id, true); this.back({ star: true }); } @param('did', Types.ObjectId) - async postUnstar(domainId: string, did: ObjectId) { + async postUnstar({ }, did: ObjectId) { await BlogModel.setStar(did, this.user._id, false); this.back({ star: false }); } @@ -176,7 +176,7 @@ class BlogEditHandler extends BlogHandler { @param('title', Types.Title) @param('content', Types.Content) - async postCreate(domainId: string, title: string, content: string) { + async postCreate({ }, title: string, content: string) { await this.limitRate('add_blog', 3600, 60); const did = await BlogModel.add(this.user._id, title, content, this.request.ip); this.response.body = { did }; @@ -186,7 +186,7 @@ class BlogEditHandler extends BlogHandler { @param('did', Types.ObjectId) @param('title', Types.Title) @param('content', Types.Content) - async postUpdate(domainId: string, did: ObjectId, title: string, content: string) { + async postUpdate({ }, did: ObjectId, title: string, content: string) { if (!this.user.own(this.ddoc!)) this.checkPriv(PRIV.PRIV_EDIT_SYSTEM); await Promise.all([ BlogModel.edit(did, title, content), @@ -197,7 +197,7 @@ class BlogEditHandler extends BlogHandler { } @param('did', Types.ObjectId) - async postDelete(domainId: string, did: ObjectId) { + async postDelete({ }, did: ObjectId) { if (!this.user.own(this.ddoc!)) this.checkPriv(PRIV.PRIV_EDIT_SYSTEM); await Promise.all([ BlogModel.del(did), diff --git a/packages/blog/package.json b/packages/blog/package.json index c61d1c9ab..75f485b3a 100644 --- a/packages/blog/package.json +++ b/packages/blog/package.json @@ -1,5 +1,5 @@ { "name": "@hydrooj/blog", - "version": "0.1.0-beta.0", + "version": "0.1.0-beta.1", "license": "AGPL-3.0-or-later" } diff --git a/packages/components/package.json b/packages/components/package.json index fa8922738..e07dd62c1 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -15,7 +15,7 @@ "devDependencies": { "@types/diff": "^8.0.0", "@types/js-yaml": "^4.0.9", - "@types/lodash": "^4.17.17", + "@types/lodash": "^4.17.18", "sass": "^1.89.2" }, "peerDependencies": { diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 207eb8954..f58eff2da 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", @@ -60,7 +60,7 @@ "@types/fs-extra": "^11.0.4", "@types/js-yaml": "^4.0.9", "@types/koa-static-cache": "^5.1.4", - "@types/lodash": "^4.17.17", + "@types/lodash": "^4.17.18", "@types/mime-types": "^2.1.4", "@types/mongodb-uri": "^0.9.4", "@types/nodemailer": "^6.4.17", diff --git a/packages/hydrooj/src/handler/connection.ts b/packages/hydrooj/src/handler/connection.ts new file mode 100644 index 000000000..1074468d3 --- /dev/null +++ b/packages/hydrooj/src/handler/connection.ts @@ -0,0 +1,113 @@ +import { Context } from '../context'; +import { ForbiddenError } from '../error'; +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 { + noCheckPermView = true; + category = '#gateway'; + private id = Math.random().toString(16).substring(2); + private privileged = false; + private channels: Set = new Set(); + private disposables: Map void> = new Map(); + + async prepare() { + const secret = SystemModel.get('websocket.secret'); + 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, id=%s', this.request.ip, this.id); + this.privileged = true; + } + await this.ctx.parallel('subscription/init', this, this.privileged); + } + + async accept(channel: string) { + if (this.channels.has(channel)) return; + this.channels.add(channel); + const d: (() => void)[] = []; + const disposeAll = () => { + for (const dispose of d) dispose(); + }; + this.disposables.set(channel, disposeAll); + await this.ctx.parallel('subscription/enable', channel, this, this.privileged, (disposable) => { + d.push(disposable); + }); + } + + async message(payload: any) { + try { + if (['resume', 'subscribe'].includes(payload.operation)) await this.subscribe(payload); + if (payload.operation === 'unsubscribe') await this.unsubscribe(payload); + } catch (e) { + logger.error('Failed to process message %o: %o', payload, e); + } + } + + async unsubscribe(payload: any) { + for (const channel of payload.channels || []) { + try { + if (!this.channels.has(channel)) continue; + this.channels.delete(channel); + this.disposables.get(channel)?.(); + this.disposables.delete(channel); + } catch (e) { + logger.error('Error unsubscribing from channel %s: %s', channel, e); + } + } + } + + async subscribe(payload: any) { + const accept = []; + const reject = []; + const session = payload.credential + ? await TokenModel.get(payload.credential, TokenModel.TYPE_SESSION) + : null; + const op = payload.operation || ''; + if (op === 'resume' && !this.privileged) { + this.send({ operation: 'resume_failed' }); + return; + } + const user = op === 'resume' ? null : await UserModel.getById('system', session?.uid || 0); + for (const channel of payload.channels || []) { + try { + const result = op === 'resume' + ? { ok: true, channel } + // eslint-disable-next-line no-await-in-loop + : await this.ctx.bail('subscription/subscribe', channel, user, this.privileged ? payload.metadata || {} : {}); + if (result?.ok) { + // eslint-disable-next-line no-await-in-loop + await this.accept(result.channel); + 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); + } + } + if (op !== 'resume') { + this.send({ + operation: 'verify', + accept, + reject, + request_id: payload.request_id, + subscription_id: payload.subscription_id, + }); + } + } + + async cleanup() { + if (this.privileged) { + logger.info('Gateway websocket disconnected from %s, id=%s', this.request.ip, this.id); + } + } +} + +export function apply(ctx: Context) { + ctx.Connection('websocket_gateway', '/websocket', WebsocketEventsConnectionManagerHandler); +} 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(); } diff --git a/packages/hydrooj/src/handler/home.ts b/packages/hydrooj/src/handler/home.ts index 892b5ff10..c4d9f8262 100644 --- a/packages/hydrooj/src/handler/home.ts +++ b/packages/hydrooj/src/handler/home.ts @@ -11,7 +11,7 @@ import { NotFoundError, PermissionError, UserAlreadyExistError, UserNotFoundError, ValidationError, VerifyPasswordError, } from '../error'; -import { DomainDoc, MessageDoc, Setting } from '../interface'; +import { DomainDoc, Setting } from '../interface'; import avatar, { validate } from '../lib/avatar'; import * as mail from '../lib/mail'; import { verifyTFA } from '../lib/verifyTFA'; @@ -29,7 +29,7 @@ import token from '../model/token'; import * as training from '../model/training'; import user from '../model/user'; import { - ConnectionHandler, Handler, param, query, requireSudo, subscribe, Types, + Handler, param, query, requireSudo, Types, } from '../service/server'; import { camelCase, md5 } from '../utils'; @@ -608,18 +608,6 @@ class HomeMessagesHandler extends Handler { } } -class HomeMessagesConnectionHandler extends ConnectionHandler { - category = '#message'; - - @subscribe('user/message') - async onMessageReceived(uid: number, mdoc: MessageDoc) { - if (uid !== this.user._id) return; - const udoc = (await user.getById(this.args.domainId, mdoc.from))!; - udoc.avatarUrl = avatar(udoc.avatar, 64); - this.send({ udoc, mdoc }); - } -} - export const inject = { geoip: { required: false }, oauth: {} }; export function apply(ctx: Context) { ctx.Route('homepage', '/', HomeHandler); @@ -630,5 +618,38 @@ export function apply(ctx: Context) { ctx.Route('home_domain', '/home/domain', HomeDomainHandler, PRIV.PRIV_USER_PROFILE); ctx.Route('home_domain_create', '/home/domain/create', HomeDomainCreateHandler, PRIV.PRIV_CREATE_DOMAIN); ctx.Route('home_messages', '/home/messages', HomeMessagesHandler, PRIV.PRIV_USER_PROFILE); - ctx.Connection('home_messages_conn', '/home/messages-conn', HomeMessagesConnectionHandler, PRIV.PRIV_USER_PROFILE); + + async function notifyMessage(uid: number[], mdoc: any, h) { + const udoc = (await user.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, udoc) => { // eslint-disable-line consistent-return + if (channel === 'message' && udoc.hasPriv(PRIV.PRIV_USER_PROFILE)) { + return { + ok: true, + channel: `message:${udoc._id}`, + }; + } + }); } diff --git a/packages/hydrooj/src/model/message.ts b/packages/hydrooj/src/model/message.ts index df96a1e86..291a64afd 100644 --- a/packages/hydrooj/src/model/message.ts +++ b/packages/hydrooj/src/model/message.ts @@ -18,17 +18,19 @@ 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); + 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); - return mdoc; + return base; } static async sendInfo(to: number, content: string) { @@ -36,7 +38,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 a68dcad42..cac53a58c 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -296,13 +296,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/plugin-api.ts b/packages/hydrooj/src/plugin-api.ts index 3cc4ea6a6..58cebb39d 100644 --- a/packages/hydrooj/src/plugin-api.ts +++ b/packages/hydrooj/src/plugin-api.ts @@ -6,6 +6,7 @@ export { default as moment, isMoment } from 'moment-timezone'; export { WebService, Router, HandlerCommon, httpServer, + Query, Mutation, Subscription, Apis, } from '@hydrooj/framework'; export * from './pipelineUtils'; diff --git a/packages/hydrooj/src/service/bus.ts b/packages/hydrooj/src/service/bus.ts index 5e3f21647..1ecde1fb7 100644 --- a/packages/hydrooj/src/service/bus.ts +++ b/packages/hydrooj/src/service/bus.ts @@ -22,6 +22,12 @@ 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, metadata: Record) => VoidReturn + 'subscription/enable': ( + channel: string, h: ConnectionHandler, privileged: boolean, onDispose: (disposable: () => void) => void + ) => VoidReturn + 'app/watch/change': (path: string) => VoidReturn 'app/watch/unlink': (path: string) => VoidReturn @@ -36,7 +42,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/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index 089ca369a..354076ade 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -271,7 +271,7 @@ export async function apply(ctx: Context) { text: v.text, name: v.name, })); - if (!h.noCheckPermView && !h.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) h.checkPerm(PERM.PERM_VIEW); + if ((!('noCheckPermView' in h) || !h.noCheckPermView) && !h.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) h.checkPerm(PERM.PERM_VIEW); if (h.context.pendingError) throw h.context.pendingError; }); on('handler/create/ws', async (h) => { diff --git a/packages/ui-default/components/message/index.page.ts b/packages/ui-default/components/message/index.page.ts index 2c45b18a9..9a3727e85 100644 --- a/packages/ui-default/components/message/index.page.ts +++ b/packages/ui-default/components/message/index.page.ts @@ -89,9 +89,7 @@ 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 { @@ -129,9 +127,19 @@ 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 }); + if (payload.operation === 'event') { + masterChannel.postMessage({ type: 'message', payload: payload.payload }); + } }; } diff --git a/packages/ui-default/components/message/worker.ts b/packages/ui-default/components/message/worker.ts index ff7bc27b6..552f52865 100644 --- a/packages/ui-default/components/message/worker.ts +++ b/packages/ui-default/components/message/worker.ts @@ -28,19 +28,47 @@ 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(); - else if (conn && conn.readyState === conn.OPEN) return; + console.log('Init connection'); + lcookie = cookie.split('sid=')[1].split(';')[0]; + if (conn) 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()); + 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 }); @@ -54,28 +82,8 @@ function initConn(path: string, port: MessagePort, cookie: any) { if (['PermissionError', 'PrivilegeError'].includes(payload.error)) { 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); + } else if (payload.operation === 'event') { + onMessage(payload.payload); } }; } diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index b733cf7c5..23bcea72d 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.30.0", "@sentry/webpack-plugin": "^3.5.0", "@simplewebauthn/browser": "13.1.0", "@svgr/webpack": "^8.1.0", @@ -66,7 +66,7 @@ "jquery.easing": "^1.4.1", "jquery.transit": "^0.9.12", "matchmedia-polyfill": "^0.3.2", - "md-editor-rt": "^5.6.1", + "md-editor-rt": "^5.7.0", "mini-css-extract-plugin": "^2.9.2", "moment": "^2.30.1", "monaco-editor": "0.52.2", diff --git a/packages/ui-default/pages/problem_main.page.tsx b/packages/ui-default/pages/problem_main.page.tsx index a8b12837f..ea791ff5c 100644 --- a/packages/ui-default/pages/problem_main.page.tsx +++ b/packages/ui-default/pages/problem_main.page.tsx @@ -315,6 +315,10 @@ function ProblemSelectionDisplay(props) { // eslint-disable-line setPids((o) => o.filter((i) => !all.includes(i))); }); }, []); + React.useEffect(() => { + (window as any).__getPids = () => pids; + (window as any).__setPids = (newIds: string[]) => setPids(newIds); + }, [pids]); const updateCheckboxSelection = React.useCallback(() => { for (const i of $('[data-checkbox-group="problem"]:checked')) { 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 %}