file transfer permission state management
This commit is contained in:
parent
19af921913
commit
57e89bb1cc
@ -131,6 +131,26 @@
|
|||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: $background-tertiary transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background-color: $background-tertiary;
|
||||||
|
border: 2px solid $background-primary;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: $background-floating;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.transfers > p {
|
.transfers > p {
|
||||||
|
@ -44,6 +44,20 @@
|
|||||||
<span />
|
<span />
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-if="admin">
|
||||||
|
<span>File transfer</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" v-model="file_transfer" />
|
||||||
|
<span />
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li v-if="admin && file_transfer">
|
||||||
|
<span>Non-admin file transfer</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" v-model="unpriv_file_transfer" />
|
||||||
|
<span />
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
<li class="broadcast" v-if="admin">
|
<li class="broadcast" v-if="admin">
|
||||||
<div>
|
<div>
|
||||||
<span>{{ $t('setting.broadcast_title') }}</span>
|
<span>{{ $t('setting.broadcast_title') }}</span>
|
||||||
@ -366,6 +380,22 @@
|
|||||||
return this.$accessor.settings.keyboard_layout
|
return this.$accessor.settings.keyboard_layout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get file_transfer() {
|
||||||
|
return this.$accessor.settings.file_transfer
|
||||||
|
}
|
||||||
|
|
||||||
|
set file_transfer(value: boolean) {
|
||||||
|
this.$accessor.settings.setGlobalFileTransferStatus({ admin: value, unpriv: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
get unpriv_file_transfer() {
|
||||||
|
return this.$accessor.settings.unpriv_file_transfer
|
||||||
|
}
|
||||||
|
|
||||||
|
set unpriv_file_transfer(value: boolean) {
|
||||||
|
this.$accessor.settings.setGlobalFileTransferStatus({ admin: this.file_transfer, unpriv: value })
|
||||||
|
}
|
||||||
|
|
||||||
get broadcast_is_active() {
|
get broadcast_is_active() {
|
||||||
return this.$accessor.settings.broadcast_is_active
|
return this.$accessor.settings.broadcast_is_active
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<i class="fas fa-comment-alt" />
|
<i class="fas fa-comment-alt" />
|
||||||
<span>{{ $t('side.chat') }}</span>
|
<span>{{ $t('side.chat') }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li :class="{ active: tab === 'files' }" @click.stop.prevent="change('files')">
|
<li v-if="filetransferAllowed" :class="{ active: tab === 'files' }" @click.stop.prevent="change('files')">
|
||||||
<i class="fas fa-file" />
|
<i class="fas fa-file" />
|
||||||
<span>{{ $t('side.files') }}</span>
|
<span>{{ $t('side.files') }}</span>
|
||||||
</li>
|
</li>
|
||||||
@ -94,6 +94,20 @@
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class extends Vue {
|
export default class extends Vue {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
if (this.tab === 'files' && (!this.$accessor.settings.file_transfer ||
|
||||||
|
!this.$accessor.user.admin && this.$accessor.settings.unpriv_file_transfer)) {
|
||||||
|
this.change('chat')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get filetransferAllowed() {
|
||||||
|
return this.$accessor.user.admin && this.$accessor.settings.file_transfer ||
|
||||||
|
this.$accessor.settings.unpriv_file_transfer
|
||||||
|
}
|
||||||
|
|
||||||
get tab() {
|
get tab() {
|
||||||
return this.$accessor.client.tab
|
return this.$accessor.client.tab
|
||||||
}
|
}
|
||||||
|
@ -39,10 +39,7 @@ export const EVENT = {
|
|||||||
EMOTE: 'chat/emote',
|
EMOTE: 'chat/emote',
|
||||||
},
|
},
|
||||||
FILETRANSFER: {
|
FILETRANSFER: {
|
||||||
ENABLE: 'filetransfer/enable',
|
STATUS: 'filetransfer/status',
|
||||||
DISABLE: 'filetransfer/disable',
|
|
||||||
UNPRIVENABLE: 'filetransfer/unprivenable',
|
|
||||||
UNPRIVDISABLE: 'filetransfer/unprivdisable',
|
|
||||||
LIST: 'filetransfer/list',
|
LIST: 'filetransfer/list',
|
||||||
REFRESH: 'filetransfer/refresh'
|
REFRESH: 'filetransfer/refresh'
|
||||||
},
|
},
|
||||||
@ -102,10 +99,7 @@ export type SignalEvents =
|
|||||||
export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOTE
|
export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOTE
|
||||||
|
|
||||||
export type FileTransferEvents =
|
export type FileTransferEvents =
|
||||||
| typeof EVENT.FILETRANSFER.ENABLE
|
| typeof EVENT.FILETRANSFER.STATUS
|
||||||
| typeof EVENT.FILETRANSFER.DISABLE
|
|
||||||
| typeof EVENT.FILETRANSFER.UNPRIVENABLE
|
|
||||||
| typeof EVENT.FILETRANSFER.UNPRIVDISABLE
|
|
||||||
| typeof EVENT.FILETRANSFER.LIST
|
| typeof EVENT.FILETRANSFER.LIST
|
||||||
| typeof EVENT.FILETRANSFER.REFRESH
|
| typeof EVENT.FILETRANSFER.REFRESH
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@ import {
|
|||||||
SystemInitPayload,
|
SystemInitPayload,
|
||||||
AdminLockResource,
|
AdminLockResource,
|
||||||
FileTransferListPayload,
|
FileTransferListPayload,
|
||||||
|
FileTransferStatusPayload,
|
||||||
} from './messages'
|
} from './messages'
|
||||||
|
|
||||||
interface NekoEvents extends BaseEvents {}
|
interface NekoEvents extends BaseEvents {}
|
||||||
@ -361,8 +362,12 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/////////////////////////////
|
/////////////////////////////
|
||||||
// Chat Events
|
// Filetransfer Events
|
||||||
/////////////////////////////
|
/////////////////////////////
|
||||||
|
protected [EVENT.FILETRANSFER.STATUS]({ admin, unpriv }: FileTransferStatusPayload) {
|
||||||
|
this.$accessor.settings.setLocalFileTransferStatus({ admin, unpriv })
|
||||||
|
}
|
||||||
|
|
||||||
protected [EVENT.FILETRANSFER.LIST]({ cwd, files }: FileTransferListPayload) {
|
protected [EVENT.FILETRANSFER.LIST]({ cwd, files }: FileTransferListPayload) {
|
||||||
this.$accessor.files.setCwd(cwd)
|
this.$accessor.files.setCwd(cwd)
|
||||||
this.$accessor.files.setFileList(files)
|
this.$accessor.files.setFileList(files)
|
||||||
|
@ -44,6 +44,7 @@ export type WebSocketPayloads =
|
|||||||
| ChatPayload
|
| ChatPayload
|
||||||
| ChatSendPayload
|
| ChatSendPayload
|
||||||
| EmojiSendPayload
|
| EmojiSendPayload
|
||||||
|
| FileTransferStatusPayload
|
||||||
| ScreenResolutionPayload
|
| ScreenResolutionPayload
|
||||||
| ScreenConfigurationsPayload
|
| ScreenConfigurationsPayload
|
||||||
| AdminPayload
|
| AdminPayload
|
||||||
@ -198,8 +199,17 @@ export interface EmojiSendPayload {
|
|||||||
emote: string
|
emote: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// file transfer
|
// file transfer enabled
|
||||||
export interface FileTransferMessage extends WebSocketMessage, FileTransferListPayload {
|
export interface FileTransferStatusMessage extends WebSocketMessage, FileTransferStatusPayload {
|
||||||
|
event: typeof EVENT.FILETRANSFER.STATUS
|
||||||
|
}
|
||||||
|
export interface FileTransferStatusPayload {
|
||||||
|
admin: boolean,
|
||||||
|
unpriv: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// file transfer list
|
||||||
|
export interface FileTransferListMessage extends WebSocketMessage, FileTransferListPayload {
|
||||||
event: FileTransferEvents
|
event: FileTransferEvents
|
||||||
}
|
}
|
||||||
export interface FileTransferListPayload {
|
export interface FileTransferListPayload {
|
||||||
|
@ -52,6 +52,15 @@ export const actions = actionTree(
|
|||||||
accessor.files._removeTransfer(transfer)
|
accessor.files._removeTransfer(transfer)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cancelAllTransfers(store) {
|
||||||
|
for (const t of accessor.files.transfers) {
|
||||||
|
if (t.status !== 'completed') {
|
||||||
|
t.abortController?.abort()
|
||||||
|
}
|
||||||
|
accessor.files.removeTransfer(t)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
refresh(store) {
|
refresh(store) {
|
||||||
if (!accessor.connected) {
|
if (!accessor.connected) {
|
||||||
return
|
return
|
||||||
|
@ -20,6 +20,9 @@ export const state = () => {
|
|||||||
|
|
||||||
keyboard_layouts_list: {} as KeyboardLayouts,
|
keyboard_layouts_list: {} as KeyboardLayouts,
|
||||||
|
|
||||||
|
file_transfer: false,
|
||||||
|
unpriv_file_transfer: false,
|
||||||
|
|
||||||
broadcast_is_active: false,
|
broadcast_is_active: false,
|
||||||
broadcast_url: '',
|
broadcast_url: '',
|
||||||
}
|
}
|
||||||
@ -58,6 +61,14 @@ export const mutations = mutationTree(state, {
|
|||||||
set('keyboard_layout', value)
|
set('keyboard_layout', value)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setFileTransfer(state, value: boolean) {
|
||||||
|
state.file_transfer = value
|
||||||
|
},
|
||||||
|
|
||||||
|
setUnprivFileTransfer(state, value: boolean) {
|
||||||
|
state.unpriv_file_transfer = value
|
||||||
|
},
|
||||||
|
|
||||||
setKeyboardLayoutsList(state, value: KeyboardLayouts) {
|
setKeyboardLayoutsList(state, value: KeyboardLayouts) {
|
||||||
state.keyboard_layouts_list = value
|
state.keyboard_layouts_list = value
|
||||||
},
|
},
|
||||||
@ -79,6 +90,23 @@ export const actions = actionTree(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setLocalFileTransferStatus({ getters }, { admin, unpriv }) {
|
||||||
|
accessor.settings.setFileTransfer(admin)
|
||||||
|
accessor.settings.setUnprivFileTransfer(unpriv)
|
||||||
|
|
||||||
|
if (!admin || !accessor.user.admin && !unpriv) {
|
||||||
|
accessor.files.cancelAllTransfers()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessor.client.tab === 'files' && !unpriv) {
|
||||||
|
accessor.client.setTab('chat')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setGlobalFileTransferStatus({ getters}, { admin, unpriv }) {
|
||||||
|
$client.sendMessage(EVENT.FILETRANSFER.STATUS, { admin, unpriv })
|
||||||
|
},
|
||||||
|
|
||||||
broadcastStatus({ getters }, { url, isActive }) {
|
broadcastStatus({ getters }, { url, isActive }) {
|
||||||
accessor.settings.setBroadcastStatus({ url, isActive })
|
accessor.settings.setBroadcastStatus({ url, isActive })
|
||||||
},
|
},
|
||||||
|
@ -35,12 +35,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
FILETRANSFER_ENABLE = "filetransfer/enable"
|
FILETRANSFER_STATUS = "filetransfer/status"
|
||||||
FILETRANSFER_DISABLE = "filetransfer/disable"
|
FILETRANSFER_LIST = "filetransfer/list"
|
||||||
FILETRANSFER_UNPRIVENABLE = "filetransfer/unprivenable"
|
FILETRANSFER_REFRESH = "filetransfer/refresh"
|
||||||
FILETRANSFER_UNPRIVDISABLE = "filetransfer/unprivdisable"
|
|
||||||
FILETRANSFER_LIST = "filetransfer/list"
|
|
||||||
FILETRANSFER_REFRESH = "filetransfer/refresh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -108,7 +108,12 @@ type EmoteSend struct {
|
|||||||
|
|
||||||
type FileTransferTarget struct {
|
type FileTransferTarget struct {
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
ID string `json:"id"`
|
}
|
||||||
|
|
||||||
|
type FileTransferStatus struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
Admin bool `json:"admin"`
|
||||||
|
Unpriv bool `json:"unpriv"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileList struct {
|
type FileList struct {
|
||||||
|
@ -8,6 +8,36 @@ import (
|
|||||||
"m1k1o/neko/internal/utils"
|
"m1k1o/neko/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (h *MessageHandler) setFileTransferStatus(session types.Session, payload *message.FileTransferStatus) error {
|
||||||
|
if !session.Admin() {
|
||||||
|
return errors.New(session.Member().Name + " tried to toggle file transfer but they're not admin")
|
||||||
|
}
|
||||||
|
h.state.SetFileTransferState(payload.Admin, payload.Unpriv)
|
||||||
|
err := h.sessions.Broadcast(message.FileTransferStatus{
|
||||||
|
Event: event.FILETRANSFER_STATUS,
|
||||||
|
Admin: payload.Admin,
|
||||||
|
Unpriv: payload.Admin && payload.Unpriv,
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := utils.ListFiles(h.state.FileTransferPath())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg := message.FileList{
|
||||||
|
Event: event.FILETRANSFER_LIST,
|
||||||
|
Cwd: h.state.FileTransferPath(),
|
||||||
|
Files: *files,
|
||||||
|
}
|
||||||
|
if payload.Unpriv {
|
||||||
|
return h.sessions.Broadcast(msg, nil)
|
||||||
|
} else {
|
||||||
|
return h.sessions.AdminBroadcast(msg, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MessageHandler) refresh(session types.Session) error {
|
func (h *MessageHandler) refresh(session types.Session) error {
|
||||||
if !(h.state.FileTransferEnabled() && session.Admin() || h.state.UnprivFileTransferEnabled()) {
|
if !(h.state.FileTransferEnabled() && session.Admin() || h.state.UnprivFileTransferEnabled()) {
|
||||||
return errors.New(session.Member().Name + " tried to refresh file list when they can't")
|
return errors.New(session.Member().Name + " tried to refresh file list when they can't")
|
||||||
|
@ -127,6 +127,12 @@ func (h *MessageHandler) Message(id string, raw []byte) error {
|
|||||||
}), "%s failed", header.Event)
|
}), "%s failed", header.Event)
|
||||||
|
|
||||||
// File Transfer Events
|
// File Transfer Events
|
||||||
|
case event.FILETRANSFER_STATUS:
|
||||||
|
payload := &message.FileTransferStatus{}
|
||||||
|
return errors.Wrapf(
|
||||||
|
utils.Unmarshal(payload, raw, func() error {
|
||||||
|
return h.setFileTransferStatus(session, payload)
|
||||||
|
}), "%s failed", header.Event)
|
||||||
case event.FILETRANSFER_REFRESH:
|
case event.FILETRANSFER_REFRESH:
|
||||||
return errors.Wrapf(h.refresh(session), "%s failed", header.Event)
|
return errors.Wrapf(h.refresh(session), "%s failed", header.Event)
|
||||||
|
|
||||||
|
@ -78,6 +78,11 @@ func (s *State) UnprivFileTransferEnabled() bool {
|
|||||||
return s.fileTransferUnprivEnabled
|
return s.fileTransferUnprivEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) SetFileTransferState(admin bool, unpriv bool) {
|
||||||
|
s.fileTransferEnabled = admin
|
||||||
|
s.fileTransferUnprivEnabled = unpriv
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) FileTransferPath() string {
|
func (s *State) FileTransferPath() string {
|
||||||
return s.fileTransferPath
|
return s.fileTransferPath
|
||||||
}
|
}
|
||||||
|
@ -135,7 +135,19 @@ func (ws *WebSocketHandler) Start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// send file list if necessary
|
// send file list if necessary
|
||||||
if session.Admin() && ws.conf.FileTransfer || ws.conf.FileTransfer && ws.conf.UnprivFileTransfer {
|
if session.Admin() && ws.state.FileTransferEnabled() ||
|
||||||
|
ws.state.FileTransferEnabled() && ws.state.UnprivFileTransferEnabled() {
|
||||||
|
err := session.Send(
|
||||||
|
message.FileTransferStatus{
|
||||||
|
Event: event.FILETRANSFER_STATUS,
|
||||||
|
Admin: ws.state.FileTransferEnabled(),
|
||||||
|
Unpriv: ws.state.UnprivFileTransferEnabled(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ws.logger.Warn().Err(err).Msgf("file transfer status event has failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
files, err := utils.ListFiles(ws.conf.FileTransferPath)
|
files, err := utils.ListFiles(ws.conf.FileTransferPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if err := session.Send(
|
if err := session.Send(
|
||||||
@ -214,27 +226,25 @@ func (ws *WebSocketHandler) Start() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// watch for file changes
|
// watch for file changes
|
||||||
if ws.conf.FileTransfer {
|
watcher, err := fsnotify.NewWatcher()
|
||||||
watcher, err := fsnotify.NewWatcher()
|
if err != nil {
|
||||||
if err != nil {
|
ws.logger.Err(err).Msg("unable to start file transfer dir watcher")
|
||||||
ws.logger.Err(err).Msg("unable to start file transfer dir watcher")
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-watcher.Events:
|
case <-watcher.Events:
|
||||||
ws.sendFileTransferUpdate()
|
ws.sendFileTransferUpdate()
|
||||||
case err := <-watcher.Errors:
|
case err := <-watcher.Errors:
|
||||||
ws.logger.Err(err).Msg("error in file transfer dir watcher")
|
ws.logger.Err(err).Msg("error in file transfer dir watcher")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
if err := watcher.Add(ws.conf.FileTransferPath); err != nil {
|
|
||||||
ws.logger.Err(err).Msg("unable to add file transfer path to watcher")
|
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := watcher.Add(ws.conf.FileTransferPath); err != nil {
|
||||||
|
ws.logger.Err(err).Msg("unable to add file transfer path to watcher")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -364,11 +374,11 @@ func (ws *WebSocketHandler) IsAdmin(password string) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ws *WebSocketHandler) CanTransferFiles(password string) (bool, error) {
|
func (ws *WebSocketHandler) CanTransferFiles(password string) (bool, error) {
|
||||||
if !ws.conf.FileTransfer {
|
if !ws.state.FileTransferEnabled() {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ws.conf.UnprivFileTransfer {
|
if !ws.state.UnprivFileTransferEnabled() {
|
||||||
return ws.IsAdmin(password)
|
return ws.IsAdmin(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,6 +390,10 @@ func (ws *WebSocketHandler) MakeFilePath(filename string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ws *WebSocketHandler) sendFileTransferUpdate() {
|
func (ws *WebSocketHandler) sendFileTransferUpdate() {
|
||||||
|
if !ws.state.FileTransferEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
files, err := utils.ListFiles(ws.conf.FileTransferPath)
|
files, err := utils.ListFiles(ws.conf.FileTransferPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ws.logger.Err(err).Msg("unable to ls file transfer path")
|
ws.logger.Err(err).Msg("unable to ls file transfer path")
|
||||||
@ -393,7 +407,7 @@ func (ws *WebSocketHandler) sendFileTransferUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var broadcastErr error
|
var broadcastErr error
|
||||||
if ws.conf.UnprivFileTransfer {
|
if ws.state.UnprivFileTransferEnabled() {
|
||||||
broadcastErr = ws.sessions.Broadcast(message, nil)
|
broadcastErr = ws.sessions.Broadcast(message, nil)
|
||||||
} else {
|
} else {
|
||||||
broadcastErr = ws.sessions.AdminBroadcast(message, nil)
|
broadcastErr = ws.sessions.AdminBroadcast(message, nil)
|
||||||
|
Reference in New Issue
Block a user