mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
socket 断线自动重连,文件上传功能
This commit is contained in:
1 parent
95bfd98b43
commit
10956f1fee
18 files changed
+157
-50
No files matched your search
@@ -308,6 +308,7 @@ class HomeMessagesHandler extends Handler {
|
||||
async postSend(domainId: string, uid: number, content: string) {
|
||||
const udoc = await user.getById('system', uid);
|
||||
if (!udoc) throw new UserNotFoundError(uid);
|
||||
if (udoc.gravatar) udoc.gravatar = misc.gravatar(udoc.gravatar);
|
||||
const mdoc = await message.send(this.user._id, uid, content);
|
||||
// TODO(twd2): improve here: projection
|
||||
if (this.user._id !== uid) {
|
||||
@@ -330,7 +331,7 @@ class HomeMessagesConnectionHandler extends ConnectionHandler {
|
||||
bus.subscribe([`user_message-${this.user._id}`], this, 'onMessageReceived');
|
||||
}
|
||||
|
||||
async onMessageReceived(e) {
|
||||
async onMessageReceived(e: any) {
|
||||
this.send(e.value);
|
||||
}
|
||||
|
||||
@@ -342,17 +343,17 @@ class HomeMessagesConnectionHandler extends ConnectionHandler {
|
||||
class HomeFileHandler extends Handler {
|
||||
async get() {
|
||||
const ufdocs = await file.getMulti({ owner: this.user._id }).toArray();
|
||||
const fdict = await file.getMetaDict(ufdocs.map((ufdoc) => ufdoc._id));
|
||||
this.response.template = 'home_file.html';
|
||||
this.response.body = { ufdocs, fdict };
|
||||
this.response.body = { ufdocs };
|
||||
}
|
||||
|
||||
async postDelete(ufid) {
|
||||
@param('ufid', Types.ObjectID)
|
||||
async postDelete(domainId: string, ufid: ObjectID) {
|
||||
const ufdoc = await file.getMeta(ufid);
|
||||
if (ufdoc.owner !== this.user._id) this.checkPriv(PRIV.PRIV_DELETE_FILE);
|
||||
else this.checkPriv(PRIV.PRIV_DELETE_FILE_SELF);
|
||||
const result = await file.del(ufdoc._id);
|
||||
if (result) await user.inc(this.user._id, 'usage', ufdoc.length);
|
||||
if (result) await user.inc(this.user._id, 'usage', -ufdoc.size);
|
||||
this.back();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable camelcase */
|
||||
import fs from 'fs';
|
||||
import { ObjectID } from 'mongodb';
|
||||
import { PRIV } from '../model/builtin';
|
||||
import * as system from '../model/system';
|
||||
@@ -9,6 +10,7 @@ import * as db from '../service/db';
|
||||
import {
|
||||
Route, Handler, Types, param,
|
||||
} from '../service/server';
|
||||
import { BadRequestError } from '../error';
|
||||
|
||||
const coll = db.collection('status');
|
||||
|
||||
@@ -23,6 +25,41 @@ class FileDownloadHandler extends Handler {
|
||||
}
|
||||
}
|
||||
|
||||
class FileUploadHandler extends Handler {
|
||||
async getQuota() {
|
||||
let quota = await system.get('user.quota');
|
||||
if (this.user.hasPriv(PRIV.PRIV_UNLIMITED_QUOTA)) {
|
||||
quota = 2 ** 63 - 1;
|
||||
}
|
||||
return quota;
|
||||
}
|
||||
|
||||
async get() {
|
||||
this.response.template = 'fs_upload.html';
|
||||
this.response.body = { fdoc: null, usage: this.user.usage, quota: await this.getQuota() };
|
||||
}
|
||||
|
||||
@param('title', Types.String)
|
||||
async post(domainId: string, title: string) {
|
||||
if (!this.request.files.file) throw new BadRequestError();
|
||||
const quota = await this.getQuota();
|
||||
const lfdoc = await fs.promises.stat(this.request.files.file.path);
|
||||
let ufid: ObjectID;
|
||||
const udoc = await user.inc(this.user._id, 'usage', lfdoc.size);
|
||||
try {
|
||||
ufid = await file.add(this.request.files.file.path, title, this.user._id);
|
||||
} catch (e) {
|
||||
await user.inc(this.user._id, 'usage', -lfdoc.size);
|
||||
throw e;
|
||||
}
|
||||
const fdoc = await file.getMeta(ufid);
|
||||
this.response.template = 'fs_upload.html';
|
||||
this.response.body = {
|
||||
fdoc, ufid, usage: udoc.usage, quota,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class StatusHandler extends Handler {
|
||||
async get() {
|
||||
const stats = await coll.find().sort({ type: 1, updateAt: -1 }).toArray();
|
||||
@@ -109,6 +146,7 @@ class UiSettingsHandler extends Handler {
|
||||
export async function apply() {
|
||||
Route('file_download', '/fs/:fileId/:secret', FileDownloadHandler);
|
||||
Route('file_download_with_name', '/fs/:fileId/:name/:secret', FileDownloadHandler);
|
||||
Route('file_upload', '/fs/upload', FileUploadHandler, PRIV.PRIV_CREATE_FILE);
|
||||
Route('status', '/status', StatusHandler);
|
||||
Route('status_update', '/status/update', StatusUpdateHandler);
|
||||
Route('switch_language', '/language/:lang', SwitchLanguageHandler);
|
||||
|
||||
+23
-5
@@ -13,20 +13,25 @@ function _timestamp() {
|
||||
return Math.floor(Number(new Date()) / 1000000);
|
||||
}
|
||||
|
||||
export async function add(streamOrPath: fs.ReadStream | string, filename: string, meta = {}) {
|
||||
export async function add(
|
||||
streamOrPath: fs.ReadStream | string, filename: string,
|
||||
owner = 1, meta = {},
|
||||
) {
|
||||
let stream: fs.ReadStream;
|
||||
if (typeof streamOrPath === 'string') stream = fs.createReadStream(streamOrPath);
|
||||
else stream = streamOrPath;
|
||||
const w = gridfs.openUploadStream(filename);
|
||||
await coll.insertOne({ ...meta, _id: w.id, secret: String.random(32) });
|
||||
await coll.insertOne({
|
||||
...meta, _id: w.id, secret: String.random(32), owner, filename,
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
w.on('error', reject);
|
||||
w.on('finish', resolve);
|
||||
stream.pipe(w);
|
||||
});
|
||||
const c = await gridfs.find({ _id: w.id }).toArray();
|
||||
await coll.updateOne({ _id: w.id }, { $set: { md5: c[0].md5, size: c[0].size } });
|
||||
return w.id;
|
||||
await coll.updateOne({ _id: w.id }, { $set: { md5: c[0].md5, size: c[0].length } });
|
||||
return w.id as ObjectID;
|
||||
}
|
||||
|
||||
export function del(_id: ObjectID) {
|
||||
@@ -92,13 +97,26 @@ export function getMulti(query: any) {
|
||||
return coll.find(query);
|
||||
}
|
||||
|
||||
export async function url(_id: ObjectID, name: string | undefined) {
|
||||
async function _url(_id: ObjectID, name: string | undefined) {
|
||||
const file = await coll.findOne({ _id });
|
||||
const secret = hash(file.secret, _timestamp().toString());
|
||||
if (name) return `/fs/${_id}/${Buffer.from(name).toString('base64')}/${secret}`;
|
||||
return `/fs/${_id}/${secret}`;
|
||||
}
|
||||
|
||||
function __url(file: any, name: string | undefined) {
|
||||
const secret = hash(file.secret, _timestamp().toString());
|
||||
if (name) return `/fs/${file._id}/${Buffer.from(name).toString('base64')}/${secret}`;
|
||||
return `/fs/${file._id}/${secret}`;
|
||||
}
|
||||
|
||||
export function url(_id: ObjectID, name: string | undefined): Promise<string>;
|
||||
export function url(file: any, name: string | undefined): string;
|
||||
export function url(arg0: any, name: any) {
|
||||
if (arg0 instanceof ObjectID) return _url(arg0, name);
|
||||
return __url(arg0, name);
|
||||
}
|
||||
|
||||
global.Hydro.model.file = {
|
||||
add, get, del, inc, dec, getMeta, getWithSecret, getMulti, getMetaDict, url,
|
||||
};
|
||||
@@ -99,6 +99,7 @@ AccountSetting(
|
||||
Setting('setting_customize', 'backgroundImage', null,
|
||||
'/components/profile/backgrounds/1.jpg', 'text', 'Profile Background Image',
|
||||
'Choose the background image in your profile page.'),
|
||||
Setting('setting_storage', 'usage', null, 0, 'number', 'Userfile Usage', null, FLAG_DISABLED | FLAG_HIDDEN),
|
||||
);
|
||||
|
||||
DomainSetting(
|
||||
@@ -118,7 +119,6 @@ SystemSetting(
|
||||
Setting('setting_smtp', 'smtp.port', null, 465, 'number', 'SMTP Server Port'),
|
||||
Setting('setting_smtp', 'smtp.from', null, null, 'text', 'Mail From'),
|
||||
Setting('setting_smtp', 'smtp.secure', null, false, 'boolean', 'SSL'),
|
||||
Setting('setting_db', 'db.ver', null, 1, 'number', 'Database version', null, FLAG_DISABLED | FLAG_HIDDEN),
|
||||
Setting('setting_server', 'server.worker', null, 1, 'number', 'Server Workers Number'),
|
||||
Setting('setting_server', 'server.hostname', null, null, 'text', 'Server Hostname'),
|
||||
Setting('setting_server', 'server.host', null, null, 'text', 'Server Host'),
|
||||
@@ -154,6 +154,8 @@ SystemSetting(
|
||||
Setting('setting_constant', 'DISCUSSION_ON_MAIN', null, 20, 'number', 'DISCUSSION_ON_MAIN'),
|
||||
Setting('setting_constant', 'CONTEST_ON_MAIN', null, 10, 'number', 'CONTEST_ON_MAIN'),
|
||||
Setting('setting_constant', 'TRAINING_ON_MAIN', null, 10, 'number', 'TRAINING_ON_MAIN'),
|
||||
Setting('setting_storage', 'db.ver', null, 1, 'number', 'Database version', null, FLAG_DISABLED | FLAG_HIDDEN),
|
||||
Setting('setting_storage', 'user', null, 1, 'number', 'User Counter', null, FLAG_DISABLED | FLAG_HIDDEN),
|
||||
);
|
||||
|
||||
global.Hydro.postInit.push(
|
||||
|
||||
+2
-2
@@ -68,14 +68,14 @@ export class User implements Udoc {
|
||||
|
||||
for (const key in setting.SETTINGS_BY_KEY) {
|
||||
if (udoc[key]) this[key] = udoc[key];
|
||||
else if (setting.SETTINGS_BY_KEY[key].value) {
|
||||
else if (setting.SETTINGS_BY_KEY[key].value !== null) {
|
||||
this[key] = setting.SETTINGS_BY_KEY[key].value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key in setting.DOMAIN_USER_SETTINGS_BY_KEY) {
|
||||
if (dudoc[key]) this[key] = dudoc[key];
|
||||
else if (setting.DOMAIN_USER_SETTINGS_BY_KEY[key].value) {
|
||||
else if (setting.DOMAIN_USER_SETTINGS_BY_KEY[key].value !== null) {
|
||||
this[key] = setting.DOMAIN_USER_SETTINGS_BY_KEY[key].value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,17 @@
|
||||
<p>{{ _('ID') }}: {{ fdoc['_id'] }}</p>
|
||||
<p>{{ _('Userfile doc_id') }}: {{ ufid }}</p>
|
||||
<p>{{ _('MD5') }}: {{ fdoc['md5'] }}</p>
|
||||
<p>{{ _('Secret (keep it secret)') }}: {{ fdoc['metadata']['secret'] }}</p>
|
||||
<p>{{ _('URL') }}: {{ reverse_url('fs_get', domain_id=vj4.model.builtin.DOMAIN_ID_SYSTEM, secret=fdoc['metadata']['secret']) }}</p>
|
||||
<p>{{ _('Image Markdown') }}: <pre> }})</pre></p>
|
||||
<p>{{ _('URL') }}: {{ model.file.url(fdoc) }}</p>
|
||||
{% endif %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrfToken" value="{{ handler.csrfToken }}">
|
||||
<p>
|
||||
{{ _('New file') }}:
|
||||
<input type="text" name="desc" placeholder="{{ _('Description') }}">
|
||||
<input type="text" name="title" placeholder="{{ _('Description') }}">
|
||||
<input type="file" name="file">
|
||||
<input type="submit" value="{{ _('Upload') }}" class="rounded primary button">
|
||||
</p>
|
||||
<p>{{ _('Note: file size should be less than or equal to {0}, and file type should be in {1}. Empty files are not allowed.').format(vj4.handler.fs.FILE_MAX_LENGTH|format_size, vj4.handler.fs.ALLOWED_MIMETYPE_PREFIX) }}</p>
|
||||
<p>{{ _('Usage: {0}, Quota: {1}').format(usage|format_size, quota|format_size) }}</p>
|
||||
<p>{{ _('Note: file size should be less than or equal to {0}, and file type should be in {1}. Empty files are not allowed.').format(size(FILE_MAX_LENGTH), ALLOWED_MIMETYPE_PREFIX) }}</p>
|
||||
<p>{{ _('Usage: {0}, Quota: {1}').format(size(usage), size(quota)) }}</p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -4,8 +4,8 @@
|
||||
<div class="section__header">
|
||||
<h1 class="section__title">{{ _('My Files') }}</h1>
|
||||
<div class="section__tools">
|
||||
{% if handler.has_priv(vj4.model.builtin.PRIV_CREATE_FILE) %}
|
||||
<a class="primary rounded button" href="{{ reverse_url('fs_upload') }}" target="_blank"><span class="icon icon-add"></span> {{ _('Upload File') }}</a>
|
||||
{% if handler.user.hasPriv(model.builtin.PRIV.PRIV_CREATE_FILE) %}
|
||||
<a class="primary rounded button" href="{{ url('file_upload') }}" target="_blank"><span class="icon icon-add"></span> {{ _('Upload File') }}</a>
|
||||
{% endif %}
|
||||
<a class="tool-button" href="{{ url('wiki', page='help', anchor='file') }}"><span class="icon icon-help"></span> {{ _("What's file?") }}</a>
|
||||
</div>
|
||||
@@ -28,14 +28,12 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ufdoc in ufdocs %}
|
||||
{% with fdoc = fdict.get(ufdoc['file_id'], {}) %}
|
||||
<tr>
|
||||
<td class="col--desc">{{ ufdoc['content'] }}</td>
|
||||
<td class="col--length">{{ fdoc['length']|format_size if fdoc['length'] else '?' }}</td>
|
||||
<td class="col--hash">{{ fdoc['md5']|default('?') }}</td>
|
||||
<td class="col--action"><a class="typo-a" href="{{ handler.file_url(fdoc) if 'metadata' in fdoc else '#' }}">{{ _('Download') }}</a> · <a class="typo-a" href="#">{{ _('Code') }}</a></td>
|
||||
<td class="col--desc">{{ ufdoc.filename }}</td>
|
||||
<td class="col--length">{{ size(ufdoc.size) if ufdoc.size else '?' }}</td>
|
||||
<td class="col--hash">{{ ufdoc.md5|default('?') }}</td>
|
||||
<td class="col--action"><a class="typo-a" href="{{ model.file.url(ufdoc) }}">{{ _('Download') }}</a></td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -51,6 +51,17 @@
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu__seperator"></li>
|
||||
<li class="menu__item">
|
||||
<a href="{{ url('home_file') }}" class="menu__link">
|
||||
<span class="icon icon-file"></span> {{ _('My Files') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu__item">
|
||||
<a href="{{ url('home_domain') }}" class="menu__link">
|
||||
<span class="icon icon-web"></span> {{ _('My Domains') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu__seperator"></li>
|
||||
<li class="menu__item">
|
||||
<a href="{{ url('user_logout') }}" class="menu__link" name="nav_logout">
|
||||
<span class="icon icon-logout"></span> {{ _('Logout') }}
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { AutoloadPage } from 'vj/misc/PageLoader';
|
||||
import Notification from 'vj/components/notification';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
|
||||
const messagePage = new AutoloadPage('messagePage', () => {
|
||||
async function init(isReconnect = false) {
|
||||
const { default: SockJs } = await import('sockjs-client');
|
||||
async function init() {
|
||||
const { default: SockJs } = await import('../socket');
|
||||
|
||||
const sock = new SockJs(`/home/messages-conn?token=${UiContext.token}`);
|
||||
sock.onopen = () => {
|
||||
if (isReconnect) Notification.info('Reconnected');
|
||||
};
|
||||
sock.onmessage = (message) => {
|
||||
const msg = JSON.parse(message.data);
|
||||
console.log(msg);
|
||||
Notification.show({ title: msg.udoc.uname, message: msg.mdoc.content, duration: 0 });
|
||||
};
|
||||
sock.onclose = () => {
|
||||
Notification.warn(i18n('Disconnected from the server. Reconnecting...'));
|
||||
setTimeout(() => {
|
||||
init(true);
|
||||
}, 3000);
|
||||
Notification.show({
|
||||
title: msg.udoc.uname,
|
||||
avatar: msg.udoc.gravatar,
|
||||
message: msg.mdoc.content,
|
||||
duration: 0,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,16 +22,18 @@ export default class Notification {
|
||||
}
|
||||
|
||||
static show({
|
||||
avatar, title, message, type, duration = 3000,
|
||||
avatar, title, message, type = '', duration = 3000,
|
||||
actionButtons = [],
|
||||
}) {
|
||||
if (duration && lastNotification) {
|
||||
Notification.hide();
|
||||
}
|
||||
if (avatar) type += ' avatar';
|
||||
if (title) type += ' title';
|
||||
const $dom = $(tpl`<div class="notification ${type} hide"></div>`);
|
||||
if (avatar) $(tpl`<img width="64" height="64" class="avatar" src="${avatar}"></img>`).appendTo($dom);
|
||||
if (title) $(tpl`${title} `).appendTo($dom);
|
||||
$(tpl`${message}`).appendTo($dom);
|
||||
if (title) $(tpl`<h2>${title}</h2>`).appendTo($dom);
|
||||
$(tpl`<p>${message}</p>`).appendTo($dom);
|
||||
actionButtons.forEach((button) => {
|
||||
$(tpl`<button class="${button.class}" onclick="javascript:window.${button.funcName}();">${button.name}</button>`).appendTo($dom);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
box-shadow: 0 4px 10px rgba(#000, 0.4)
|
||||
min-width: rem(150px)
|
||||
|
||||
img
|
||||
border-radius: 50%
|
||||
|
||||
&.hide
|
||||
opacity: 0
|
||||
transform: translateY(20px) scale(0.9)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import SockJS from 'sockjs-client';
|
||||
import Notification from 'vj/components/notification';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
|
||||
export default class Sock {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.reconnect = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.sock = new SockJS(this.url);
|
||||
this.sock.onopen = () => {
|
||||
if (this.reconnect) Notification.warn(i18n('Reconnected to the server.'));
|
||||
if (this.onopen) this.onopen(this.reconnect);
|
||||
};
|
||||
this.sock.onclose = (code, reason) => {
|
||||
console.warn('Connection closed, ', code, reason);
|
||||
if (this.closed) {
|
||||
if (this.onclose) this.onclose(code, reason);
|
||||
return;
|
||||
}
|
||||
Notification.warn(i18n('Disconnected from the server. Reconnecting...'));
|
||||
this.reconnect = setTimeout(() => { this.init(); }, 3000);
|
||||
if (this.onclose) this.onclose(code, reason);
|
||||
};
|
||||
this.sock.onmessage = (message) => {
|
||||
if (process.env.NODE_ENV !== 'production') console.log('Sock.onmessage: ', message);
|
||||
if (this.onmessage) this.onmessage(message);
|
||||
};
|
||||
}
|
||||
|
||||
send(data) {
|
||||
this.sock.send(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
this.sock.close();
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const page = new NamedPage('home_messages', () => {
|
||||
}
|
||||
|
||||
async function mountComponent() {
|
||||
const { default: SockJs } = await import('sockjs-client');
|
||||
const { default: SockJs } = await import('../components/socket');
|
||||
const { default: MessagePadApp } = await import('../components/messagepad');
|
||||
const { default: MessagePadReducer } = await import('../components/messagepad/reducers');
|
||||
const {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NamedPage } from 'vj/misc/PageLoader';
|
||||
import * as recordEnum from 'vj/constant/record';
|
||||
|
||||
const page = new NamedPage('judge_playground', async () => {
|
||||
const { default: SockJs } = await import('sockjs-client');
|
||||
const { default: SockJs } = await import('../components/socket');
|
||||
|
||||
const sock = new SockJs(`/judge/consume-conn?token=${UiContext.token}`);
|
||||
sock.onopen = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NamedPage } from 'vj/misc/PageLoader';
|
||||
|
||||
const page = new NamedPage('manage_dashboard', async () => {
|
||||
const { default: SockJs } = await import('sockjs-client');
|
||||
const { default: SockJs } = await import('../components/socket');
|
||||
|
||||
const sock = new SockJs(Context.socketUrl);
|
||||
sock.onopen = () => {
|
||||
|
||||
@@ -160,7 +160,7 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor
|
||||
if (reactLoaded) return;
|
||||
$('.loader-container').show();
|
||||
|
||||
const { default: SockJs } = await import('sockjs-client');
|
||||
const { default: SockJs } = await import('../components/socket');
|
||||
const { default: ScratchpadApp } = await import('../components/scratchpad');
|
||||
const { default: ScratchpadReducer } = await import('../components/scratchpad/reducers');
|
||||
const {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NamedPage } from 'vj/misc/PageLoader';
|
||||
|
||||
const page = new NamedPage('record_detail', async () => {
|
||||
const { default: SockJs } = await import('sockjs-client');
|
||||
const { default: SockJs } = await import('../components/socket');
|
||||
const { DiffDOM } = await import('diff-dom');
|
||||
|
||||
const sock = new SockJs(Context.socketUrl);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NamedPage } from 'vj/misc/PageLoader';
|
||||
import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete';
|
||||
|
||||
const page = new NamedPage('record_main', async () => {
|
||||
const { default: SockJs } = await import('sockjs-client');
|
||||
const { default: SockJs } = await import('../components/socket');
|
||||
const { DiffDOM } = await import('diff-dom');
|
||||
|
||||
const sock = new SockJs(Context.socketUrl);
|
||||
|
||||
Reference in new issue
Block a user