mirror of
https://github.com/m1k1o/neko.git
synced 2024-07-24 14:40:50 +12:00
Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
1b84c7e7ba | |||
21a4b2b797 | |||
5e96bca296 | |||
c78d797fe7 | |||
57596315e9 | |||
0d7887e9d2 | |||
978fd8977d | |||
4ab5901ba9 | |||
11a862f101 | |||
b938a4e09e |
@ -3,7 +3,9 @@ FROM $BASE_IMAGE
|
||||
|
||||
# latest working version with EGL: 111.0.5563.146, revert when resolved
|
||||
# 112.0.5615.49 fails: https://github.com/VirtualGL/virtualgl/issues/229
|
||||
ARG SRC_URL="https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_111.0.5563.146-1_amd64.deb"
|
||||
# google does not provide a direct link to the deb file anymore
|
||||
# ARG SRC_URL="https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_111.0.5563.146-1_amd64.deb"
|
||||
ARG SRC_URL="https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb"
|
||||
|
||||
#
|
||||
# install google chrome
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ul>
|
||||
<li v-if="!implicitHosting && hosting">
|
||||
<li v-if="!implicitHosting && (!controlLocked || hosting)">
|
||||
<i
|
||||
:class="[
|
||||
!disabeld && shakeKbd ? 'shake' : '',
|
||||
@ -22,9 +22,9 @@
|
||||
</li>
|
||||
<li class="no-pointer" v-if="implicitHosting">
|
||||
<i
|
||||
:class="['fas', 'fa-mouse-pointer']"
|
||||
:class="[controlLocked ? 'disabled' : '', 'fas', 'fa-mouse-pointer']"
|
||||
v-tooltip="{
|
||||
content: $t('controls.has'),
|
||||
content: controlLocked ? $t('controls.hasnot') : $t('controls.has'),
|
||||
placement: 'top',
|
||||
offset: 5,
|
||||
boundariesElement: 'body',
|
||||
@ -32,7 +32,7 @@
|
||||
}"
|
||||
/>
|
||||
</li>
|
||||
<li v-if="implicitHosting || (!implicitHosting && hosting)">
|
||||
<li v-if="implicitHosting || (!implicitHosting && (!controlLocked || hosting))">
|
||||
<label
|
||||
class="switch"
|
||||
v-tooltip="{
|
||||
@ -43,7 +43,7 @@
|
||||
delay: { show: 300, hide: 100 },
|
||||
}"
|
||||
>
|
||||
<input type="checkbox" v-model="locked" :disabled="!hosting || implicitHosting" />
|
||||
<input type="checkbox" v-model="locked" :disabled="!hosting || (implicitHosting && controlLocked)" />
|
||||
<span />
|
||||
</label>
|
||||
</li>
|
||||
@ -258,6 +258,10 @@
|
||||
export default class extends Vue {
|
||||
@Prop(Boolean) readonly shakeKbd!: boolean
|
||||
|
||||
get controlLocked() {
|
||||
return 'control' in this.$accessor.locked && this.$accessor.locked['control'] && !this.$accessor.user.admin
|
||||
}
|
||||
|
||||
get disabeld() {
|
||||
return this.$accessor.remote.hosted
|
||||
}
|
||||
|
520
client/src/components/files.vue
Normal file
520
client/src/components/files.vue
Normal file
@ -0,0 +1,520 @@
|
||||
<template>
|
||||
<div class="files">
|
||||
<div class="files-cwd">
|
||||
<p>{{ cwd }}</p>
|
||||
<i class="fas fa-rotate-right refresh" @click="refresh" />
|
||||
</div>
|
||||
<div class="files-list">
|
||||
<div v-for="item in files" :key="item.name" class="files-list-item">
|
||||
<i :class="fileIcon(item)" />
|
||||
<p class="file-name" :title="item.name">{{ item.name }}</p>
|
||||
<p class="file-size">{{ fileSize(item.size) }}</p>
|
||||
<i v-if="item.type !== 'dir'" class="fas fa-download download" @click="download(item)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="transfer-area">
|
||||
<div class="transfers" v-if="transfers.length > 0">
|
||||
<p v-if="downloads.length > 0" class="transfers-list-header">
|
||||
<span>{{ $t('files.downloads') }}</span>
|
||||
<i class="fas fa-xmark remove-transfer" @click="downloads.forEach((t) => removeTransfer(t))"></i>
|
||||
</p>
|
||||
<div v-for="download in downloads" :key="download.id" class="transfers-list-item">
|
||||
<div class="transfer-info">
|
||||
<i
|
||||
class="fas transfer-status"
|
||||
:class="{
|
||||
'fa-clock': download.status === 'pending',
|
||||
'fa-arrows-rotate': download.status === 'inprogress',
|
||||
'fa-check': download.status === 'completed',
|
||||
'fa-warning': download.status === 'failed',
|
||||
}"
|
||||
></i>
|
||||
<p class="file-name" :title="download.name">{{ download.name }}</p>
|
||||
<p class="file-size">{{ Math.min(100, Math.round((download.progress / download.size) * 100)) }}%</p>
|
||||
<i class="fas fa-xmark remove-transfer" @click="removeTransfer(download)"></i>
|
||||
</div>
|
||||
<div v-if="download.status === 'failed'" class="transfer-error">{{ download.error }}</div>
|
||||
<progress
|
||||
v-else
|
||||
class="transfer-progress"
|
||||
:aria-label="download.name + ' progress'"
|
||||
:value="download.progress"
|
||||
:max="download.size"
|
||||
></progress>
|
||||
</div>
|
||||
<p v-if="uploads.length > 0" class="transfers-list-header">
|
||||
<span>{{ $t('files.uploads') }}</span>
|
||||
<i class="fas fa-xmark remove-transfer" @click="uploads.forEach((t) => removeTransfer(t))"></i>
|
||||
</p>
|
||||
<div v-for="upload in uploads" :key="upload.id" class="transfers-list-item">
|
||||
<div class="transfer-info">
|
||||
<i
|
||||
class="fas transfer-status"
|
||||
:title="upload.status"
|
||||
:class="{
|
||||
'fa-clock': upload.status === 'pending',
|
||||
'fa-arrows-rotate': upload.status === 'inprogress',
|
||||
'fa-check': upload.status === 'completed',
|
||||
'fa-warning': upload.status === 'failed',
|
||||
}"
|
||||
></i>
|
||||
<p class="file-name" :title="upload.name">{{ upload.name }}</p>
|
||||
<p class="file-size">{{ Math.min(100, Math.round((upload.progress / upload.size) * 100)) }}%</p>
|
||||
<i class="fas fa-xmark remove-transfer" @click="removeTransfer(upload)"></i>
|
||||
</div>
|
||||
<div v-if="upload.status === 'failed'" class="transfer-error">{{ upload.error }}</div>
|
||||
<progress
|
||||
v-else
|
||||
class="transfer-progress"
|
||||
:aria-label="upload.name + ' progress'"
|
||||
:value="upload.progress"
|
||||
:max="upload.size"
|
||||
></progress>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="upload-area"
|
||||
:class="{ 'upload-area-drag': uploadAreaDrag }"
|
||||
@dragover.prevent="uploadAreaDrag = true"
|
||||
@dragleave.prevent="uploadAreaDrag = false"
|
||||
@drop.prevent="(e) => upload(e.dataTransfer)"
|
||||
@click="openFileBrowser"
|
||||
>
|
||||
<i class="fas fa-file-arrow-up" />
|
||||
<p>{{ $t('files.upload_here') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.files {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
|
||||
.files-cwd {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 10px 10px 0px 10px;
|
||||
padding: 0.5em;
|
||||
font-weight: 600;
|
||||
background-color: rgba($color: #fff, $alpha: 0.05);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.files-list {
|
||||
margin: 10px 10px 10px 10px;
|
||||
background-color: rgba($color: #fff, $alpha: 0.05);
|
||||
border-radius: 5px;
|
||||
overflow-y: scroll;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.files-list-item {
|
||||
padding: 0.5em;
|
||||
border-bottom: 2px solid rgba($color: #fff, $alpha: 0.1);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.transfers-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2px solid rgba($color: #fff, $alpha: 0.1);
|
||||
}
|
||||
|
||||
.file-icon,
|
||||
.transfer-status {
|
||||
width: 14px;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.transfer-error {
|
||||
border: 1px solid $style-error;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.files-list-item:last-child {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.refresh {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
margin-left: auto;
|
||||
margin-right: 0.5em;
|
||||
color: rgba($color: #fff, $alpha: 0.4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.refresh:hover,
|
||||
.download:hover,
|
||||
.remove-transfer:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.transfer-area {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.transfers {
|
||||
margin: 10px 10px 10px 10px;
|
||||
background-color: rgba($color: #fff, $alpha: 0.05);
|
||||
border-radius: 5px;
|
||||
max-height: 50vh;
|
||||
overflow-y: scroll;
|
||||
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 {
|
||||
padding: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.transfer-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.transfer-progress {
|
||||
margin: 0px 10px 10px 10px;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
margin: 10px 10px 10px 10px;
|
||||
background-color: rgba($color: #fff, $alpha: 0.05);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-area-drag,
|
||||
.upload-area:hover {
|
||||
background-color: rgba($color: #fff, $alpha: 0.1);
|
||||
}
|
||||
|
||||
.upload-area > i {
|
||||
font-size: 4em;
|
||||
margin: 10px 10px 10px 10px;
|
||||
}
|
||||
|
||||
.upload-area > p {
|
||||
margin: 0px 10px 10px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator'
|
||||
|
||||
import Markdown from './markdown'
|
||||
import Content from './context.vue'
|
||||
import { FileTransfer, FileListItem } from '~/neko/types'
|
||||
|
||||
@Component({
|
||||
name: 'neko-files',
|
||||
components: {
|
||||
'neko-markdown': Markdown,
|
||||
'neko-context': Content,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
public uploadAreaDrag: boolean = false
|
||||
|
||||
get cwd() {
|
||||
return this.$accessor.files.cwd
|
||||
}
|
||||
|
||||
get files() {
|
||||
return this.$accessor.files.files
|
||||
}
|
||||
|
||||
get transfers() {
|
||||
return this.$accessor.files.transfers
|
||||
}
|
||||
|
||||
get downloads() {
|
||||
return this.$accessor.files.transfers.filter((t) => t.direction === 'download')
|
||||
}
|
||||
|
||||
get uploads() {
|
||||
return this.$accessor.files.transfers.filter((t) => t.direction === 'upload')
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.$accessor.files.refresh()
|
||||
}
|
||||
|
||||
download(item: FileListItem) {
|
||||
if (this.downloads.map((t) => t.name).includes(item.name)) {
|
||||
return
|
||||
}
|
||||
|
||||
const url =
|
||||
'/file?pwd=' + encodeURIComponent(this.$accessor.password) + '&filename=' + encodeURIComponent(item.name)
|
||||
const abortController = new AbortController()
|
||||
|
||||
let transfer: FileTransfer = {
|
||||
id: Math.round(Math.random() * 10000),
|
||||
name: item.name,
|
||||
direction: 'download',
|
||||
// this may be smaller than the actual transfer amount, but for large files the
|
||||
// content length is not sent (chunked transfer)
|
||||
size: item.size,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
abortController: abortController,
|
||||
}
|
||||
|
||||
this.$http
|
||||
.get(url, {
|
||||
responseType: 'blob',
|
||||
signal: abortController.signal,
|
||||
withCredentials: false,
|
||||
onDownloadProgress: (x) => {
|
||||
transfer.progress = x.loaded
|
||||
|
||||
if (x.total && transfer.size !== x.total) {
|
||||
transfer.size = x.total
|
||||
}
|
||||
if (transfer.progress === transfer.size) {
|
||||
transfer.status = 'completed'
|
||||
} else if (transfer.status !== 'inprogress') {
|
||||
transfer.status = 'inprogress'
|
||||
}
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
const url = window.URL.createObjectURL(new Blob([res.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', item.name)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
transfer.progress = transfer.size
|
||||
transfer.status = 'completed'
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$log.error(error)
|
||||
|
||||
transfer.status = 'failed'
|
||||
transfer.error = error.message
|
||||
})
|
||||
|
||||
this.$accessor.files.addTransfer(transfer)
|
||||
}
|
||||
|
||||
upload(dt: DataTransfer) {
|
||||
const url = '/file?pwd=' + encodeURIComponent(this.$accessor.password)
|
||||
this.uploadAreaDrag = false
|
||||
|
||||
for (const file of dt.files) {
|
||||
const abortController = new AbortController()
|
||||
|
||||
const formdata = new FormData()
|
||||
formdata.append('files', file, file.name)
|
||||
|
||||
let transfer: FileTransfer = {
|
||||
id: Math.round(Math.random() * 10000),
|
||||
name: file.name,
|
||||
direction: 'upload',
|
||||
size: file.size,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
abortController: abortController,
|
||||
}
|
||||
|
||||
this.$http
|
||||
.post(url, formdata, {
|
||||
signal: abortController.signal,
|
||||
withCredentials: false,
|
||||
onUploadProgress: (x: any) => {
|
||||
transfer.progress = x.loaded
|
||||
|
||||
if (transfer.size !== x.total) {
|
||||
transfer.size = x.total
|
||||
}
|
||||
if (transfer.progress === transfer.size) {
|
||||
transfer.status = 'completed'
|
||||
} else if (transfer.status !== 'inprogress') {
|
||||
transfer.status = 'inprogress'
|
||||
}
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$log.error(error)
|
||||
|
||||
transfer.status = 'failed'
|
||||
transfer.error = error.message
|
||||
})
|
||||
|
||||
this.$accessor.files.addTransfer(transfer)
|
||||
}
|
||||
}
|
||||
|
||||
openFileBrowser() {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.setAttribute('multiple', 'true')
|
||||
input.onchange = (e: Event) => {
|
||||
if (e === null) return
|
||||
|
||||
const dt = new DataTransfer()
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files === null) return
|
||||
|
||||
for (const f of target.files) {
|
||||
dt.items.add(f)
|
||||
}
|
||||
|
||||
this.upload(dt)
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
removeTransfer(transfer: FileTransfer) {
|
||||
if (transfer.status !== 'completed') {
|
||||
transfer.abortController?.abort()
|
||||
}
|
||||
this.$accessor.files.removeTransfer(transfer)
|
||||
}
|
||||
|
||||
fileIcon(file: FileListItem) {
|
||||
let className = 'file-icon fas '
|
||||
// if is directory
|
||||
if (file.type === 'dir') {
|
||||
className += 'fa-folder'
|
||||
return className
|
||||
}
|
||||
// try to get file extension
|
||||
const ext = file.name.split('.').pop()
|
||||
if (ext === undefined) {
|
||||
className += 'fa-file'
|
||||
return className
|
||||
}
|
||||
// try to find icon
|
||||
switch (ext.toLowerCase()) {
|
||||
case 'txt':
|
||||
case 'md':
|
||||
className += 'fa-file-text'
|
||||
break
|
||||
case 'pdf':
|
||||
className += 'fa-file-pdf'
|
||||
break
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
case 'gz':
|
||||
className += 'fa-archive'
|
||||
break
|
||||
case 'aac':
|
||||
case 'flac':
|
||||
case 'midi':
|
||||
case 'mp3':
|
||||
case 'ogg':
|
||||
case 'wav':
|
||||
className += 'fa-music'
|
||||
break
|
||||
case 'avi':
|
||||
case 'mkv':
|
||||
case 'mov':
|
||||
case 'mpeg':
|
||||
case 'mp4':
|
||||
case 'webm':
|
||||
className += 'fa-film'
|
||||
break
|
||||
case 'bmp':
|
||||
case 'gif':
|
||||
case 'jpeg':
|
||||
case 'jpg':
|
||||
case 'png':
|
||||
case 'svg':
|
||||
case 'tiff':
|
||||
case 'webp':
|
||||
className += 'fa-image'
|
||||
break
|
||||
default:
|
||||
className += 'fa-file'
|
||||
}
|
||||
return className
|
||||
}
|
||||
|
||||
fileSize(size: number) {
|
||||
if (size < 1024) {
|
||||
return size + ' B'
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return Math.round(size / 1024) + ' KB'
|
||||
}
|
||||
if (size < 1024 * 1024 * 1024) {
|
||||
return Math.round(size / (1024 * 1024)) + ' MB'
|
||||
}
|
||||
if (size < 1024 * 1024 * 1024 * 1024) {
|
||||
return Math.round(size / (1024 * 1024 * 1024)) + ' GB'
|
||||
}
|
||||
return Math.round(size / (1024 * 1024 * 1024 * 1024)) + ' TB'
|
||||
}
|
||||
}
|
||||
</script>
|
@ -5,6 +5,45 @@
|
||||
<span><b>n</b>.eko</span>
|
||||
</a>
|
||||
<ul class="menu">
|
||||
<li>
|
||||
<i
|
||||
:class="[{ disabled: !admin }, { locked: isLocked('control') }, 'fas', 'fa-mouse']"
|
||||
@click="toggleLock('control')"
|
||||
v-tooltip="{
|
||||
content: lockedTooltip('control'),
|
||||
placement: 'bottom',
|
||||
offset: 5,
|
||||
boundariesElement: 'body',
|
||||
delay: { show: 300, hide: 100 },
|
||||
}"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<i
|
||||
:class="[{ disabled: !admin }, { locked: isLocked('login') }, locked ? 'fa-lock' : 'fa-lock-open', 'fas']"
|
||||
@click="toggleLock('login')"
|
||||
v-tooltip="{
|
||||
content: lockedTooltip('login'),
|
||||
placement: 'bottom',
|
||||
offset: 5,
|
||||
boundariesElement: 'body',
|
||||
delay: { show: 300, hide: 100 },
|
||||
}"
|
||||
/>
|
||||
</li>
|
||||
<li v-if="fileTransfer">
|
||||
<i
|
||||
:class="[{ disabled: !admin }, { locked: isLocked('file_transfer') }, 'fas', 'fa-file']"
|
||||
@click="toggleLock('file_transfer')"
|
||||
v-tooltip="{
|
||||
content: lockedTooltip('file_transfer'),
|
||||
placement: 'bottom',
|
||||
offset: 5,
|
||||
boundariesElement: 'body',
|
||||
delay: { show: 300, hide: 100 },
|
||||
}"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span v-if="showBadge" class="badge">•</span>
|
||||
<i class="fas fa-bars toggle" @click="toggleMenu" />
|
||||
@ -123,6 +162,14 @@
|
||||
|
||||
@Component({ name: 'neko-settings' })
|
||||
export default class extends Vue {
|
||||
get admin() {
|
||||
return this.$accessor.user.admin
|
||||
}
|
||||
|
||||
get locked() {
|
||||
return this.$accessor.locked
|
||||
}
|
||||
|
||||
get side() {
|
||||
return this.$accessor.client.side
|
||||
}
|
||||
@ -135,10 +182,30 @@
|
||||
return !this.side && this.readTexts != this.texts
|
||||
}
|
||||
|
||||
get fileTransfer() {
|
||||
return this.$accessor.remote.fileTransfer
|
||||
}
|
||||
|
||||
toggleLock(resource: AdminLockResource) {
|
||||
this.$accessor.toggleLock(resource)
|
||||
}
|
||||
|
||||
isLocked(resource: AdminLockResource): boolean {
|
||||
return this.$accessor.isLocked(resource)
|
||||
}
|
||||
|
||||
readTexts: number = 0
|
||||
toggleMenu() {
|
||||
this.$accessor.client.toggleSide()
|
||||
this.readTexts = this.texts
|
||||
}
|
||||
|
||||
lockedTooltip(resource: AdminLockResource) {
|
||||
if (this.admin) {
|
||||
return this.$t(`locks.${resource}.` + (this.isLocked(resource) ? `unlock` : `lock`))
|
||||
}
|
||||
|
||||
return this.$t(`locks.${resource}.` + (this.isLocked(resource) ? `locked` : `unlocked`))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -6,6 +6,10 @@
|
||||
<i class="fas fa-comment-alt" />
|
||||
<span>{{ $t('side.chat') }}</span>
|
||||
</li>
|
||||
<li v-if="filetransferAllowed" :class="{ active: tab === 'files' }" @click.stop.prevent="change('files')">
|
||||
<i class="fas fa-file" />
|
||||
<span>{{ $t('side.files') }}</span>
|
||||
</li>
|
||||
<li :class="{ active: tab === 'settings' }" @click.stop.prevent="change('settings')">
|
||||
<i class="fas fa-sliders-h" />
|
||||
<span>{{ $t('side.settings') }}</span>
|
||||
@ -75,23 +79,47 @@
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from 'vue-property-decorator'
|
||||
import { Vue, Component, Watch } from 'vue-property-decorator'
|
||||
|
||||
import Settings from '~/components/settings.vue'
|
||||
import Chat from '~/components/chat.vue'
|
||||
import Files from '~/components/files.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko',
|
||||
components: {
|
||||
'neko-settings': Settings,
|
||||
'neko-chat': Chat,
|
||||
'neko-files': Files,
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
get filetransferAllowed() {
|
||||
return (
|
||||
this.$accessor.remote.fileTransfer && (this.$accessor.user.admin || !this.$accessor.isLocked('file_transfer'))
|
||||
)
|
||||
}
|
||||
|
||||
get tab() {
|
||||
return this.$accessor.client.tab
|
||||
}
|
||||
|
||||
@Watch('tab', { immediate: true })
|
||||
@Watch('filetransferAllowed', { immediate: true })
|
||||
onTabChange() {
|
||||
// do not show the files tab if file transfer is disabled
|
||||
if (this.tab === 'files' && !this.filetransferAllowed) {
|
||||
this.change('chat')
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('filetransferAllowed')
|
||||
onFileTransferAllowedChange() {
|
||||
if (this.filetransferAllowed) {
|
||||
this.$accessor.files.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
change(tab: string) {
|
||||
this.$accessor.client.setTab(tab)
|
||||
}
|
||||
|
@ -313,7 +313,15 @@
|
||||
}
|
||||
|
||||
get clipboard_read_available() {
|
||||
return 'clipboard' in navigator && typeof navigator.clipboard.readText === 'function'
|
||||
return (
|
||||
'clipboard' in navigator &&
|
||||
typeof navigator.clipboard.readText === 'function' &&
|
||||
// Firefox 122+ incorrectly reports that it can read the clipboard but it can't
|
||||
// instead it hangs when reading clipboard, until user clicks on the page
|
||||
// and the click itself is not handled by the page at all, also the clipboard
|
||||
// reads always fail with "Clipboard read operation is not allowed."
|
||||
navigator.userAgent.indexOf('Firefox') == -1
|
||||
)
|
||||
}
|
||||
|
||||
get clipboard_write_available() {
|
||||
@ -630,7 +638,7 @@
|
||||
}
|
||||
|
||||
async syncClipboard() {
|
||||
if (this.clipboard_read_available) {
|
||||
if (this.clipboard_read_available && window.document.hasFocus()) {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (this.clipboard !== text) {
|
||||
|
@ -52,6 +52,33 @@ export const controls = {
|
||||
unlock: 'Steuerung entsperren',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: 'Steuerung sperren (für Nutzer)',
|
||||
unlock: 'Steuerung entsperren (für Nutzer)',
|
||||
locked: 'Steuerung gesperrt (für Nutzer)',
|
||||
unlocked: 'Steuerung entsperrt (für Nutzer)',
|
||||
notif_locked: 'Steuerung sperren für Nutzer',
|
||||
notif_unlocked: 'Steuerung entsperren für Nutzer',
|
||||
},
|
||||
login: {
|
||||
lock: 'Raum sperren (für Nutzer)',
|
||||
unlock: 'Raum entsperren (für Nutzer)',
|
||||
locked: 'Raum gesperrt (für Nutzer)',
|
||||
unlocked: 'Raum entsperrt (für Nutzer)',
|
||||
notif_locked: 'Raum gesperrt',
|
||||
notif_unlocked: 'Raum entsperrt',
|
||||
},
|
||||
file_transfer: {
|
||||
lock: 'Dateiübertragung sperren (für Nutzer)',
|
||||
unlock: 'Dateiübertragung entsperren (für Nutzer)',
|
||||
locked: 'Dateiübertragung gesperrt (für Nutzer)',
|
||||
unlocked: 'Dateiübertragung entsperrt (für Nutzer)',
|
||||
notif_locked: 'Dateiübertragung gesperrt',
|
||||
notif_unlocked: 'Dateiübertragung entsperrt',
|
||||
},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Scroll-Empfindlichkeit',
|
||||
scroll_invert: 'Bildlauf umkehren',
|
||||
|
@ -54,6 +54,33 @@ export const controls = {
|
||||
hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: 'Lock Controls (for users)',
|
||||
unlock: 'Unlock Controls (for users)',
|
||||
locked: 'Controls Locked (for users)',
|
||||
unlocked: 'Controls Unlocked (for users)',
|
||||
notif_locked: 'locked controls for users',
|
||||
notif_unlocked: 'unlocked controls for users',
|
||||
},
|
||||
login: {
|
||||
lock: 'Lock Room (for users)',
|
||||
unlock: 'Unlock Room (for users)',
|
||||
locked: 'Room Locked (for users)',
|
||||
unlocked: 'Room Unlocked (for users)',
|
||||
notif_locked: 'locked the room',
|
||||
notif_unlocked: 'unlocked the room',
|
||||
},
|
||||
file_transfer: {
|
||||
lock: 'Lock File Transfer (for users)',
|
||||
unlock: 'Unlock File Transfer (for users)',
|
||||
locked: 'File Transfer Locked (for users)',
|
||||
unlocked: 'File Transfer Unlocked (for users)',
|
||||
notif_locked: 'locked file transfer',
|
||||
notif_unlocked: 'unlocked file transfer',
|
||||
},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Scroll Sensitivity',
|
||||
scroll_invert: 'Invert Scroll',
|
||||
|
@ -57,6 +57,35 @@ export const controls = {
|
||||
//hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
// TODO
|
||||
//control: {
|
||||
// lock: 'Lock Controls (for users)',
|
||||
// unlock: 'Unlock Controls (for users)',
|
||||
// locked: 'Controls Locked (for users)',
|
||||
// unlocked: 'Controls Unlocked (for users)',
|
||||
// notif_locked: 'locked controls for users',
|
||||
// notif_unlocked: 'unlocked controls for users',
|
||||
//},
|
||||
login: {
|
||||
lock: 'Bloquear sala (para usuarios)',
|
||||
unlock: 'Desbloquear sala (para usuarios)',
|
||||
locked: 'Sala bloqueada (para usuarios)',
|
||||
unlocked: 'Sala desbloqueada (para usuarios)',
|
||||
notif_locked: 'bloqueó la sala',
|
||||
notif_unlocked: 'desbloqueó la sala',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Sensibilidad del Scroll',
|
||||
scroll_invert: 'Invertir Scroll',
|
||||
|
@ -54,6 +54,34 @@ export const controls = {
|
||||
hasnot: 'Sinulle ei ole kontrolleja',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: 'Lukitse kontrollit (käyttäjiltä)',
|
||||
unlock: 'Vapauta kontrollit (käyttäjiltä)',
|
||||
locked: 'Kontrollit lukittu (käyttäjiltä)',
|
||||
unlocked: 'Kontrollit vapautettu (käyttäjiltä)',
|
||||
notif_locked: 'kontrollit on lukittu käyttäjiltä',
|
||||
notif_unlocked: 'kontrollit on vapautettu käyttäjille',
|
||||
},
|
||||
login: {
|
||||
lock: 'Lukitse huone (käyttäjiltä)',
|
||||
unlock: 'Vapauta huone (käyttäjiltä)',
|
||||
locked: 'Huone lukittu (käyttäjiltä)',
|
||||
unlocked: 'Huone vapautettu (käyttäjiltä)',
|
||||
notif_locked: 'lukittu huone',
|
||||
notif_unlocked: 'vapautettu huone',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Scrollin herkkyys',
|
||||
scroll_invert: 'Käänteinen Scroll',
|
||||
|
@ -57,6 +57,35 @@ export const controls = {
|
||||
// hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
// TODO
|
||||
//control: {
|
||||
// lock: 'Lock Controls (for users)',
|
||||
// unlock: 'Unlock Controls (for users)',
|
||||
// locked: 'Controls Locked (for users)',
|
||||
// unlocked: 'Controls Unlocked (for users)',
|
||||
// notif_locked: 'locked controls for users',
|
||||
// notif_unlocked: 'unlocked controls for users',
|
||||
//},
|
||||
login: {
|
||||
lock: 'Vérouiller la salle (pour les utilisateurs)',
|
||||
unlock: 'Dévérouiller la salle (pour les utilisateurs)',
|
||||
locked: 'Salle vérouillée (pour les utilisateurs)',
|
||||
unlocked: 'Salle dévérouillée (pour les utilisateurs)',
|
||||
notif_locked: 'a vérouillé la salle',
|
||||
notif_unlocked: 'a dévérouillé la salle',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Sensibilité de défilement (scroll)',
|
||||
scroll_invert: 'Inverser le défilement (scroll)',
|
||||
|
@ -52,6 +52,34 @@ export const controls = {
|
||||
unlock: '조작 잠금 해제하기',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: '조작 잠그기 (사용자)',
|
||||
unlock: '조작 잠금 해제하기 (사용자)',
|
||||
locked: '조작이 잠겼습니다 (사용자)',
|
||||
unlocked: '조작 잠금이 해제됐습니다 (사용자)',
|
||||
notif_locked: '사용자의 조작을 잠궜습니다',
|
||||
notif_unlocked: '사용자의 조작 잠금을 해제했습니다',
|
||||
},
|
||||
login: {
|
||||
lock: '방 잠그기 (사용자)',
|
||||
unlock: '방 잠금 해제하기 (사용자)',
|
||||
locked: '방이 잠겼습니다 (사용자)',
|
||||
unlocked: '방 잠금이 해제됐습니다 (사용자)',
|
||||
notif_locked: '방이 잠겼습니다',
|
||||
notif_unlocked: '방 잠금이 해제됐습니다',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: '스크롤 감도',
|
||||
scroll_invert: '스크롤 반전',
|
||||
|
@ -57,6 +57,35 @@ export const controls = {
|
||||
//hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
// TODO
|
||||
//control: {
|
||||
// lock: 'Lock Controls (for users)',
|
||||
// unlock: 'Unlock Controls (for users)',
|
||||
// locked: 'Controls Locked (for users)',
|
||||
// unlocked: 'Controls Unlocked (for users)',
|
||||
// notif_locked: 'locked controls for users',
|
||||
// notif_unlocked: 'unlocked controls for users',
|
||||
//},
|
||||
login: {
|
||||
lock: 'Lås rommet (for brukere)',
|
||||
unlock: 'Lås opp rommet (for brukere)',
|
||||
locked: 'Rom låst (for brukere)',
|
||||
unlocked: 'Rom opplåst (for brukere)',
|
||||
notif_locked: 'låste rommet',
|
||||
notif_unlocked: 'låste opp rommet',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Rullingssensitivitet',
|
||||
scroll_invert: 'Inverter rulling',
|
||||
|
@ -54,6 +54,34 @@ export const controls = {
|
||||
hasnot: 'Вы не управляете',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: 'Закрепить управление (для пользователей)',
|
||||
unlock: 'Открепить управление (для пользователей)',
|
||||
locked: 'Управление закреплено (для пользователей)',
|
||||
unlocked: 'Управление откреплено (для пользователей)',
|
||||
notif_locked: 'закреплено управление для пользователей',
|
||||
notif_unlocked: 'откреплено управление для пользователей',
|
||||
},
|
||||
login: {
|
||||
lock: 'Закрыть комнату (для пользователей)',
|
||||
unlock: 'Открыть комнату (для пользователей)',
|
||||
locked: 'Комната закрыта (для пользователей)',
|
||||
unlocked: 'Комната открыта (для пользователей)',
|
||||
notif_locked: 'комната закрыта',
|
||||
notif_unlocked: 'комната открыта',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Чувствительность прокрутки',
|
||||
scroll_invert: 'Инвертировать прокрутку',
|
||||
|
@ -57,6 +57,34 @@ export const controls = {
|
||||
//hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: 'Zakázať ovládanie (pre používateľov)',
|
||||
unlock: 'Povoliť ovládanie (pre používateľov)',
|
||||
locked: 'Ovládanie je zakázané (pre používateľov)',
|
||||
unlocked: 'Ovládanie je povolené (pre používateľov)',
|
||||
notif_locked: 'zakázal/a ovládanie pre používateľov',
|
||||
notif_unlocked: 'povolil/a ovládanie pre používateľov',
|
||||
},
|
||||
login: {
|
||||
lock: 'Zamknúť miestnosť (pre používateľov)',
|
||||
unlock: 'Odomknúť miestnosť (pre používateľov)',
|
||||
locked: 'Miestnosť je zamknutá (pre používateľov)',
|
||||
unlocked: 'Miestnosť odomknutá (pre používateľov)',
|
||||
notif_locked: 'miestnosť bola zamknutá',
|
||||
notif_unlocked: 'miestnosť bola odomknutá',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Citlivosť kolieska myši',
|
||||
scroll_invert: 'Invertovať koliesko myši',
|
||||
|
@ -57,6 +57,35 @@ export const controls = {
|
||||
//hasnot: 'You do not have control',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
// TODO
|
||||
//control: {
|
||||
// lock: 'Lock Controls (for users)',
|
||||
// unlock: 'Unlock Controls (for users)',
|
||||
// locked: 'Controls Locked (for users)',
|
||||
// unlocked: 'Controls Unlocked (for users)',
|
||||
// notif_locked: 'locked controls for users',
|
||||
// notif_unlocked: 'unlocked controls for users',
|
||||
//},
|
||||
login: {
|
||||
lock: 'Lås rum (för användare)',
|
||||
unlock: 'Lås upp rummet (för användare)',
|
||||
locked: 'Rum låst (för användare)',
|
||||
unlocked: 'Rum upplåst (för användare)',
|
||||
notif_locked: 'låste rummet',
|
||||
notif_unlocked: 'låste upp rummet',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: 'Scrollkänslighet',
|
||||
scroll_invert: 'Vänd Scrollen',
|
||||
|
@ -54,6 +54,34 @@ export const controls = {
|
||||
hasnot: '你没有控制',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: '对所有用户进行锁定控制',
|
||||
unlock: '对所有用户进行解锁控制',
|
||||
locked: '锁定的控制装置',
|
||||
unlocked: '解锁的控制装置',
|
||||
notif_locked: '为用户锁定控制',
|
||||
notif_unlocked: '为用户解锁控制',
|
||||
},
|
||||
login: {
|
||||
lock: '所有用户的锁定室',
|
||||
unlock: '所有用户的解锁室',
|
||||
locked: '为所有用户锁定的房间',
|
||||
unlocked: '为所有用户解锁的房间',
|
||||
notif_locked: '锁上房间',
|
||||
notif_unlocked: '解锁房间',
|
||||
},
|
||||
// TODO
|
||||
//file_transfer: {
|
||||
// lock: 'Lock File Transfer (for users)',
|
||||
// unlock: 'Unlock File Transfer (for users)',
|
||||
// locked: 'File Transfer Locked (for users)',
|
||||
// unlocked: 'File Transfer Unlocked (for users)',
|
||||
// notif_locked: 'locked file transfer',
|
||||
// notif_unlocked: 'unlocked file transfer',
|
||||
//},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: '滚动敏感度',
|
||||
scroll_invert: '反转滚动敏感度',
|
||||
|
@ -54,6 +54,33 @@ export const controls = {
|
||||
hasnot: '您沒有控制權',
|
||||
}
|
||||
|
||||
export const locks = {
|
||||
control: {
|
||||
lock: '鎖定控制(對使用者)',
|
||||
unlock: '解鎖控制(對使用者)',
|
||||
locked: '已鎖定控制(對使用者)',
|
||||
unlocked: '已解鎖控制(對使用者)',
|
||||
notif_locked: '已鎖定使用者控制',
|
||||
notif_unlocked: '已解鎖使用者控制',
|
||||
},
|
||||
login: {
|
||||
lock: '鎖定房間(對使用者)',
|
||||
unlock: '解鎖房間(對使用者)',
|
||||
locked: '房間已鎖定(對使用者)',
|
||||
unlocked: '房間已解鎖(對使用者)',
|
||||
notif_locked: '已鎖定房間',
|
||||
notif_unlocked: '已解鎖房間',
|
||||
},
|
||||
file_transfer: {
|
||||
lock: '鎖定檔案傳輸(對使用者)',
|
||||
unlock: '解鎖檔案傳輸(對使用者)',
|
||||
locked: '檔案傳輸已鎖定(對使用者)',
|
||||
unlocked: '檔案傳輸已解鎖(對使用者)',
|
||||
notif_locked: '已鎖定檔案傳輸',
|
||||
notif_unlocked: '已解鎖檔案傳輸',
|
||||
},
|
||||
}
|
||||
|
||||
export const setting = {
|
||||
scroll: '滾動靈敏度',
|
||||
scroll_invert: '反向滾動',
|
||||
|
@ -38,6 +38,10 @@ export const EVENT = {
|
||||
MESSAGE: 'chat/message',
|
||||
EMOTE: 'chat/emote',
|
||||
},
|
||||
FILETRANSFER: {
|
||||
LIST: 'filetransfer/list',
|
||||
REFRESH: 'filetransfer/refresh',
|
||||
},
|
||||
SCREEN: {
|
||||
CONFIGURATIONS: 'screen/configurations',
|
||||
RESOLUTION: 'screen/resolution',
|
||||
@ -51,6 +55,8 @@ export const EVENT = {
|
||||
ADMIN: {
|
||||
BAN: 'admin/ban',
|
||||
KICK: 'admin/kick',
|
||||
LOCK: 'admin/lock',
|
||||
UNLOCK: 'admin/unlock',
|
||||
MUTE: 'admin/mute',
|
||||
UNMUTE: 'admin/unmute',
|
||||
CONTROL: 'admin/control',
|
||||
@ -67,6 +73,7 @@ export type WebSocketEvents =
|
||||
| MemberEvents
|
||||
| SignalEvents
|
||||
| ChatEvents
|
||||
| FileTransferEvents
|
||||
| ScreenEvents
|
||||
| BroadcastEvents
|
||||
| AdminEvents
|
||||
@ -90,6 +97,8 @@ export type SignalEvents =
|
||||
|
||||
export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOTE
|
||||
|
||||
export type FileTransferEvents = typeof EVENT.FILETRANSFER.LIST | typeof EVENT.FILETRANSFER.REFRESH
|
||||
|
||||
export type ScreenEvents = typeof EVENT.SCREEN.CONFIGURATIONS | typeof EVENT.SCREEN.RESOLUTION | typeof EVENT.SCREEN.SET
|
||||
|
||||
export type BroadcastEvents =
|
||||
@ -100,6 +109,8 @@ export type BroadcastEvents =
|
||||
export type AdminEvents =
|
||||
| typeof EVENT.ADMIN.BAN
|
||||
| typeof EVENT.ADMIN.KICK
|
||||
| typeof EVENT.ADMIN.LOCK
|
||||
| typeof EVENT.ADMIN.UNLOCK
|
||||
| typeof EVENT.ADMIN.MUTE
|
||||
| typeof EVENT.ADMIN.UNMUTE
|
||||
| typeof EVENT.ADMIN.CONTROL
|
||||
|
@ -22,6 +22,7 @@ import {
|
||||
AdminLockMessage,
|
||||
SystemInitPayload,
|
||||
AdminLockResource,
|
||||
FileTransferListPayload,
|
||||
} from './messages'
|
||||
|
||||
interface NekoEvents extends BaseEvents {}
|
||||
@ -133,9 +134,17 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
||||
/////////////////////////////
|
||||
// System Events
|
||||
/////////////////////////////
|
||||
protected [EVENT.SYSTEM.INIT]({ implicit_hosting, file_transfer }: SystemInitPayload) {
|
||||
protected [EVENT.SYSTEM.INIT]({ implicit_hosting, locks, file_transfer }: SystemInitPayload) {
|
||||
this.$accessor.remote.setImplicitHosting(implicit_hosting)
|
||||
this.$accessor.remote.setFileTransfer(file_transfer)
|
||||
|
||||
for (const resource in locks) {
|
||||
this[EVENT.ADMIN.LOCK]({
|
||||
event: EVENT.ADMIN.LOCK,
|
||||
resource: resource as AdminLockResource,
|
||||
id: locks[resource],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
protected [EVENT.SYSTEM.DISCONNECT]({ message }: SystemMessagePayload) {
|
||||
@ -344,6 +353,14 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
||||
this.$accessor.chat.newEmote({ type: emote })
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// File Transfer Events
|
||||
/////////////////////////////
|
||||
protected [EVENT.FILETRANSFER.LIST]({ cwd, files }: FileTransferListPayload) {
|
||||
this.$accessor.files.setCwd(cwd)
|
||||
this.$accessor.files.setFileList(files)
|
||||
}
|
||||
|
||||
/////////////////////////////
|
||||
// Screen Events
|
||||
/////////////////////////////
|
||||
@ -469,6 +486,28 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
||||
})
|
||||
}
|
||||
|
||||
protected [EVENT.ADMIN.LOCK]({ id, resource }: AdminLockMessage) {
|
||||
this.$accessor.setLocked(resource)
|
||||
|
||||
this.$accessor.chat.newMessage({
|
||||
id,
|
||||
content: this.$vue.$t(`locks.${resource}.notif_locked`) as string,
|
||||
type: 'event',
|
||||
created: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
protected [EVENT.ADMIN.UNLOCK]({ id, resource }: AdminLockMessage) {
|
||||
this.$accessor.setUnlocked(resource)
|
||||
|
||||
this.$accessor.chat.newMessage({
|
||||
id,
|
||||
content: this.$vue.$t(`locks.${resource}.notif_unlocked`) as string,
|
||||
type: 'event',
|
||||
created: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
protected [EVENT.ADMIN.CONTROL]({ id, target }: AdminTargetPayload) {
|
||||
this.$accessor.remote.setHost(id)
|
||||
this.$accessor.remote.changeKeyboard()
|
||||
|
@ -8,8 +8,9 @@ import {
|
||||
ChatEvents,
|
||||
ScreenEvents,
|
||||
AdminEvents,
|
||||
FileTransferEvents,
|
||||
} from './events'
|
||||
import { Member, ScreenConfigurations, ScreenResolution } from './types'
|
||||
import { FileListItem, Member, ScreenConfigurations, ScreenResolution } from './types'
|
||||
|
||||
export type WebSocketMessages =
|
||||
| WebSocketMessage
|
||||
@ -193,6 +194,18 @@ export interface EmojiSendPayload {
|
||||
emote: string
|
||||
}
|
||||
|
||||
/*
|
||||
FILE TRANSFER PAYLOADS
|
||||
*/
|
||||
export interface FileTransferListMessage extends WebSocketMessage, FileTransferListPayload {
|
||||
event: FileTransferEvents
|
||||
}
|
||||
|
||||
export interface FileTransferListPayload {
|
||||
cwd: string
|
||||
files: FileListItem[]
|
||||
}
|
||||
|
||||
/*
|
||||
SCREEN PAYLOADS
|
||||
*/
|
||||
|
@ -22,3 +22,20 @@ export interface ScreenResolution {
|
||||
height: number
|
||||
rate: number
|
||||
}
|
||||
|
||||
export interface FileListItem {
|
||||
name: string
|
||||
type: 'file' | 'dir'
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface FileTransfer {
|
||||
id: number
|
||||
name: string
|
||||
direction: 'upload' | 'download'
|
||||
size: number
|
||||
progress: number
|
||||
status: 'pending' | 'inprogress' | 'completed' | 'failed'
|
||||
error?: string
|
||||
abortController?: AbortController
|
||||
}
|
||||
|
72
client/src/store/files.ts
Normal file
72
client/src/store/files.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { actionTree, getterTree, mutationTree } from 'typed-vuex'
|
||||
import { FileListItem, FileTransfer } from '~/neko/types'
|
||||
import { EVENT } from '~/neko/events'
|
||||
import { accessor } from '~/store'
|
||||
|
||||
export const state = () => ({
|
||||
cwd: '',
|
||||
files: [] as FileListItem[],
|
||||
transfers: [] as FileTransfer[],
|
||||
})
|
||||
|
||||
export const getters = getterTree(state, {
|
||||
//
|
||||
})
|
||||
|
||||
export const mutations = mutationTree(state, {
|
||||
_setCwd(state, cwd: string) {
|
||||
state.cwd = cwd
|
||||
},
|
||||
|
||||
_setFileList(state, files: FileListItem[]) {
|
||||
state.files = files
|
||||
},
|
||||
|
||||
_addTransfer(state, transfer: FileTransfer) {
|
||||
state.transfers = [...state.transfers, transfer]
|
||||
},
|
||||
|
||||
_removeTransfer(state, transfer: FileTransfer) {
|
||||
state.transfers = state.transfers.filter((t) => t.id !== transfer.id)
|
||||
},
|
||||
})
|
||||
|
||||
export const actions = actionTree(
|
||||
{ state, getters, mutations },
|
||||
{
|
||||
setCwd(store, cwd: string) {
|
||||
accessor.files._setCwd(cwd)
|
||||
},
|
||||
|
||||
setFileList(store, files: FileListItem[]) {
|
||||
accessor.files._setFileList(files)
|
||||
},
|
||||
|
||||
addTransfer(store, transfer: FileTransfer) {
|
||||
if (transfer.status !== 'pending') {
|
||||
return
|
||||
}
|
||||
accessor.files._addTransfer(transfer)
|
||||
},
|
||||
|
||||
removeTransfer(store, transfer: FileTransfer) {
|
||||
accessor.files._removeTransfer(transfer)
|
||||
},
|
||||
|
||||
cancelAllTransfers() {
|
||||
for (const t of accessor.files.transfers) {
|
||||
if (t.status !== 'completed') {
|
||||
t.abortController?.abort()
|
||||
}
|
||||
accessor.files.removeTransfer(t)
|
||||
}
|
||||
},
|
||||
|
||||
refresh() {
|
||||
if (!accessor.connected) {
|
||||
return
|
||||
}
|
||||
$client.sendMessage(EVENT.FILETRANSFER.REFRESH)
|
||||
},
|
||||
},
|
||||
)
|
@ -7,6 +7,7 @@ import { get, set } from '~/utils/localstorage'
|
||||
|
||||
import * as video from './video'
|
||||
import * as chat from './chat'
|
||||
import * as files from './files'
|
||||
import * as remote from './remote'
|
||||
import * as user from './user'
|
||||
import * as settings from './settings'
|
||||
@ -19,6 +20,7 @@ export const state = () => ({
|
||||
active: false,
|
||||
connecting: false,
|
||||
connected: false,
|
||||
locked: {} as Record<string, boolean>,
|
||||
})
|
||||
|
||||
export const mutations = mutationTree(state, {
|
||||
@ -31,6 +33,14 @@ export const mutations = mutationTree(state, {
|
||||
state.password = password
|
||||
},
|
||||
|
||||
setLocked(state, resource: string) {
|
||||
Vue.set(state.locked, resource, true)
|
||||
},
|
||||
|
||||
setUnlocked(state, resource: string) {
|
||||
Vue.set(state.locked, resource, false)
|
||||
},
|
||||
|
||||
setConnnecting(state) {
|
||||
state.connected = false
|
||||
state.connecting = true
|
||||
@ -46,7 +56,9 @@ export const mutations = mutationTree(state, {
|
||||
},
|
||||
})
|
||||
|
||||
export const getters = getterTree(state, {})
|
||||
export const getters = getterTree(state, {
|
||||
isLocked: (state) => (resource: AdminLockResource) => resource in state.locked && state.locked[resource],
|
||||
})
|
||||
|
||||
export const actions = actionTree(
|
||||
{ state, getters, mutations },
|
||||
@ -56,6 +68,30 @@ export const actions = actionTree(
|
||||
accessor.settings.initialise()
|
||||
},
|
||||
|
||||
lock(_, resource: AdminLockResource) {
|
||||
if (!accessor.connected || !accessor.user.admin) {
|
||||
return
|
||||
}
|
||||
|
||||
$client.sendMessage(EVENT.ADMIN.LOCK, { resource })
|
||||
},
|
||||
|
||||
unlock(_, resource: AdminLockResource) {
|
||||
if (!accessor.connected || !accessor.user.admin) {
|
||||
return
|
||||
}
|
||||
|
||||
$client.sendMessage(EVENT.ADMIN.UNLOCK, { resource })
|
||||
},
|
||||
|
||||
toggleLock(_, resource: AdminLockResource) {
|
||||
if (accessor.isLocked(resource)) {
|
||||
accessor.unlock(resource)
|
||||
} else {
|
||||
accessor.lock(resource)
|
||||
}
|
||||
},
|
||||
|
||||
login(store, { displayname, password }: { displayname: string; password: string }) {
|
||||
accessor.setLogin({ displayname, password })
|
||||
$client.login(password, displayname)
|
||||
@ -75,7 +111,7 @@ export const storePattern = {
|
||||
mutations,
|
||||
actions,
|
||||
getters,
|
||||
modules: { video, chat, user, remote, settings, client, emoji },
|
||||
modules: { video, chat, files, user, remote, settings, client, emoji },
|
||||
}
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
@ -6,6 +6,7 @@
|
||||
- Added nvidia support for firefox.
|
||||
- Added `?lang=<lang>` parameter to the URL, which will set the language of the interface (by @mbattista).
|
||||
- Added `?show_side=1` and `?mute_chat=1` parameter to the URL, for chat mute and show side (by @mbattista).
|
||||
- Added `NEKO_BROADCAST_AUTOSTART` to automatically start or do not start broadcasting when the room is created. By default, it is set to `true` because it was the previous behavior.
|
||||
|
||||
### Bugs
|
||||
- Fix incorrect version sorting for chromium, microsoft-edge, opera and ungoogledchromium.
|
||||
|
@ -107,9 +107,11 @@ nat1to1: <ip>
|
||||
#### `NEKO_BROADCAST_PIPELINE`:
|
||||
- Makes it possible to create custom gstreamer pipeline used for broadcasting, strings `{url}`, `{device}` and `{display}` will be replaced.
|
||||
#### `NEKO_BROADCAST_URL`:
|
||||
- Set a default URL for broadcast streams. Setting this value will automatically enable broadcasting when n.eko starts. It can be disabled/changed later by admins in the GUI.
|
||||
- Set a default URL for broadcast streams. It can be disabled/changed later by admins in the GUI.
|
||||
- e.g. `rtmp://<your-server>:1935/ingest/<stream-key>`
|
||||
|
||||
#### `NEKO_BROADCAST_AUTOSTART`:
|
||||
- Automatically start broadcasting when neko starts and broadcast_url is set.
|
||||
- e.g. `true`
|
||||
### Server
|
||||
|
||||
#### `NEKO_BIND`:
|
||||
|
@ -4,15 +4,18 @@ Neko UI loads, but you don't see the screen, and it gives you `connection timeou
|
||||
|
||||
## Test your client
|
||||
|
||||
Some browser may block WebRTC access by default. You can check if it is enabled by going to `about:webrtc` or `chrome://webrtc-internals` in your browser.
|
||||
Some browsers may block WebRTC access by default. You can check if it is enabled by going to `about:webrtc` or `chrome://webrtc-internals` in your browser.
|
||||
|
||||
Check if your extensions are not blocking WebRTC access. For example, Privacy Badger or Private Internet Access blocks WebRTC by default.
|
||||
Check if your extensions are not blocking WebRTC access. Following extensions are known to block or does not work properly with WebRTC:
|
||||
- Privacy Badger
|
||||
- Private Internet Access
|
||||
- PIA VPN (even if disabled)
|
||||
|
||||
Test whether your client [supports](https://www.webrtc-experiment.com/DetectRTC/) and can [connect to WebRTC](https://www.webcasts.com/webrtc/).
|
||||
|
||||
## Networking
|
||||
|
||||
Most problems are networking related.
|
||||
If you are absolutely sure, that your client is working correctly, then most likely your networking is not set up correctly.
|
||||
|
||||
### Check if your ports are correctly exposed using docker
|
||||
|
||||
@ -59,6 +62,13 @@ Then try to type on one end, you should see characters on the other side.
|
||||
|
||||
If it does not work for you, then most likely your port forwarding is not working correctly. Or your ISP is blocking traffic.
|
||||
|
||||
|
||||
If you get [`Command 'nc' not found.`](https://command-not-found.com/nc) error, you can install `netcat` package using:
|
||||
|
||||
```shell
|
||||
sudo apt-get install netcat
|
||||
```
|
||||
|
||||
### Check if your external IP was determined correctly
|
||||
|
||||
One of the first logs, when the server starts, writes down your external IP that will be sent to your clients to connect to.
|
||||
@ -67,6 +77,8 @@ One of the first logs, when the server starts, writes down your external IP that
|
||||
docker-compose logs neko | grep nat_ips
|
||||
```
|
||||
|
||||
Note: Some newer versions of docker-compose use `docker compose` instead of `docker-compose`.
|
||||
|
||||
You should see this:
|
||||
|
||||
```
|
||||
|
@ -22,7 +22,7 @@ type BroacastManagerCtx struct {
|
||||
started bool
|
||||
}
|
||||
|
||||
func broadcastNew(pipelineFn func(url string) (string, error), defaultUrl string) *BroacastManagerCtx {
|
||||
func broadcastNew(pipelineFn func(url string) (string, error), url string, started bool) *BroacastManagerCtx {
|
||||
logger := log.With().
|
||||
Str("module", "capture").
|
||||
Str("submodule", "broadcast").
|
||||
@ -31,8 +31,8 @@ func broadcastNew(pipelineFn func(url string) (string, error), defaultUrl string
|
||||
return &BroacastManagerCtx{
|
||||
logger: logger,
|
||||
pipelineFn: pipelineFn,
|
||||
url: defaultUrl,
|
||||
started: defaultUrl != "",
|
||||
url: url,
|
||||
started: started && url != "",
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -31,7 +31,7 @@ func New(desktop types.DesktopManager, config *config.Capture) *CaptureManagerCt
|
||||
// sinks
|
||||
broadcast: broadcastNew(func(url string) (string, error) {
|
||||
return NewBroadcastPipeline(config.AudioDevice, config.Display, config.BroadcastPipeline, url)
|
||||
}, config.BroadcastUrl),
|
||||
}, config.BroadcastUrl, config.BroadcastAutostart),
|
||||
audio: streamSinkNew(config.AudioCodec, func() (string, error) {
|
||||
return NewAudioPipeline(config.AudioCodec, config.AudioDevice, config.AudioPipeline, config.AudioBitrate)
|
||||
}, "audio"),
|
||||
|
@ -34,8 +34,9 @@ type Capture struct {
|
||||
AudioPipeline string
|
||||
|
||||
// broadcast
|
||||
BroadcastPipeline string
|
||||
BroadcastUrl string
|
||||
BroadcastPipeline string
|
||||
BroadcastUrl string
|
||||
BroadcastAutostart bool
|
||||
}
|
||||
|
||||
func (Capture) Init(cmd *cobra.Command) error {
|
||||
@ -155,11 +156,16 @@ func (Capture) Init(cmd *cobra.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("broadcast_url", "", "URL for broadcasting, setting this value will automatically enable broadcasting")
|
||||
cmd.PersistentFlags().String("broadcast_url", "", "a default default URL for broadcast streams, can be disabled/changed later by admins in the GUI")
|
||||
if err := viper.BindPFlag("broadcast_url", cmd.PersistentFlags().Lookup("broadcast_url")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("broadcast_autostart", true, "automatically start broadcasting when neko starts and broadcast_url is set")
|
||||
if err := viper.BindPFlag("broadcast_autostart", cmd.PersistentFlags().Lookup("broadcast_autostart")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -247,4 +253,5 @@ func (s *Capture) Set() {
|
||||
|
||||
s.BroadcastPipeline = viper.GetString("broadcast_pipeline")
|
||||
s.BroadcastUrl = viper.GetString("broadcast_url")
|
||||
s.BroadcastAutostart = viper.GetBool("broadcast_autostart")
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@ -8,6 +10,12 @@ import (
|
||||
type WebSocket struct {
|
||||
Password string
|
||||
AdminPassword string
|
||||
Locks []string
|
||||
|
||||
ControlProtection bool
|
||||
|
||||
FileTransferEnabled bool
|
||||
FileTransferPath string
|
||||
}
|
||||
|
||||
func (WebSocket) Init(cmd *cobra.Command) error {
|
||||
@ -21,10 +29,39 @@ func (WebSocket) Init(cmd *cobra.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringSlice("locks", []string{}, "resources, that will be locked when starting (control, login)")
|
||||
if err := viper.BindPFlag("locks", cmd.PersistentFlags().Lookup("locks")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().Bool("control_protection", false, "control protection means, users can gain control only if at least one admin is in the room")
|
||||
if err := viper.BindPFlag("control_protection", cmd.PersistentFlags().Lookup("control_protection")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// File transfer
|
||||
|
||||
cmd.PersistentFlags().Bool("file_transfer_enabled", false, "enable file transfer feature")
|
||||
if err := viper.BindPFlag("file_transfer_enabled", cmd.PersistentFlags().Lookup("file_transfer_enabled")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().String("file_transfer_path", "/home/neko/Downloads", "path to use for file transfer")
|
||||
if err := viper.BindPFlag("file_transfer_path", cmd.PersistentFlags().Lookup("file_transfer_path")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebSocket) Set() {
|
||||
s.Password = viper.GetString("password")
|
||||
s.AdminPassword = viper.GetString("password_admin")
|
||||
s.Locks = viper.GetStringSlice("locks")
|
||||
|
||||
s.ControlProtection = viper.GetBool("control_protection")
|
||||
|
||||
s.FileTransferEnabled = viper.GetBool("file_transfer_enabled")
|
||||
s.FileTransferPath = viper.GetString("file_transfer_path")
|
||||
s.FileTransferPath = filepath.Clean(s.FileTransferPath)
|
||||
}
|
||||
|
@ -3,9 +3,12 @@ package http
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@ -18,6 +21,8 @@ import (
|
||||
"m1k1o/neko/internal/types"
|
||||
)
|
||||
|
||||
const FILE_UPLOAD_BUF_SIZE = 65000
|
||||
|
||||
type Server struct {
|
||||
logger zerolog.Logger
|
||||
router *chi.Mux
|
||||
@ -93,6 +98,11 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop t
|
||||
return
|
||||
}
|
||||
|
||||
if webSocketHandler.IsLocked("login") {
|
||||
http.Error(w, "room is locked", http.StatusLocked)
|
||||
return
|
||||
}
|
||||
|
||||
quality, err := strconv.Atoi(r.URL.Query().Get("quality"))
|
||||
if err != nil {
|
||||
quality = 90
|
||||
@ -108,6 +118,89 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop t
|
||||
}
|
||||
})
|
||||
|
||||
// allow downloading and uploading files
|
||||
if webSocketHandler.FileTransferEnabled() {
|
||||
router.Get("/file", func(w http.ResponseWriter, r *http.Request) {
|
||||
password := r.URL.Query().Get("pwd")
|
||||
isAuthorized, err := webSocketHandler.CanTransferFiles(password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !isAuthorized {
|
||||
http.Error(w, "bad authorization", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
filename := r.URL.Query().Get("filename")
|
||||
badChars, _ := regexp.MatchString(`(?m)\.\.(?:\/|$)`, filename)
|
||||
if filename == "" || badChars {
|
||||
http.Error(w, "bad filename", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := webSocketHandler.FileTransferPath(filename)
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "not found or unable to open", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
io.Copy(w, f)
|
||||
})
|
||||
|
||||
router.Post("/file", func(w http.ResponseWriter, r *http.Request) {
|
||||
password := r.URL.Query().Get("pwd")
|
||||
isAuthorized, err := webSocketHandler.CanTransferFiles(password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !isAuthorized {
|
||||
http.Error(w, "bad authorization", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
err = r.ParseMultipartForm(32 << 20)
|
||||
if err != nil || r.MultipartForm == nil {
|
||||
logger.Warn().Err(err).Msg("failed to parse multipart form")
|
||||
http.Error(w, "error parsing form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
for _, formheader := range r.MultipartForm.File["files"] {
|
||||
filePath := webSocketHandler.FileTransferPath(formheader.Filename)
|
||||
|
||||
formfile, err := formheader.Open()
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to open formdata file")
|
||||
http.Error(w, "error writing file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer formfile.Close()
|
||||
|
||||
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to open file for writing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
io.Copy(f, formfile)
|
||||
}
|
||||
|
||||
err = r.MultipartForm.RemoveAll()
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to remove multipart form")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("true"))
|
||||
})
|
||||
|
@ -28,6 +28,8 @@ type SessionManager struct {
|
||||
capture types.CaptureManager
|
||||
members map[string]*Session
|
||||
eventsChannel chan types.SessionEvent
|
||||
// TODO: Handle locks in sessions as flags.
|
||||
controlLocked bool
|
||||
}
|
||||
|
||||
func (manager *SessionManager) New(id string, admin bool, socket types.WebSocket) types.Session {
|
||||
@ -116,6 +118,16 @@ func (manager *SessionManager) Get(id string) (types.Session, bool) {
|
||||
return session, ok
|
||||
}
|
||||
|
||||
// TODO: Handle locks in sessions as flags.
|
||||
func (manager *SessionManager) SetControlLocked(locked bool) {
|
||||
manager.controlLocked = locked
|
||||
}
|
||||
|
||||
func (manager *SessionManager) CanControl(id string) bool {
|
||||
session, ok := manager.Get(id)
|
||||
return ok && (!manager.controlLocked || session.Admin())
|
||||
}
|
||||
|
||||
func (manager *SessionManager) Admins() []*types.Member {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
@ -34,6 +34,11 @@ const (
|
||||
CHAT_EMOTE = "chat/emote"
|
||||
)
|
||||
|
||||
const (
|
||||
FILETRANSFER_LIST = "filetransfer/list"
|
||||
FILETRANSFER_REFRESH = "filetransfer/refresh"
|
||||
)
|
||||
|
||||
const (
|
||||
SCREEN_CONFIGURATIONS = "screen/configurations"
|
||||
SCREEN_RESOLUTION = "screen/resolution"
|
||||
@ -41,15 +46,17 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
BORADCAST_STATUS = "broadcast/status"
|
||||
BORADCAST_CREATE = "broadcast/create"
|
||||
BORADCAST_DESTROY = "broadcast/destroy"
|
||||
BROADCAST_STATUS = "broadcast/status"
|
||||
BROADCAST_CREATE = "broadcast/create"
|
||||
BROADCAST_DESTROY = "broadcast/destroy"
|
||||
)
|
||||
|
||||
const (
|
||||
ADMIN_BAN = "admin/ban"
|
||||
ADMIN_KICK = "admin/kick"
|
||||
ADMIN_LOCK = "admin/lock"
|
||||
ADMIN_MUTE = "admin/mute"
|
||||
ADMIN_UNLOCK = "admin/unlock"
|
||||
ADMIN_UNMUTE = "admin/unmute"
|
||||
ADMIN_CONTROL = "admin/control"
|
||||
ADMIN_RELEASE = "admin/release"
|
||||
|
@ -11,8 +11,10 @@ type Message struct {
|
||||
}
|
||||
|
||||
type SystemInit struct {
|
||||
Event string `json:"event"`
|
||||
ImplicitHosting bool `json:"implicit_hosting"`
|
||||
Event string `json:"event"`
|
||||
ImplicitHosting bool `json:"implicit_hosting"`
|
||||
Locks map[string]string `json:"locks"`
|
||||
FileTransfer bool `json:"file_transfer"`
|
||||
}
|
||||
|
||||
type SystemMessage struct {
|
||||
@ -105,6 +107,12 @@ type EmoteSend struct {
|
||||
Emote string `json:"emote"`
|
||||
}
|
||||
|
||||
type FileTransferList struct {
|
||||
Event string `json:"event"`
|
||||
Cwd string `json:"cwd"`
|
||||
Files []types.FileListItem `json:"files"`
|
||||
}
|
||||
|
||||
type Admin struct {
|
||||
Event string `json:"event"`
|
||||
ID string `json:"id"`
|
||||
|
@ -55,6 +55,8 @@ type SessionManager interface {
|
||||
ClearHost()
|
||||
Has(id string) bool
|
||||
Get(id string) (Session, bool)
|
||||
SetControlLocked(locked bool)
|
||||
CanControl(id string) bool
|
||||
Members() []*Member
|
||||
Admins() []*Member
|
||||
Destroy(id string)
|
||||
|
@ -11,12 +11,14 @@ type Stats struct {
|
||||
Members []*Member `json:"members"`
|
||||
|
||||
Banned map[string]string `json:"banned"` // IP -> session ID (that banned it)
|
||||
Locked map[string]string `json:"locked"` // resource name -> session ID (that locked it)
|
||||
|
||||
ServerStartedAt time.Time `json:"server_started_at"`
|
||||
LastAdminLeftAt *time.Time `json:"last_admin_left_at"`
|
||||
LastUserLeftAt *time.Time `json:"last_user_left_at"`
|
||||
|
||||
ImplicitControl bool `json:"implicit_control"`
|
||||
ControlProtection bool `json:"control_protection"`
|
||||
ImplicitControl bool `json:"implicit_control"`
|
||||
}
|
||||
|
||||
type WebSocket interface {
|
||||
@ -30,5 +32,17 @@ type WebSocketHandler interface {
|
||||
Shutdown() error
|
||||
Upgrade(w http.ResponseWriter, r *http.Request) error
|
||||
Stats() Stats
|
||||
IsLocked(resource string) bool
|
||||
IsAdmin(password string) (bool, error)
|
||||
|
||||
// File Transfer
|
||||
CanTransferFiles(password string) (bool, error)
|
||||
FileTransferPath(filename string) string
|
||||
FileTransferEnabled() bool
|
||||
}
|
||||
|
||||
type FileListItem struct {
|
||||
Filename string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
36
server/internal/utils/files.go
Normal file
36
server/internal/utils/files.go
Normal file
@ -0,0 +1,36 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"m1k1o/neko/internal/types"
|
||||
)
|
||||
|
||||
func ListFiles(path string) ([]types.FileListItem, error) {
|
||||
items, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]types.FileListItem, len(items))
|
||||
for i, item := range items {
|
||||
var itemType string = ""
|
||||
var size int64 = 0
|
||||
if item.IsDir() {
|
||||
itemType = "dir"
|
||||
} else {
|
||||
itemType = "file"
|
||||
info, err := item.Info()
|
||||
if err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
}
|
||||
out[i] = types.FileListItem{
|
||||
Filename: item.Name(),
|
||||
Type: itemType,
|
||||
Size: size,
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
@ -39,7 +39,7 @@ type PayloadKey struct {
|
||||
}
|
||||
|
||||
func (manager *WebRTCManager) handle(id string, msg webrtc.DataChannelMessage) error {
|
||||
if (!manager.config.ImplicitControl && !manager.sessions.IsHost(id)) || manager.config.ImplicitControl {
|
||||
if (!manager.config.ImplicitControl && !manager.sessions.IsHost(id)) || (manager.config.ImplicitControl && !manager.sessions.CanControl(id)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,78 @@ import (
|
||||
"m1k1o/neko/internal/types/message"
|
||||
)
|
||||
|
||||
func (h *MessageHandler) adminLock(id string, session types.Session, payload *message.AdminLock) error {
|
||||
if !session.Admin() {
|
||||
h.logger.Debug().Msg("user not admin")
|
||||
return nil
|
||||
}
|
||||
|
||||
if h.state.IsLocked(payload.Resource) {
|
||||
h.logger.Debug().Str("resource", payload.Resource).Msg("resource already locked...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// allow only known resources
|
||||
switch payload.Resource {
|
||||
case "login":
|
||||
case "control":
|
||||
case "file_transfer":
|
||||
default:
|
||||
h.logger.Debug().Msg("unknown lock resource")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Handle locks in sessions as flags.
|
||||
if payload.Resource == "control" {
|
||||
h.sessions.SetControlLocked(true)
|
||||
}
|
||||
|
||||
h.state.Lock(payload.Resource, id)
|
||||
|
||||
if err := h.sessions.Broadcast(
|
||||
message.AdminLock{
|
||||
Event: event.ADMIN_LOCK,
|
||||
ID: id,
|
||||
Resource: payload.Resource,
|
||||
}, nil); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_LOCK)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *MessageHandler) adminUnlock(id string, session types.Session, payload *message.AdminLock) error {
|
||||
if !session.Admin() {
|
||||
h.logger.Debug().Msg("user not admin")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !h.state.IsLocked(payload.Resource) {
|
||||
h.logger.Debug().Str("resource", payload.Resource).Msg("resource not locked...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Handle locks in sessions as flags.
|
||||
if payload.Resource == "control" {
|
||||
h.sessions.SetControlLocked(false)
|
||||
}
|
||||
|
||||
h.state.Unlock(payload.Resource)
|
||||
|
||||
if err := h.sessions.Broadcast(
|
||||
message.AdminLock{
|
||||
Event: event.ADMIN_UNLOCK,
|
||||
ID: id,
|
||||
Resource: payload.Resource,
|
||||
}, nil); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_UNLOCK)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *MessageHandler) adminControl(id string, session types.Session) error {
|
||||
if !session.Admin() {
|
||||
h.logger.Debug().Msg("user not admin")
|
||||
@ -103,7 +175,7 @@ func (h *MessageHandler) adminGive(id string, session types.Session, payload *me
|
||||
ID: id,
|
||||
Target: payload.ID,
|
||||
}, nil); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_LOCKED)
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_GIVE)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -135,7 +207,7 @@ func (h *MessageHandler) adminMute(id string, session types.Session, payload *me
|
||||
Target: target.ID(),
|
||||
ID: id,
|
||||
}, nil); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_UNMUTE)
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_MUTE)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,7 @@ import (
|
||||
"m1k1o/neko/internal/types/message"
|
||||
)
|
||||
|
||||
func (h *MessageHandler) boradcastCreate(session types.Session, payload *message.BroadcastCreate) error {
|
||||
func (h *MessageHandler) broadcastCreate(session types.Session, payload *message.BroadcastCreate) error {
|
||||
broadcast := h.capture.Broadcast()
|
||||
|
||||
if !session.Admin() {
|
||||
@ -44,14 +44,14 @@ func (h *MessageHandler) boradcastCreate(session types.Session, payload *message
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.boradcastStatus(nil); err != nil {
|
||||
if err := h.broadcastStatus(nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *MessageHandler) boradcastDestroy(session types.Session) error {
|
||||
func (h *MessageHandler) broadcastDestroy(session types.Session) error {
|
||||
broadcast := h.capture.Broadcast()
|
||||
|
||||
if !session.Admin() {
|
||||
@ -70,18 +70,18 @@ func (h *MessageHandler) boradcastDestroy(session types.Session) error {
|
||||
|
||||
broadcast.Stop()
|
||||
|
||||
if err := h.boradcastStatus(nil); err != nil {
|
||||
if err := h.broadcastStatus(nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *MessageHandler) boradcastStatus(session types.Session) error {
|
||||
func (h *MessageHandler) broadcastStatus(session types.Session) error {
|
||||
broadcast := h.capture.Broadcast()
|
||||
|
||||
msg := message.BroadcastStatus{
|
||||
Event: event.BORADCAST_STATUS,
|
||||
Event: event.BROADCAST_STATUS,
|
||||
IsActive: broadcast.Started(),
|
||||
URL: broadcast.Url(),
|
||||
}
|
||||
@ -89,7 +89,7 @@ func (h *MessageHandler) boradcastStatus(session types.Session) error {
|
||||
// if no session, broadcast change
|
||||
if session == nil {
|
||||
if err := h.sessions.AdminBroadcast(msg, nil); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.BORADCAST_STATUS)
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.BROADCAST_STATUS)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -102,7 +102,7 @@ func (h *MessageHandler) boradcastStatus(session types.Session) error {
|
||||
}
|
||||
|
||||
if err := session.Send(msg); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("sending event %s has failed", event.BORADCAST_STATUS)
|
||||
h.logger.Warn().Err(err).Msgf("sending event %s has failed", event.BROADCAST_STATUS)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -17,7 +17,7 @@ func (h *MessageHandler) chat(id string, session types.Session, payload *message
|
||||
Content: payload.Content,
|
||||
ID: id,
|
||||
}, nil); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_RELEASE)
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CHAT_MESSAGE)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@ -34,7 +34,7 @@ func (h *MessageHandler) chatEmote(id string, session types.Session, payload *me
|
||||
Emote: payload.Emote,
|
||||
ID: id,
|
||||
}, nil); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_RELEASE)
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CHAT_EMOTE)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
@ -33,6 +33,12 @@ func (h *MessageHandler) controlRelease(id string, session types.Session) error
|
||||
func (h *MessageHandler) controlRequest(id string, session types.Session) error {
|
||||
// check for host
|
||||
if !h.sessions.HasHost() {
|
||||
// check if control is locked or user is admin
|
||||
if h.state.IsLocked("control") && !session.Admin() {
|
||||
h.logger.Debug().Msg("control is locked")
|
||||
return nil
|
||||
}
|
||||
|
||||
// set host
|
||||
err := h.sessions.SetHost(id)
|
||||
if err != nil {
|
||||
@ -90,6 +96,12 @@ func (h *MessageHandler) controlGive(id string, session types.Session, payload *
|
||||
return nil
|
||||
}
|
||||
|
||||
// check if control is locked or giver is admin
|
||||
if h.state.IsLocked("control") && !session.Admin() {
|
||||
h.logger.Debug().Msg("control is locked")
|
||||
return nil
|
||||
}
|
||||
|
||||
// set host
|
||||
err := h.sessions.SetHost(payload.ID)
|
||||
if err != nil {
|
||||
@ -103,7 +115,7 @@ func (h *MessageHandler) controlGive(id string, session types.Session, payload *
|
||||
ID: id,
|
||||
Target: payload.ID,
|
||||
}, nil); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_LOCKED)
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_GIVE)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -112,7 +124,7 @@ func (h *MessageHandler) controlGive(id string, session types.Session, payload *
|
||||
|
||||
func (h *MessageHandler) controlClipboard(id string, session types.Session, payload *message.Clipboard) error {
|
||||
// check if session can access clipboard
|
||||
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || h.webrtc.ImplicitControl() {
|
||||
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || (h.webrtc.ImplicitControl() && !h.sessions.CanControl(id)) {
|
||||
h.logger.Debug().Str("id", id).Msg("cannot access clipboard")
|
||||
return nil
|
||||
}
|
||||
@ -123,7 +135,7 @@ func (h *MessageHandler) controlClipboard(id string, session types.Session, payl
|
||||
|
||||
func (h *MessageHandler) controlKeyboard(id string, session types.Session, payload *message.Keyboard) error {
|
||||
// check if session can control keyboard
|
||||
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || h.webrtc.ImplicitControl() {
|
||||
if (!h.webrtc.ImplicitControl() && !h.sessions.IsHost(id)) || (h.webrtc.ImplicitControl() && !h.sessions.CanControl(id)) {
|
||||
h.logger.Debug().Str("id", id).Msg("cannot control keyboard")
|
||||
return nil
|
||||
}
|
||||
|
47
server/internal/websocket/handler/filetransfer.go
Normal file
47
server/internal/websocket/handler/filetransfer.go
Normal file
@ -0,0 +1,47 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"m1k1o/neko/internal/types"
|
||||
"m1k1o/neko/internal/types/event"
|
||||
"m1k1o/neko/internal/types/message"
|
||||
"m1k1o/neko/internal/utils"
|
||||
)
|
||||
|
||||
func (h *MessageHandler) FileTransferRefresh(session types.Session) error {
|
||||
if !h.state.FileTransferEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileTransferPath := h.state.FileTransferPath("") // root
|
||||
|
||||
// allow users only if file transfer is not locked
|
||||
if session != nil && !(session.Admin() || !h.state.IsLocked("file_transfer")) {
|
||||
h.logger.Debug().Msg("file transfer is locked for users")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: keep list of files in memory and update it on file changes
|
||||
files, err := utils.ListFiles(fileTransferPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message := message.FileTransferList{
|
||||
Event: event.FILETRANSFER_LIST,
|
||||
Cwd: fileTransferPath,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
// send to just one user
|
||||
if session != nil {
|
||||
return session.Send(message)
|
||||
}
|
||||
|
||||
// broadcast to all admins
|
||||
if h.state.IsLocked("file_transfer") {
|
||||
return h.sessions.AdminBroadcast(message, nil)
|
||||
}
|
||||
|
||||
// broadcast to all users
|
||||
return h.sessions.Broadcast(message, nil)
|
||||
}
|
@ -50,6 +50,11 @@ func (h *MessageHandler) Connected(admin bool, address string) (bool, string) {
|
||||
}
|
||||
}
|
||||
|
||||
if h.state.IsLocked("login") && !admin {
|
||||
h.logger.Debug().Msg("server locked")
|
||||
return false, "locked"
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
@ -127,6 +132,10 @@ func (h *MessageHandler) Message(id string, raw []byte) error {
|
||||
return h.chatEmote(id, session, payload)
|
||||
}), "%s failed", header.Event)
|
||||
|
||||
// File Transfer Events
|
||||
case event.FILETRANSFER_REFRESH:
|
||||
return errors.Wrapf(h.FileTransferRefresh(session), "%s failed", header.Event)
|
||||
|
||||
// Screen Events
|
||||
case event.SCREEN_RESOLUTION:
|
||||
return errors.Wrapf(h.screenResolution(id, session), "%s failed", header.Event)
|
||||
@ -139,17 +148,29 @@ func (h *MessageHandler) Message(id string, raw []byte) error {
|
||||
return h.screenSet(id, session, payload)
|
||||
}), "%s failed", header.Event)
|
||||
|
||||
// Boradcast Events
|
||||
case event.BORADCAST_CREATE:
|
||||
// Broadcast Events
|
||||
case event.BROADCAST_CREATE:
|
||||
payload := &message.BroadcastCreate{}
|
||||
return errors.Wrapf(
|
||||
utils.Unmarshal(payload, raw, func() error {
|
||||
return h.boradcastCreate(session, payload)
|
||||
return h.broadcastCreate(session, payload)
|
||||
}), "%s failed", header.Event)
|
||||
case event.BORADCAST_DESTROY:
|
||||
return errors.Wrapf(h.boradcastDestroy(session), "%s failed", header.Event)
|
||||
case event.BROADCAST_DESTROY:
|
||||
return errors.Wrapf(h.broadcastDestroy(session), "%s failed", header.Event)
|
||||
|
||||
// Admin Events
|
||||
case event.ADMIN_LOCK:
|
||||
payload := &message.AdminLock{}
|
||||
return errors.Wrapf(
|
||||
utils.Unmarshal(payload, raw, func() error {
|
||||
return h.adminLock(id, session, payload)
|
||||
}), "%s failed", header.Event)
|
||||
case event.ADMIN_UNLOCK:
|
||||
payload := &message.AdminLock{}
|
||||
return errors.Wrapf(
|
||||
utils.Unmarshal(payload, raw, func() error {
|
||||
return h.adminUnlock(id, session, payload)
|
||||
}), "%s failed", header.Event)
|
||||
case event.ADMIN_CONTROL:
|
||||
return errors.Wrapf(h.adminControl(id, session), "%s failed", header.Event)
|
||||
case event.ADMIN_RELEASE:
|
||||
|
@ -16,6 +16,8 @@ func (h *MessageHandler) SessionCreated(id string, session types.Session) error
|
||||
if err := session.Send(message.SystemInit{
|
||||
Event: event.SYSTEM_INIT,
|
||||
ImplicitHosting: h.webrtc.ImplicitControl(),
|
||||
Locks: h.state.AllLocked(),
|
||||
FileTransfer: h.state.FileTransferEnabled(),
|
||||
}); err != nil {
|
||||
h.logger.Warn().Str("id", id).Err(err).Msgf("sending event %s has failed", event.SYSTEM_INIT)
|
||||
return err
|
||||
@ -28,7 +30,14 @@ func (h *MessageHandler) SessionCreated(id string, session types.Session) error
|
||||
}
|
||||
|
||||
// send broadcast status if admin
|
||||
if err := h.boradcastStatus(session); err != nil {
|
||||
if err := h.broadcastStatus(session); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// send file list if file transfer is enabled
|
||||
if h.state.FileTransferEnabled() && (session.Admin() || !h.state.IsLocked("file_transfer")) {
|
||||
if err := h.FileTransferRefresh(session); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -69,7 +78,7 @@ func (h *MessageHandler) SessionConnected(id string, session types.Session) erro
|
||||
Event: event.MEMBER_CONNECTED,
|
||||
Member: session.Member(),
|
||||
}, nil); err != nil {
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.CONTROL_RELEASE)
|
||||
h.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.MEMBER_CONNECTED)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,22 @@
|
||||
package state
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
type State struct {
|
||||
banned map[string]string // IP -> session ID (that banned it)
|
||||
locked map[string]string // resource name -> session ID (that locked it)
|
||||
|
||||
fileTransferEnabled bool
|
||||
fileTransferPath string // path where files are located
|
||||
}
|
||||
|
||||
func New() *State {
|
||||
func New(fileTransferEnabled bool, fileTransferPath string) *State {
|
||||
return &State{
|
||||
banned: make(map[string]string),
|
||||
locked: make(map[string]string),
|
||||
|
||||
fileTransferEnabled: fileTransferEnabled,
|
||||
fileTransferPath: fileTransferPath,
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,3 +43,42 @@ func (s *State) GetBanned(ip string) (string, bool) {
|
||||
func (s *State) AllBanned() map[string]string {
|
||||
return s.banned
|
||||
}
|
||||
|
||||
// Lock
|
||||
|
||||
func (s *State) Lock(resource, id string) {
|
||||
s.locked[resource] = id
|
||||
}
|
||||
|
||||
func (s *State) Unlock(resource string) {
|
||||
delete(s.locked, resource)
|
||||
}
|
||||
|
||||
func (s *State) IsLocked(resource string) bool {
|
||||
_, ok := s.locked[resource]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *State) GetLocked(resource string) (string, bool) {
|
||||
id, ok := s.locked[resource]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func (s *State) AllLocked() map[string]string {
|
||||
return s.locked
|
||||
}
|
||||
|
||||
// File transfer
|
||||
|
||||
func (s *State) FileTransferPath(filename string) string {
|
||||
if filename == "" {
|
||||
return s.fileTransferPath
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(filename)
|
||||
return filepath.Join(s.fileTransferPath, cleanPath)
|
||||
}
|
||||
|
||||
func (s *State) FileTransferEnabled() bool {
|
||||
return s.fileTransferEnabled
|
||||
}
|
||||
|
@ -3,10 +3,12 @@ package websocket
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
@ -20,10 +22,35 @@ import (
|
||||
"m1k1o/neko/internal/websocket/state"
|
||||
)
|
||||
|
||||
const CONTROL_PROTECTION_SESSION = "by_control_protection"
|
||||
|
||||
func New(sessions types.SessionManager, desktop types.DesktopManager, capture types.CaptureManager, webrtc types.WebRTCManager, conf *config.WebSocket) *WebSocketHandler {
|
||||
logger := log.With().Str("module", "websocket").Logger()
|
||||
|
||||
state := state.New()
|
||||
state := state.New(conf.FileTransferEnabled, conf.FileTransferPath)
|
||||
|
||||
// if control protection is enabled
|
||||
if conf.ControlProtection {
|
||||
state.Lock("control", CONTROL_PROTECTION_SESSION)
|
||||
logger.Info().Msgf("control locked on behalf of control protection")
|
||||
}
|
||||
|
||||
// create file transfer directory if not exists
|
||||
if conf.FileTransferEnabled {
|
||||
if _, err := os.Stat(conf.FileTransferPath); os.IsNotExist(err) {
|
||||
err = os.Mkdir(conf.FileTransferPath, os.ModePerm)
|
||||
logger.Err(err).Msg("creating file transfer directory")
|
||||
}
|
||||
}
|
||||
|
||||
// apply default locks
|
||||
for _, lock := range conf.Locks {
|
||||
state.Lock(lock, "") // empty session ID
|
||||
}
|
||||
|
||||
if len(conf.Locks) > 0 {
|
||||
logger.Info().Msgf("locked resources: %+v", conf.Locks)
|
||||
}
|
||||
|
||||
handler := handler.New(
|
||||
sessions,
|
||||
@ -96,6 +123,24 @@ func (ws *WebSocketHandler) Start() {
|
||||
ws.logger.Debug().Str("id", e.Id).Msg("session connected")
|
||||
}
|
||||
|
||||
// if control protection is enabled and at least one admin
|
||||
// and if room was locked on behalf control protection, unlock
|
||||
sess, ok := ws.state.GetLocked("control")
|
||||
if ok && ws.conf.ControlProtection && sess == CONTROL_PROTECTION_SESSION && len(ws.sessions.Admins()) > 0 {
|
||||
ws.state.Unlock("control")
|
||||
ws.sessions.SetControlLocked(false) // TODO: Handle locks in sessions as flags.
|
||||
ws.logger.Info().Msgf("control unlocked on behalf of control protection")
|
||||
|
||||
if err := ws.sessions.Broadcast(
|
||||
message.AdminLock{
|
||||
Event: event.ADMIN_UNLOCK,
|
||||
ID: e.Id,
|
||||
Resource: "control",
|
||||
}, nil); err != nil {
|
||||
ws.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_UNLOCK)
|
||||
}
|
||||
}
|
||||
|
||||
// remove outdated stats
|
||||
if e.Session.Admin() {
|
||||
ws.lastAdminLeftAt = nil
|
||||
@ -112,6 +157,25 @@ func (ws *WebSocketHandler) Start() {
|
||||
membersCount := len(ws.sessions.Members())
|
||||
adminCount := len(ws.sessions.Admins())
|
||||
|
||||
// if control protection is enabled and no admin
|
||||
// and room is not locked, lock
|
||||
ok := ws.state.IsLocked("control")
|
||||
if !ok && ws.conf.ControlProtection && adminCount == 0 {
|
||||
ws.state.Lock("control", CONTROL_PROTECTION_SESSION)
|
||||
ws.sessions.SetControlLocked(true) // TODO: Handle locks in sessions as flags.
|
||||
ws.logger.Info().Msgf("control locked and released on behalf of control protection")
|
||||
ws.handler.AdminRelease(e.Id, e.Session)
|
||||
|
||||
if err := ws.sessions.Broadcast(
|
||||
message.AdminLock{
|
||||
Event: event.ADMIN_LOCK,
|
||||
ID: e.Id,
|
||||
Resource: "control",
|
||||
}, nil); err != nil {
|
||||
ws.logger.Warn().Err(err).Msgf("broadcasting event %s has failed", event.ADMIN_LOCK)
|
||||
}
|
||||
}
|
||||
|
||||
// if this was the last admin
|
||||
if e.Session.Admin() && adminCount == 0 {
|
||||
now := time.Now()
|
||||
@ -152,6 +216,37 @@ func (ws *WebSocketHandler) Start() {
|
||||
ws.logger.Err(err).Msg("sync clipboard")
|
||||
}
|
||||
}()
|
||||
|
||||
// watch for file changes and send file list if file transfer is enabled
|
||||
if ws.conf.FileTransferEnabled {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
ws.logger.Err(err).Msg("unable to start file transfer dir watcher")
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case e, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
ws.logger.Info().Msg("file transfer dir watcher closed")
|
||||
return
|
||||
}
|
||||
if e.Has(fsnotify.Create) || e.Has(fsnotify.Remove) || e.Has(fsnotify.Rename) {
|
||||
ws.logger.Debug().Str("event", e.String()).Msg("file transfer dir watcher event")
|
||||
ws.handler.FileTransferRefresh(nil)
|
||||
}
|
||||
case err := <-watcher.Errors:
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WebSocketHandler) Shutdown() error {
|
||||
@ -252,15 +347,21 @@ func (ws *WebSocketHandler) Stats() types.Stats {
|
||||
Members: ws.sessions.Members(),
|
||||
|
||||
Banned: ws.state.AllBanned(),
|
||||
Locked: ws.state.AllLocked(),
|
||||
|
||||
ServerStartedAt: ws.serverStartedAt,
|
||||
LastAdminLeftAt: ws.lastAdminLeftAt,
|
||||
LastUserLeftAt: ws.lastUserLeftAt,
|
||||
|
||||
ImplicitControl: ws.webrtc.ImplicitControl(),
|
||||
ControlProtection: ws.conf.ControlProtection,
|
||||
ImplicitControl: ws.webrtc.ImplicitControl(),
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WebSocketHandler) IsLocked(resource string) bool {
|
||||
return ws.state.IsLocked(resource)
|
||||
}
|
||||
|
||||
func (ws *WebSocketHandler) IsAdmin(password string) (bool, error) {
|
||||
if password == ws.conf.AdminPassword {
|
||||
return true, nil
|
||||
@ -343,3 +444,28 @@ func (ws *WebSocketHandler) handle(connection *websocket.Conn, id string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// File transfer
|
||||
//
|
||||
|
||||
func (ws *WebSocketHandler) CanTransferFiles(password string) (bool, error) {
|
||||
if !ws.conf.FileTransferEnabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
isAdmin, err := ws.IsAdmin(password)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return isAdmin || !ws.state.IsLocked("file_transfer"), nil
|
||||
}
|
||||
|
||||
func (ws *WebSocketHandler) FileTransferPath(filename string) string {
|
||||
return ws.state.FileTransferPath(filename)
|
||||
}
|
||||
|
||||
func (ws *WebSocketHandler) FileTransferEnabled() bool {
|
||||
return ws.conf.FileTransferEnabled
|
||||
}
|
||||
|
Reference in New Issue
Block a user