mirror of
https://github.com/m1k1o/neko.git
synced 2024-07-24 14:40:50 +12:00
Merge branch 'master' of github.com:prophetofxenu/neko
This commit is contained in:
commit
472a3c3355
@ -22,7 +22,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.2.0",
|
"@fortawesome/fontawesome-free": "^6.2.0",
|
||||||
"animejs": "^3.2.0",
|
"animejs": "^3.2.0",
|
||||||
"axios": "^0.21.4",
|
"axios": "^0.24.0",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"emoji-datasource": "^6.0.1",
|
"emoji-datasource": "^6.0.1",
|
||||||
"eventemitter3": "^4.0.7",
|
"eventemitter3": "^4.0.7",
|
||||||
|
427
client/src/components/files.vue
Normal file
427
client/src/components/files.vue
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
<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>{{ 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">{{ $t('files.downloads') }}</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-arrows-rotate': download.status !== 'completed', 'fa-check': download.status === 'completed' }"></i>
|
||||||
|
<p>{{ 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>
|
||||||
|
<progress class="transfer-progress" :aria-label="download.name + ' progress'" :value="download.progress"
|
||||||
|
:max="download.size"></progress>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploads.length > 0">{{ $t('files.uploads' )}}</p>
|
||||||
|
<div v-for="upload in uploads" :key="upload.id" class="transfers-list-item">
|
||||||
|
<div class="transfer-info">
|
||||||
|
<i class="fas transfer-status" :class="{ 'fa-arrows-rotate': upload.status !== 'completed', 'fa-check': upload.status === 'completed' }"></i>
|
||||||
|
<p>{{ 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>
|
||||||
|
<progress 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.10);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon, .transfer-status {
|
||||||
|
width: 14px;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-list-item:last-child {
|
||||||
|
border-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
color: rgba($color: #fff, $alpha: 0.40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 } 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: any) {
|
||||||
|
if (this.downloads.map((t) => t.name).includes(item.name)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/file?pwd=${this.$accessor.password}&filename=${item.name}`
|
||||||
|
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',
|
||||||
|
axios: null,
|
||||||
|
abortController: null
|
||||||
|
}
|
||||||
|
transfer.abortController = new AbortController()
|
||||||
|
transfer.axios = this.$http.get(url, {
|
||||||
|
responseType: 'blob',
|
||||||
|
signal: transfer.abortController.signal,
|
||||||
|
onDownloadProgress: (x) => {
|
||||||
|
transfer.progress = x.loaded
|
||||||
|
|
||||||
|
if (x.lengthComputable && 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((err) => {
|
||||||
|
this.$log.error(err)
|
||||||
|
})
|
||||||
|
this.$accessor.files.addTransfer(transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
upload(dt: DataTransfer) {
|
||||||
|
this.uploadAreaDrag = false
|
||||||
|
|
||||||
|
for (const file of dt.files) {
|
||||||
|
const formdata = new FormData()
|
||||||
|
formdata.append("files", file, file.name)
|
||||||
|
|
||||||
|
const url = `/file?pwd=${this.$accessor.password}`
|
||||||
|
let transfer: FileTransfer = {
|
||||||
|
id: Math.round(Math.random() * 10000),
|
||||||
|
name: file.name,
|
||||||
|
direction: 'upload',
|
||||||
|
size: file.size,
|
||||||
|
progress: 0,
|
||||||
|
status: 'pending',
|
||||||
|
axios: null,
|
||||||
|
abortController: null
|
||||||
|
}
|
||||||
|
transfer.abortController = new AbortController()
|
||||||
|
this.$http.post(url, formdata, {
|
||||||
|
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((err) => {
|
||||||
|
this.$log.error(err)
|
||||||
|
})
|
||||||
|
this.$accessor.files.addTransfer(transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openFileBrowser() {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.setAttribute('multiple', 'true')
|
||||||
|
input.click()
|
||||||
|
|
||||||
|
input.onchange = (e) => {
|
||||||
|
if (e === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
const target = e.target as any
|
||||||
|
for (const f of target.files) {
|
||||||
|
dt.items.add(f)
|
||||||
|
}
|
||||||
|
this.upload(dt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTransfer(transfer: FileTransfer) {
|
||||||
|
if (transfer.status !== 'completed') {
|
||||||
|
transfer.abortController?.abort()
|
||||||
|
}
|
||||||
|
this.$accessor.files.removeTransfer(transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileIcon(file: any) {
|
||||||
|
let className = 'file-icon fas '
|
||||||
|
if (file.type === 'dir') {
|
||||||
|
className += 'fa-folder'
|
||||||
|
return className
|
||||||
|
}
|
||||||
|
const parts = file.name.split('.')
|
||||||
|
if (!parts) {
|
||||||
|
className += 'fa-file'
|
||||||
|
return className
|
||||||
|
}
|
||||||
|
const ext = parts[parts.length - 1]
|
||||||
|
switch (ext) {
|
||||||
|
case 'aac':
|
||||||
|
case 'flac':
|
||||||
|
case 'midi':
|
||||||
|
case 'mp3':
|
||||||
|
case 'ogg':
|
||||||
|
case 'wav':
|
||||||
|
className += 'fa-music'
|
||||||
|
break
|
||||||
|
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 < 1000) {
|
||||||
|
return `${size} b`
|
||||||
|
}
|
||||||
|
if (size < 1000 ** 2) {
|
||||||
|
return `${(size / 1000).toFixed(2)} kb`
|
||||||
|
}
|
||||||
|
if (size < 1000 ** 3) {
|
||||||
|
return `${(size / 1000 ** 2).toFixed(2)} mb`
|
||||||
|
}
|
||||||
|
if (size < 1000 ** 4) {
|
||||||
|
return `${(size / 1000 ** 3).toFixed(2)} gb`
|
||||||
|
}
|
||||||
|
return `${(size / 1000 ** 4).toFixed(3)} tb`
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
@ -44,6 +44,20 @@
|
|||||||
<span />
|
<span />
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-if="admin">
|
||||||
|
<span>{{ $t('setting.file_transfer') }}</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" v-model="file_transfer" />
|
||||||
|
<span />
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li v-if="admin && file_transfer">
|
||||||
|
<span>{{ $t('setting.unpriv_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,6 +6,10 @@
|
|||||||
<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 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')">
|
<li :class="{ active: tab === 'settings' }" @click.stop.prevent="change('settings')">
|
||||||
<i class="fas fa-sliders-h" />
|
<i class="fas fa-sliders-h" />
|
||||||
<span>{{ $t('side.settings') }}</span>
|
<span>{{ $t('side.settings') }}</span>
|
||||||
@ -14,6 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<neko-chat v-if="tab === 'chat'" />
|
<neko-chat v-if="tab === 'chat'" />
|
||||||
|
<neko-files v-if="tab === 'files'" />
|
||||||
<neko-settings v-if="tab === 'settings'" />
|
<neko-settings v-if="tab === 'settings'" />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -78,15 +83,31 @@
|
|||||||
|
|
||||||
import Settings from '~/components/settings.vue'
|
import Settings from '~/components/settings.vue'
|
||||||
import Chat from '~/components/chat.vue'
|
import Chat from '~/components/chat.vue'
|
||||||
|
import Files from '~/components/files.vue'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
name: 'neko',
|
name: 'neko',
|
||||||
components: {
|
components: {
|
||||||
'neko-settings': Settings,
|
'neko-settings': Settings,
|
||||||
'neko-chat': Chat,
|
'neko-chat': Chat,
|
||||||
|
'neko-files': Files
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
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
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ export const send_a_message = 'Sende eine Nachricht'
|
|||||||
|
|
||||||
export const side = {
|
export const side = {
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
|
files: 'Dateien',
|
||||||
settings: 'Einstellungen',
|
settings: 'Einstellungen',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +78,8 @@ export const setting = {
|
|||||||
ignore_emotes: 'Emotes ignorieren',
|
ignore_emotes: 'Emotes ignorieren',
|
||||||
chat_sound: 'Chat-Sound abspielen',
|
chat_sound: 'Chat-Sound abspielen',
|
||||||
keyboard_layout: 'Tastaturbelegung',
|
keyboard_layout: 'Tastaturbelegung',
|
||||||
|
file_transfer: 'Dateiübertragung',
|
||||||
|
unpriv_file_transfer: 'Übertragung von Benutzerdateien',
|
||||||
broadcast_title: 'Live-Übertragung',
|
broadcast_title: 'Live-Übertragung',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,3 +111,9 @@ export const notifications = {
|
|||||||
muted: '{name} stummgeschaltet',
|
muted: '{name} stummgeschaltet',
|
||||||
unmuted: '{name} stummschaltung aufgehoben',
|
unmuted: '{name} stummschaltung aufgehoben',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: 'Herunterladen',
|
||||||
|
uploads: 'Hochladen',
|
||||||
|
upload_here: 'Klicken oder ziehen Sie Dateien zum Hochladen hierher'
|
||||||
|
}
|
||||||
|
@ -7,6 +7,7 @@ export const send_a_message = 'Send a message'
|
|||||||
|
|
||||||
export const side = {
|
export const side = {
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
|
files: 'Files',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +80,8 @@ export const setting = {
|
|||||||
ignore_emotes: 'Ignore Emotes',
|
ignore_emotes: 'Ignore Emotes',
|
||||||
chat_sound: 'Play Chat Sound',
|
chat_sound: 'Play Chat Sound',
|
||||||
keyboard_layout: 'Keyboard Layout',
|
keyboard_layout: 'Keyboard Layout',
|
||||||
|
file_transfer: 'File Transfer',
|
||||||
|
unpriv_file_transfer: 'Non-admin File Transfer',
|
||||||
broadcast_title: 'Live Broadcast',
|
broadcast_title: 'Live Broadcast',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,3 +113,9 @@ export const notifications = {
|
|||||||
muted: 'muted {name}',
|
muted: 'muted {name}',
|
||||||
unmuted: 'unmuted {name}',
|
unmuted: 'unmuted {name}',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: 'Downloads',
|
||||||
|
uploads: 'Uploads',
|
||||||
|
upload_here: 'Click or drag files here to upload'
|
||||||
|
}
|
||||||
|
@ -8,6 +8,7 @@ export const send_a_message = 'Enviar un mensaje'
|
|||||||
|
|
||||||
export const side = {
|
export const side = {
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
|
files: 'Archivos',
|
||||||
settings: 'Configuración',
|
settings: 'Configuración',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +84,8 @@ export const setting = {
|
|||||||
ignore_emotes: 'Ignorar Emotes',
|
ignore_emotes: 'Ignorar Emotes',
|
||||||
chat_sound: 'Reproducir Sonidos Chat',
|
chat_sound: 'Reproducir Sonidos Chat',
|
||||||
keyboard_layout: 'Keyboard Layout',
|
keyboard_layout: 'Keyboard Layout',
|
||||||
|
file_transfer: 'Transferencia de archivos',
|
||||||
|
unpriv_file_transfer: 'Transferencia de archivos de usuario',
|
||||||
// TODO
|
// TODO
|
||||||
//broadcast_title: 'Live Broadcast',
|
//broadcast_title: 'Live Broadcast',
|
||||||
}
|
}
|
||||||
@ -117,3 +120,9 @@ export const notifications = {
|
|||||||
muted: '{name} silenciado',
|
muted: '{name} silenciado',
|
||||||
unmuted: '{name} no silenciado',
|
unmuted: '{name} no silenciado',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: 'Descargas',
|
||||||
|
uploads: 'Cargar',
|
||||||
|
upload_here: 'Haga clic o arrastre los archivos aquí para cargarlos'
|
||||||
|
}
|
||||||
|
@ -7,6 +7,7 @@ export const send_a_message = 'Lähetä viesti'
|
|||||||
|
|
||||||
export const side = {
|
export const side = {
|
||||||
chat: 'Chatti',
|
chat: 'Chatti',
|
||||||
|
files: 'Tiedostot',
|
||||||
settings: 'Asetukset',
|
settings: 'Asetukset',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +80,8 @@ export const setting = {
|
|||||||
ignore_emotes: 'Estä emojit',
|
ignore_emotes: 'Estä emojit',
|
||||||
chat_sound: 'Soita viesti ääni',
|
chat_sound: 'Soita viesti ääni',
|
||||||
keyboard_layout: 'Näppäimistöasettelu',
|
keyboard_layout: 'Näppäimistöasettelu',
|
||||||
|
file_transfer: 'Tiedoston siirto',
|
||||||
|
unpriv_file_transfer: 'Käyttäjän tiedostojen siirto',
|
||||||
broadcast_title: 'Suora Lähetys',
|
broadcast_title: 'Suora Lähetys',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,3 +113,9 @@ export const notifications = {
|
|||||||
muted: 'mykistetty {name}',
|
muted: 'mykistetty {name}',
|
||||||
unmuted: 'poistettu mykistys {name}',
|
unmuted: 'poistettu mykistys {name}',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: 'Lataukset',
|
||||||
|
uploads: 'Lataa',
|
||||||
|
upload_here: 'Klikkaa tai vedä tiedostoja tähän ladataksesi'
|
||||||
|
}
|
||||||
|
@ -8,6 +8,7 @@ export const send_a_message = 'Envoyer un message'
|
|||||||
|
|
||||||
export const side = {
|
export const side = {
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
|
files: 'Fichiers',
|
||||||
settings: 'Paramètres',
|
settings: 'Paramètres',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +84,8 @@ export const setting = {
|
|||||||
ignore_emotes: 'Ignorer les Emotes',
|
ignore_emotes: 'Ignorer les Emotes',
|
||||||
chat_sound: 'Jouer le son du tchat',
|
chat_sound: 'Jouer le son du tchat',
|
||||||
keyboard_layout: 'Langue du clavier',
|
keyboard_layout: 'Langue du clavier',
|
||||||
|
file_transfer: 'Transfert de fichiers',
|
||||||
|
unpriv_file_transfer: 'Transfert de fichiers d\'utilisateurs',
|
||||||
// TODO
|
// TODO
|
||||||
//broadcast_title: 'Live Broadcast',
|
//broadcast_title: 'Live Broadcast',
|
||||||
}
|
}
|
||||||
@ -117,3 +120,9 @@ export const notifications = {
|
|||||||
muted: 'a mute {name}',
|
muted: 'a mute {name}',
|
||||||
unmuted: 'a démute {name}',
|
unmuted: 'a démute {name}',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: 'Téléchargements',
|
||||||
|
uploads: 'Télécharger',
|
||||||
|
upload_here: 'Cliquez ou faites glisser les fichiers ici pour les télécharger'
|
||||||
|
}
|
||||||
|
@ -7,6 +7,7 @@ export const send_a_message = '메세지 보내기'
|
|||||||
|
|
||||||
export const side = {
|
export const side = {
|
||||||
chat: '채팅',
|
chat: '채팅',
|
||||||
|
files: '파일',
|
||||||
settings: '설정',
|
settings: '설정',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +78,8 @@ export const setting = {
|
|||||||
ignore_emotes: '이모지 무시',
|
ignore_emotes: '이모지 무시',
|
||||||
chat_sound: '채팅 소리 재생',
|
chat_sound: '채팅 소리 재생',
|
||||||
keyboard_layout: '키보드 레이아웃',
|
keyboard_layout: '키보드 레이아웃',
|
||||||
|
file_transfer: '파일 전송',
|
||||||
|
unpriv_file_transfer: '사용자 파일 전송',
|
||||||
broadcast_title: '실시간 방송',
|
broadcast_title: '실시간 방송',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,3 +111,9 @@ export const notifications = {
|
|||||||
muted: '{name} 님이 뮤트됐습니다',
|
muted: '{name} 님이 뮤트됐습니다',
|
||||||
unmuted: '{name} 님의 뮤트가 해제됐습니다',
|
unmuted: '{name} 님의 뮤트가 해제됐습니다',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: '다운로드',
|
||||||
|
uploads: '업로드',
|
||||||
|
upload_here: '업로드할 파일을 여기로 클릭하거나 드래그하세요.'
|
||||||
|
}
|
||||||
|
@ -8,6 +8,7 @@ export const send_a_message = 'Send en melding'
|
|||||||
|
|
||||||
export const side = {
|
export const side = {
|
||||||
chat: 'Sludring',
|
chat: 'Sludring',
|
||||||
|
files: 'Filer',
|
||||||
settings: 'Innstillinger',
|
settings: 'Innstillinger',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +84,8 @@ export const setting = {
|
|||||||
ignore_emotes: 'Ignorer smilefjes',
|
ignore_emotes: 'Ignorer smilefjes',
|
||||||
chat_sound: 'Sludringslyd',
|
chat_sound: 'Sludringslyd',
|
||||||
keyboard_layout: 'Tastaturoppsett',
|
keyboard_layout: 'Tastaturoppsett',
|
||||||
|
file_transfer: 'Filoverførsel',
|
||||||
|
unpriv_file_transfer: 'Overførsel af brugerfiler',
|
||||||
// TODO
|
// TODO
|
||||||
//broadcast_title: 'Live Broadcast',
|
//broadcast_title: 'Live Broadcast',
|
||||||
}
|
}
|
||||||
@ -117,3 +120,9 @@ export const notifications = {
|
|||||||
muted: 'forstummet {name}',
|
muted: 'forstummet {name}',
|
||||||
unmuted: 'opphevet forstummingen av {name}',
|
unmuted: 'opphevet forstummingen av {name}',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: 'Overførsler',
|
||||||
|
uploads: 'Overfør',
|
||||||
|
upload_here: 'Klik eller træk filer her for at uploade'
|
||||||
|
}
|
||||||
|
@ -7,6 +7,7 @@ export const send_a_message = 'Отправить сообщение'
|
|||||||
|
|
||||||
export const side = {
|
export const side = {
|
||||||
chat: 'Чат',
|
chat: 'Чат',
|
||||||
|
files: 'Файлы',
|
||||||
settings: 'Настройки',
|
settings: 'Настройки',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +80,8 @@ export const setting = {
|
|||||||
ignore_emotes: 'Игнорировать эмоции',
|
ignore_emotes: 'Игнорировать эмоции',
|
||||||
chat_sound: 'Проигрывать звук чата',
|
chat_sound: 'Проигрывать звук чата',
|
||||||
keyboard_layout: 'Раскладка клавиатуры',
|
keyboard_layout: 'Раскладка клавиатуры',
|
||||||
|
file_transfer: 'Передача файлов',
|
||||||
|
unpriv_file_transfer: 'Передача файлов пользователей',
|
||||||
broadcast_title: 'Прямой эфир',
|
broadcast_title: 'Прямой эфир',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,3 +113,9 @@ export const notifications = {
|
|||||||
muted: 'заглушен {name}',
|
muted: 'заглушен {name}',
|
||||||
unmuted: 'не заглушен {name}',
|
unmuted: 'не заглушен {name}',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: 'Загрузки',
|
||||||
|
uploads: 'Загрузить',
|
||||||
|
upload_here: 'Нажмите или перетащите сюда файлы для загрузки'
|
||||||
|
}
|
||||||
|
@ -8,6 +8,7 @@ export const send_a_message = 'Odoslať správu'
|
|||||||
|
|
||||||
export const side = {
|
export const side = {
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
|
files: 'Súbory',
|
||||||
settings: 'Nastavenia',
|
settings: 'Nastavenia',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,6 +83,8 @@ export const setting = {
|
|||||||
ignore_emotes: 'Ignorovať smajlíky',
|
ignore_emotes: 'Ignorovať smajlíky',
|
||||||
chat_sound: 'Prehrávať zvuky chatu',
|
chat_sound: 'Prehrávať zvuky chatu',
|
||||||
keyboard_layout: 'Rozloženie klávesnice',
|
keyboard_layout: 'Rozloženie klávesnice',
|
||||||
|
file_transfer: 'Prenos súborov',
|
||||||
|
unpriv_file_transfer: 'Prenos súborov používateľa',
|
||||||
broadcast_title: 'Živé vysielanie',
|
broadcast_title: 'Živé vysielanie',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,3 +116,9 @@ export const notifications = {
|
|||||||
muted: 'zakázal chat používateľovi {name}',
|
muted: 'zakázal chat používateľovi {name}',
|
||||||
unmuted: 'povolil chat používateľovi {name}',
|
unmuted: 'povolil chat používateľovi {name}',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: 'Stiahnutia',
|
||||||
|
uploads: 'Nahrávanie',
|
||||||
|
upload_here: 'Kliknutím alebo pretiahnutím súborov sem ich môžete nahrať'
|
||||||
|
}
|
||||||
|
@ -8,6 +8,7 @@ export const send_a_message = 'Skicka ett meddelande'
|
|||||||
|
|
||||||
export const side = {
|
export const side = {
|
||||||
chat: 'Chatt',
|
chat: 'Chatt',
|
||||||
|
files: 'Filer',
|
||||||
settings: 'Inställningar',
|
settings: 'Inställningar',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +84,8 @@ export const setting = {
|
|||||||
ignore_emotes: 'Ignorera Emotes',
|
ignore_emotes: 'Ignorera Emotes',
|
||||||
chat_sound: 'Spela Chatt Ljud',
|
chat_sound: 'Spela Chatt Ljud',
|
||||||
keyboard_layout: 'Tangentbordslayout',
|
keyboard_layout: 'Tangentbordslayout',
|
||||||
|
file_transfer: 'Överföring av filer',
|
||||||
|
unpriv_file_transfer: 'Överföring av användarfiler',
|
||||||
// TODO
|
// TODO
|
||||||
//broadcast_title: 'Live Broadcast',
|
//broadcast_title: 'Live Broadcast',
|
||||||
}
|
}
|
||||||
@ -117,3 +120,9 @@ export const notifications = {
|
|||||||
muted: 'tystade {name}',
|
muted: 'tystade {name}',
|
||||||
unmuted: 'tog bort tystningen på {name}',
|
unmuted: 'tog bort tystningen på {name}',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: 'Nedladdningar',
|
||||||
|
uploads: 'Ladda upp',
|
||||||
|
upload_here: 'Klicka eller dra filer hit för att ladda upp dem'
|
||||||
|
}
|
||||||
|
@ -7,6 +7,7 @@ export const send_a_message = '发送消息'
|
|||||||
|
|
||||||
export const side = {
|
export const side = {
|
||||||
chat: '聊天',
|
chat: '聊天',
|
||||||
|
files: '文件',
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +80,8 @@ export const setting = {
|
|||||||
ignore_emotes: '忽略表情符号',
|
ignore_emotes: '忽略表情符号',
|
||||||
chat_sound: '播放聊天声音',
|
chat_sound: '播放聊天声音',
|
||||||
keyboard_layout: '键盘布局',
|
keyboard_layout: '键盘布局',
|
||||||
|
file_transfer: '文件传输',
|
||||||
|
unpriv_file_transfer: '用户文件传输',
|
||||||
broadcast_title: '现场流媒体',
|
broadcast_title: '现场流媒体',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,3 +113,9 @@ export const notifications = {
|
|||||||
muted: '鸟粪 {name}',
|
muted: '鸟粪 {name}',
|
||||||
unmuted: '取消静音 {name}',
|
unmuted: '取消静音 {name}',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: '下载',
|
||||||
|
uploads: '上传',
|
||||||
|
upload_here: '点击或拖动文件到这里来上传'
|
||||||
|
}
|
||||||
|
@ -38,6 +38,11 @@ export const EVENT = {
|
|||||||
MESSAGE: 'chat/message',
|
MESSAGE: 'chat/message',
|
||||||
EMOTE: 'chat/emote',
|
EMOTE: 'chat/emote',
|
||||||
},
|
},
|
||||||
|
FILETRANSFER: {
|
||||||
|
STATUS: 'filetransfer/status',
|
||||||
|
LIST: 'filetransfer/list',
|
||||||
|
REFRESH: 'filetransfer/refresh'
|
||||||
|
},
|
||||||
SCREEN: {
|
SCREEN: {
|
||||||
CONFIGURATIONS: 'screen/configurations',
|
CONFIGURATIONS: 'screen/configurations',
|
||||||
RESOLUTION: 'screen/resolution',
|
RESOLUTION: 'screen/resolution',
|
||||||
@ -69,6 +74,7 @@ export type WebSocketEvents =
|
|||||||
| MemberEvents
|
| MemberEvents
|
||||||
| SignalEvents
|
| SignalEvents
|
||||||
| ChatEvents
|
| ChatEvents
|
||||||
|
| FileTransferEvents
|
||||||
| ScreenEvents
|
| ScreenEvents
|
||||||
| BroadcastEvents
|
| BroadcastEvents
|
||||||
| AdminEvents
|
| AdminEvents
|
||||||
@ -91,6 +97,12 @@ export type SignalEvents =
|
|||||||
| typeof EVENT.SIGNAL.CANDIDATE
|
| typeof EVENT.SIGNAL.CANDIDATE
|
||||||
|
|
||||||
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 =
|
||||||
|
| typeof EVENT.FILETRANSFER.STATUS
|
||||||
|
| 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 ScreenEvents = typeof EVENT.SCREEN.CONFIGURATIONS | typeof EVENT.SCREEN.RESOLUTION | typeof EVENT.SCREEN.SET
|
||||||
|
|
||||||
export type BroadcastEvents =
|
export type BroadcastEvents =
|
||||||
|
@ -24,6 +24,8 @@ import {
|
|||||||
AdminLockMessage,
|
AdminLockMessage,
|
||||||
SystemInitPayload,
|
SystemInitPayload,
|
||||||
AdminLockResource,
|
AdminLockResource,
|
||||||
|
FileTransferListPayload,
|
||||||
|
FileTransferStatusPayload,
|
||||||
} from './messages'
|
} from './messages'
|
||||||
|
|
||||||
interface NekoEvents extends BaseEvents {}
|
interface NekoEvents extends BaseEvents {}
|
||||||
@ -70,6 +72,14 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public refreshFiles() {
|
||||||
|
if (!this.connected) {
|
||||||
|
this.emit('warn', 'attempting to refresh files while disconnected')
|
||||||
|
}
|
||||||
|
this.emit('debug', `sending event '${EVENT.FILETRANSFER.REFRESH}'`)
|
||||||
|
this._ws!.send(JSON.stringify({ event: EVENT.FILETRANSFER.REFRESH }))
|
||||||
|
}
|
||||||
|
|
||||||
/////////////////////////////
|
/////////////////////////////
|
||||||
// Internal Events
|
// Internal Events
|
||||||
/////////////////////////////
|
/////////////////////////////
|
||||||
@ -351,6 +361,18 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
|||||||
this.$accessor.chat.newEmote({ type: emote })
|
this.$accessor.chat.newEmote({ type: emote })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/////////////////////////////
|
||||||
|
// Filetransfer Events
|
||||||
|
/////////////////////////////
|
||||||
|
protected [EVENT.FILETRANSFER.STATUS]({ admin, unpriv }: FileTransferStatusPayload) {
|
||||||
|
this.$accessor.settings.setLocalFileTransferStatus({ admin, unpriv })
|
||||||
|
}
|
||||||
|
|
||||||
|
protected [EVENT.FILETRANSFER.LIST]({ cwd, files }: FileTransferListPayload) {
|
||||||
|
this.$accessor.files.setCwd(cwd)
|
||||||
|
this.$accessor.files.setFileList(files)
|
||||||
|
}
|
||||||
|
|
||||||
/////////////////////////////
|
/////////////////////////////
|
||||||
// Screen Events
|
// Screen Events
|
||||||
/////////////////////////////
|
/////////////////////////////
|
||||||
|
@ -8,8 +8,14 @@ import {
|
|||||||
ChatEvents,
|
ChatEvents,
|
||||||
ScreenEvents,
|
ScreenEvents,
|
||||||
AdminEvents,
|
AdminEvents,
|
||||||
|
FileTransferEvents,
|
||||||
} from './events'
|
} from './events'
|
||||||
import { Member, ScreenConfigurations, ScreenResolution } from './types'
|
import {
|
||||||
|
FileListItem,
|
||||||
|
Member,
|
||||||
|
ScreenConfigurations,
|
||||||
|
ScreenResolution
|
||||||
|
} from './types'
|
||||||
|
|
||||||
export type WebSocketMessages =
|
export type WebSocketMessages =
|
||||||
| WebSocketMessage
|
| WebSocketMessage
|
||||||
@ -38,6 +44,7 @@ export type WebSocketPayloads =
|
|||||||
| ChatPayload
|
| ChatPayload
|
||||||
| ChatSendPayload
|
| ChatSendPayload
|
||||||
| EmojiSendPayload
|
| EmojiSendPayload
|
||||||
|
| FileTransferStatusPayload
|
||||||
| ScreenResolutionPayload
|
| ScreenResolutionPayload
|
||||||
| ScreenConfigurationsPayload
|
| ScreenConfigurationsPayload
|
||||||
| AdminPayload
|
| AdminPayload
|
||||||
@ -192,6 +199,24 @@ export interface EmojiSendPayload {
|
|||||||
emote: string
|
emote: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// file transfer enabled
|
||||||
|
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
|
||||||
|
}
|
||||||
|
export interface FileTransferListPayload {
|
||||||
|
cwd: string,
|
||||||
|
files: FileListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
SCREEN PAYLOADS
|
SCREEN PAYLOADS
|
||||||
*/
|
*/
|
||||||
|
@ -22,3 +22,20 @@ export interface ScreenResolution {
|
|||||||
height: number
|
height: number
|
||||||
rate: 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',
|
||||||
|
axios: Promise<void> | null,
|
||||||
|
abortController: AbortController | null
|
||||||
|
}
|
||||||
|
71
client/src/store/files.ts
Normal file
71
client/src/store/files.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { actionTree, getterTree, mutationTree } from 'typed-vuex'
|
||||||
|
import { FileListItem, FileTransfer } from '~/neko/types'
|
||||||
|
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(store) {
|
||||||
|
for (const t of accessor.files.transfers) {
|
||||||
|
if (t.status !== 'completed') {
|
||||||
|
t.abortController?.abort()
|
||||||
|
}
|
||||||
|
accessor.files.removeTransfer(t)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh(store) {
|
||||||
|
if (!accessor.connected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$client.refreshFiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
@ -7,6 +7,7 @@ import { get, set } from '~/utils/localstorage'
|
|||||||
|
|
||||||
import * as video from './video'
|
import * as video from './video'
|
||||||
import * as chat from './chat'
|
import * as chat from './chat'
|
||||||
|
import * as files from './files'
|
||||||
import * as remote from './remote'
|
import * as remote from './remote'
|
||||||
import * as user from './user'
|
import * as user from './user'
|
||||||
import * as settings from './settings'
|
import * as settings from './settings'
|
||||||
@ -97,7 +98,7 @@ export const storePattern = {
|
|||||||
state,
|
state,
|
||||||
mutations,
|
mutations,
|
||||||
actions,
|
actions,
|
||||||
modules: { video, chat, user, remote, settings, client, emoji },
|
modules: { video, chat, files, user, remote, settings, client, emoji },
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.use(Vuex)
|
Vue.use(Vuex)
|
||||||
|
@ -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 })
|
||||||
},
|
},
|
||||||
|
@ -126,6 +126,19 @@ nat1to1: <ip>
|
|||||||
- Path prefix for HTTP requests.
|
- Path prefix for HTTP requests.
|
||||||
- e.g. `/neko/`
|
- e.g. `/neko/`
|
||||||
|
|
||||||
|
### File Transfer
|
||||||
|
|
||||||
|
#### `NEKO_FILE_TRANSFER`:
|
||||||
|
- Enable file transfer for admins at start
|
||||||
|
- e.g. `1`
|
||||||
|
#### `NEKO_UNPRIV_FILE_TRANSFER`:
|
||||||
|
- Enable file transfer for all users at start. Ignored if NEKO_FILE_TRANSFER not enabled.
|
||||||
|
- e.g. `1`
|
||||||
|
#### `NEKO_FILE_TRANSFER_PATH`:
|
||||||
|
- Path where files will be transferred between the host and users. By default this is
|
||||||
|
/home/neko/Downloads. If the path doesn't exist, it will be created.
|
||||||
|
- e.g. `/home/neko/Desktop`
|
||||||
|
|
||||||
### Expert settings
|
### Expert settings
|
||||||
|
|
||||||
#### `NEKO_DISPLAY`:
|
#### `NEKO_DISPLAY`:
|
||||||
@ -155,6 +168,8 @@ Flags:
|
|||||||
--device string audio device to capture (default "auto_null.monitor")
|
--device string audio device to capture (default "auto_null.monitor")
|
||||||
--display string XDisplay to capture (default ":99.0")
|
--display string XDisplay to capture (default ":99.0")
|
||||||
--epr string limits the pool of ephemeral ports that ICE UDP connections can allocate from (default "59000-59100")
|
--epr string limits the pool of ephemeral ports that ICE UDP connections can allocate from (default "59000-59100")
|
||||||
|
--file_transfer allow file transfer for admins
|
||||||
|
--file_transfer_path string path to use for file transfer (default "/home/neko/Downloads")
|
||||||
--g722 DEPRECATED: use audio_codec
|
--g722 DEPRECATED: use audio_codec
|
||||||
--h264 DEPRECATED: use video_codec
|
--h264 DEPRECATED: use video_codec
|
||||||
-h, --help help for serve
|
-h, --help help for serve
|
||||||
@ -179,6 +194,7 @@ Flags:
|
|||||||
--static string path to neko client files to serve (default "./www")
|
--static string path to neko client files to serve (default "./www")
|
||||||
--tcpmux int single TCP mux port for all peers
|
--tcpmux int single TCP mux port for all peers
|
||||||
--udpmux int single UDP mux port for all peers
|
--udpmux int single UDP mux port for all peers
|
||||||
|
--unpriv_file_transfer allow file transfer for non admins
|
||||||
--video string video codec parameters to use for streaming
|
--video string video codec parameters to use for streaming
|
||||||
--video_bitrate int video bitrate in kbit/s (default 3072)
|
--video_bitrate int video bitrate in kbit/s (default 3072)
|
||||||
--video_codec string video codec to be used (default "vp8")
|
--video_codec string video codec to be used (default "vp8")
|
||||||
|
@ -12,6 +12,10 @@ type WebSocket struct {
|
|||||||
Locks []string
|
Locks []string
|
||||||
|
|
||||||
ControlProtection bool
|
ControlProtection bool
|
||||||
|
|
||||||
|
FileTransfer bool
|
||||||
|
UnprivFileTransfer bool
|
||||||
|
FileTransferPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (WebSocket) Init(cmd *cobra.Command) error {
|
func (WebSocket) Init(cmd *cobra.Command) error {
|
||||||
@ -40,6 +44,21 @@ func (WebSocket) Init(cmd *cobra.Command) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().Bool("file_transfer", false, "allow file transfer for admins")
|
||||||
|
if err := viper.BindPFlag("file_transfer", cmd.PersistentFlags().Lookup("file_transfer")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().Bool("unpriv_file_transfer", false, "allow file transfer for non admins")
|
||||||
|
if err := viper.BindPFlag("unpriv_file_transfer", cmd.PersistentFlags().Lookup("unpriv_file_transfer")); 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,4 +69,8 @@ func (s *WebSocket) Set() {
|
|||||||
s.Locks = viper.GetStringSlice("locks")
|
s.Locks = viper.GetStringSlice("locks")
|
||||||
|
|
||||||
s.ControlProtection = viper.GetBool("control_protection")
|
s.ControlProtection = viper.GetBool("control_protection")
|
||||||
|
|
||||||
|
s.FileTransfer = viper.GetBool("file_transfer")
|
||||||
|
s.UnprivFileTransfer = viper.GetBool("unpriv_file_transfer")
|
||||||
|
s.FileTransferPath = viper.GetString("file_transfer_path")
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,12 @@ package http
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
@ -17,6 +20,8 @@ import (
|
|||||||
"m1k1o/neko/internal/types"
|
"m1k1o/neko/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const FILE_UPLOAD_BUF_SIZE = 65000
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
logger zerolog.Logger
|
logger zerolog.Logger
|
||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
@ -31,6 +36,7 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop t
|
|||||||
router.Use(middleware.RequestID) // Create a request ID for each request
|
router.Use(middleware.RequestID) // Create a request ID for each request
|
||||||
router.Use(middleware.RequestLogger(&logformatter{logger}))
|
router.Use(middleware.RequestLogger(&logformatter{logger}))
|
||||||
router.Use(middleware.Recoverer) // Recover from panics without crashing server
|
router.Use(middleware.Recoverer) // Recover from panics without crashing server
|
||||||
|
router.Use(middleware.Compress(5, "application/octet-stream"))
|
||||||
|
|
||||||
if conf.PathPrefix != "/" {
|
if conf.PathPrefix != "/" {
|
||||||
router.Use(func(h http.Handler) http.Handler {
|
router.Use(func(h http.Handler) http.Handler {
|
||||||
@ -99,6 +105,71 @@ func New(conf *config.Server, webSocketHandler types.WebSocketHandler, desktop t
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
path := webSocketHandler.MakeFilePath(filename)
|
||||||
|
f, err := os.Open(path)
|
||||||
|
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=\"%s\"", 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
|
||||||
|
}
|
||||||
|
|
||||||
|
r.ParseMultipartForm(32 << 20)
|
||||||
|
for _, formheader := range r.MultipartForm.File["files"] {
|
||||||
|
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(webSocketHandler.MakeFilePath(formheader.Filename), 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte("true"))
|
_, _ = w.Write([]byte("true"))
|
||||||
})
|
})
|
||||||
|
@ -34,6 +34,12 @@ const (
|
|||||||
CHAT_EMOTE = "chat/emote"
|
CHAT_EMOTE = "chat/emote"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FILETRANSFER_STATUS = "filetransfer/status"
|
||||||
|
FILETRANSFER_LIST = "filetransfer/list"
|
||||||
|
FILETRANSFER_REFRESH = "filetransfer/refresh"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SCREEN_CONFIGURATIONS = "screen/configurations"
|
SCREEN_CONFIGURATIONS = "screen/configurations"
|
||||||
SCREEN_RESOLUTION = "screen/resolution"
|
SCREEN_RESOLUTION = "screen/resolution"
|
||||||
|
@ -106,6 +106,22 @@ type EmoteSend struct {
|
|||||||
Emote string `json:"emote"`
|
Emote string `json:"emote"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileTransferTarget struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileTransferStatus struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
Admin bool `json:"admin"`
|
||||||
|
Unpriv bool `json:"unpriv"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileList struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
Cwd string `json:"cwd"`
|
||||||
|
Files []types.FileListItem `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
type Admin struct {
|
type Admin struct {
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
@ -34,4 +34,12 @@ type WebSocketHandler interface {
|
|||||||
Stats() Stats
|
Stats() Stats
|
||||||
IsLocked(resource string) bool
|
IsLocked(resource string) bool
|
||||||
IsAdmin(password string) (bool, error)
|
IsAdmin(password string) (bool, error)
|
||||||
|
CanTransferFiles(password string) (bool, error)
|
||||||
|
MakeFilePath(filename string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
56
server/internal/websocket/handler/files.go
Normal file
56
server/internal/websocket/handler/files.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"m1k1o/neko/internal/types"
|
||||||
|
"m1k1o/neko/internal/types/event"
|
||||||
|
"m1k1o/neko/internal/types/message"
|
||||||
|
"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 {
|
||||||
|
if !(h.state.FileTransferEnabled() && session.Admin() || h.state.UnprivFileTransferEnabled()) {
|
||||||
|
return errors.New(session.Member().Name + " tried to refresh file list when they can't")
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := utils.ListFiles(h.state.FileTransferPath())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return session.Send(
|
||||||
|
message.FileList{
|
||||||
|
Event: event.FILETRANSFER_LIST,
|
||||||
|
Cwd: h.state.FileTransferPath(),
|
||||||
|
Files: *files,
|
||||||
|
})
|
||||||
|
}
|
@ -126,6 +126,16 @@ func (h *MessageHandler) Message(id string, raw []byte) error {
|
|||||||
return h.chatEmote(id, session, payload)
|
return h.chatEmote(id, session, payload)
|
||||||
}), "%s failed", header.Event)
|
}), "%s failed", header.Event)
|
||||||
|
|
||||||
|
// 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:
|
||||||
|
return errors.Wrapf(h.refresh(session), "%s failed", header.Event)
|
||||||
|
|
||||||
// Screen Events
|
// Screen Events
|
||||||
case event.SCREEN_RESOLUTION:
|
case event.SCREEN_RESOLUTION:
|
||||||
return errors.Wrapf(h.screenResolution(id, session), "%s failed", header.Event)
|
return errors.Wrapf(h.screenResolution(id, session), "%s failed", header.Event)
|
||||||
|
@ -3,12 +3,20 @@ package state
|
|||||||
type State struct {
|
type State struct {
|
||||||
banned map[string]string // IP -> session ID (that banned it)
|
banned map[string]string // IP -> session ID (that banned it)
|
||||||
locked map[string]string // resource name -> session ID (that locked it)
|
locked map[string]string // resource name -> session ID (that locked it)
|
||||||
|
|
||||||
|
fileTransferEnabled bool // admins can transfer files
|
||||||
|
fileTransferUnprivEnabled bool // all users can transfer files
|
||||||
|
fileTransferPath string // path where files are located
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *State {
|
func New(fileTransferEnabled bool, fileTransferUnprivEnabled bool, fileTransferPath string) *State {
|
||||||
return &State{
|
return &State{
|
||||||
banned: make(map[string]string),
|
banned: make(map[string]string),
|
||||||
locked: make(map[string]string),
|
locked: make(map[string]string),
|
||||||
|
|
||||||
|
fileTransferEnabled: fileTransferEnabled,
|
||||||
|
fileTransferUnprivEnabled: fileTransferUnprivEnabled,
|
||||||
|
fileTransferPath: fileTransferPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,3 +67,22 @@ func (s *State) GetLocked(resource string) (string, bool) {
|
|||||||
func (s *State) AllLocked() map[string]string {
|
func (s *State) AllLocked() map[string]string {
|
||||||
return s.locked
|
return s.locked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File Transfer
|
||||||
|
|
||||||
|
func (s *State) FileTransferEnabled() bool {
|
||||||
|
return s.fileTransferEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) UnprivFileTransferEnabled() bool {
|
||||||
|
return s.fileTransferUnprivEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) SetFileTransferState(admin bool, unpriv bool) {
|
||||||
|
s.fileTransferEnabled = admin
|
||||||
|
s.fileTransferUnprivEnabled = unpriv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) FileTransferPath() string {
|
||||||
|
return s.fileTransferPath
|
||||||
|
}
|
||||||
|
@ -3,10 +3,12 @@ package websocket
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@ -25,7 +27,7 @@ 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 {
|
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()
|
logger := log.With().Str("module", "websocket").Logger()
|
||||||
|
|
||||||
state := state.New()
|
state := state.New(conf.FileTransfer, conf.UnprivFileTransfer, conf.FileTransferPath)
|
||||||
|
|
||||||
// if control protection is enabled
|
// if control protection is enabled
|
||||||
if conf.ControlProtection {
|
if conf.ControlProtection {
|
||||||
@ -33,6 +35,14 @@ func New(sessions types.SessionManager, desktop types.DesktopManager, capture ty
|
|||||||
logger.Info().Msgf("control locked on behalf of control protection")
|
logger.Info().Msgf("control locked on behalf of control protection")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if conf.FileTransferPath[len(conf.FileTransferPath)-1] != '/' {
|
||||||
|
conf.FileTransferPath += "/"
|
||||||
|
}
|
||||||
|
err := os.Mkdir(conf.FileTransferPath, 0755)
|
||||||
|
if err != nil && !os.IsExist(err) {
|
||||||
|
logger.Panic().Err(err).Msg("unable to create file transfer directory")
|
||||||
|
}
|
||||||
|
|
||||||
// apply default locks
|
// apply default locks
|
||||||
for _, lock := range conf.Locks {
|
for _, lock := range conf.Locks {
|
||||||
state.Lock(lock, "") // empty session ID
|
state.Lock(lock, "") // empty session ID
|
||||||
@ -124,6 +134,33 @@ func (ws *WebSocketHandler) Start() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// send file list if necessary
|
||||||
|
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)
|
||||||
|
if err == nil {
|
||||||
|
if err := session.Send(
|
||||||
|
message.FileList{
|
||||||
|
Event: event.FILETRANSFER_LIST,
|
||||||
|
Cwd: ws.conf.FileTransferPath,
|
||||||
|
Files: *files,
|
||||||
|
}); err != nil {
|
||||||
|
ws.logger.Warn().Err(err).Msg("file list event has failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// remove outdated stats
|
// remove outdated stats
|
||||||
if session.Admin() {
|
if session.Admin() {
|
||||||
ws.lastAdminLeftAt = nil
|
ws.lastAdminLeftAt = nil
|
||||||
@ -187,6 +224,28 @@ func (ws *WebSocketHandler) Start() {
|
|||||||
|
|
||||||
ws.logger.Err(err).Msg("sync clipboard")
|
ws.logger.Err(err).Msg("sync clipboard")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// watch for file changes
|
||||||
|
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 <-watcher.Events:
|
||||||
|
ws.sendFileTransferUpdate()
|
||||||
|
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 {
|
func (ws *WebSocketHandler) Shutdown() error {
|
||||||
@ -314,6 +373,50 @@ func (ws *WebSocketHandler) IsAdmin(password string) (bool, error) {
|
|||||||
return false, fmt.Errorf("invalid password")
|
return false, fmt.Errorf("invalid password")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ws *WebSocketHandler) CanTransferFiles(password string) (bool, error) {
|
||||||
|
if !ws.state.FileTransferEnabled() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ws.state.UnprivFileTransferEnabled() {
|
||||||
|
return ws.IsAdmin(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
return password == ws.conf.Password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebSocketHandler) MakeFilePath(filename string) string {
|
||||||
|
return fmt.Sprintf("%s%s", ws.conf.FileTransferPath, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebSocketHandler) sendFileTransferUpdate() {
|
||||||
|
if !ws.state.FileTransferEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := utils.ListFiles(ws.conf.FileTransferPath)
|
||||||
|
if err != nil {
|
||||||
|
ws.logger.Err(err).Msg("unable to ls file transfer path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := message.FileList{
|
||||||
|
Event: event.FILETRANSFER_LIST,
|
||||||
|
Cwd: ws.conf.FileTransferPath,
|
||||||
|
Files: *files,
|
||||||
|
}
|
||||||
|
|
||||||
|
var broadcastErr error
|
||||||
|
if ws.state.UnprivFileTransferEnabled() {
|
||||||
|
broadcastErr = ws.sessions.Broadcast(message, nil)
|
||||||
|
} else {
|
||||||
|
broadcastErr = ws.sessions.AdminBroadcast(message, nil)
|
||||||
|
}
|
||||||
|
if broadcastErr != nil {
|
||||||
|
ws.logger.Err(broadcastErr).Msg("unable to broadcast file list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (ws *WebSocketHandler) authenticate(r *http.Request) (bool, error) {
|
func (ws *WebSocketHandler) authenticate(r *http.Request) (bool, error) {
|
||||||
passwords, ok := r.URL.Query()["password"]
|
passwords, ok := r.URL.Query()["password"]
|
||||||
if !ok || len(passwords[0]) < 1 {
|
if !ok || len(passwords[0]) < 1 {
|
||||||
|
Loading…
Reference in New Issue
Block a user