commit
db87229f16
@ -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",
|
||||||
|
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.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((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>
|
@ -31,6 +31,19 @@
|
|||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<span v-if="showBadge" class="badge">•</span>
|
<span v-if="showBadge" class="badge">•</span>
|
||||||
<i class="fas fa-bars toggle" @click="toggleMenu" />
|
<i class="fas fa-bars toggle" @click="toggleMenu" />
|
||||||
@ -169,26 +182,24 @@
|
|||||||
return !this.side && this.readTexts != this.texts
|
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
|
readTexts: number = 0
|
||||||
toggleMenu() {
|
toggleMenu() {
|
||||||
this.$accessor.client.toggleSide()
|
this.$accessor.client.toggleSide()
|
||||||
this.readTexts = this.texts
|
this.readTexts = this.texts
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleLock(resource: AdminLockResource) {
|
|
||||||
if (!this.admin) return
|
|
||||||
|
|
||||||
if (this.isLocked(resource)) {
|
|
||||||
this.$accessor.unlock(resource)
|
|
||||||
} else {
|
|
||||||
this.$accessor.lock(resource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isLocked(resource: AdminLockResource): boolean {
|
|
||||||
return resource in this.locked && this.locked[resource]
|
|
||||||
}
|
|
||||||
|
|
||||||
lockedTooltip(resource: AdminLockResource) {
|
lockedTooltip(resource: AdminLockResource) {
|
||||||
if (this.admin) {
|
if (this.admin) {
|
||||||
return this.$t(`locks.${resource}.` + (this.isLocked(resource) ? `unlock` : `lock`))
|
return this.$t(`locks.${resource}.` + (this.isLocked(resource) ? `unlock` : `lock`))
|
||||||
|
@ -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>
|
||||||
@ -74,23 +79,47 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<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 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 {
|
||||||
|
get filetransferAllowed() {
|
||||||
|
return (
|
||||||
|
this.$accessor.remote.fileTransfer && (this.$accessor.user.admin || !this.$accessor.isLocked('file_transfer'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
get tab() {
|
get tab() {
|
||||||
return this.$accessor.client.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) {
|
change(tab: string) {
|
||||||
this.$accessor.client.setTab(tab)
|
this.$accessor.client.setTab(tab)
|
||||||
}
|
}
|
||||||
|
@ -38,9 +38,9 @@ const exportMixin = {
|
|||||||
$accessor() {
|
$accessor() {
|
||||||
return neko
|
return neko
|
||||||
},
|
},
|
||||||
$client () {
|
$client() {
|
||||||
return window.$client
|
return window.$client
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,15 +52,8 @@ const plugini18n: PluginObject<undefined> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function extend (component: any) {
|
function extend(component: any) {
|
||||||
return component
|
return component.use(plugini18n).use(Logger).use(Axios).use(Swal).use(Anime).use(Client).extend(exportMixin)
|
||||||
.use(plugini18n)
|
|
||||||
.use(Logger)
|
|
||||||
.use(Axios)
|
|
||||||
.use(Swal)
|
|
||||||
.use(Anime)
|
|
||||||
.use(Client)
|
|
||||||
.extend(exportMixin)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NekoConnect = extend(Connect)
|
export const NekoConnect = extend(Connect)
|
||||||
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +69,14 @@ export const locks = {
|
|||||||
notif_locked: 'Raum gesperrt',
|
notif_locked: 'Raum gesperrt',
|
||||||
notif_unlocked: 'Raum entsperrt',
|
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 = {
|
export const setting = {
|
||||||
@ -108,3 +117,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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +71,14 @@ export const locks = {
|
|||||||
notif_locked: 'locked the room',
|
notif_locked: 'locked the room',
|
||||||
notif_unlocked: 'unlocked 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 = {
|
export const setting = {
|
||||||
@ -110,3 +119,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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,6 +75,15 @@ export const locks = {
|
|||||||
notif_locked: 'bloqueó la sala',
|
notif_locked: 'bloqueó la sala',
|
||||||
notif_unlocked: 'desbloqueó 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 = {
|
export const setting = {
|
||||||
@ -117,3 +127,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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +71,15 @@ export const locks = {
|
|||||||
notif_locked: 'lukittu huone',
|
notif_locked: 'lukittu huone',
|
||||||
notif_unlocked: 'vapautettu 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 = {
|
export const setting = {
|
||||||
@ -110,3 +120,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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,6 +75,15 @@ export const locks = {
|
|||||||
notif_locked: 'a vérouillé la salle',
|
notif_locked: 'a vérouillé la salle',
|
||||||
notif_unlocked: 'a dé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 = {
|
export const setting = {
|
||||||
@ -117,3 +127,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: '설정',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +69,15 @@ export const locks = {
|
|||||||
notif_locked: '방이 잠겼습니다',
|
notif_locked: '방이 잠겼습니다',
|
||||||
notif_unlocked: '방 잠금이 해제됐습니다',
|
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 = {
|
export const setting = {
|
||||||
@ -108,3 +118,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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,6 +75,15 @@ export const locks = {
|
|||||||
notif_locked: 'låste rommet',
|
notif_locked: 'låste rommet',
|
||||||
notif_unlocked: 'låste opp 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 = {
|
export const setting = {
|
||||||
@ -117,3 +127,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: 'Настройки',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +71,15 @@ export const locks = {
|
|||||||
notif_locked: 'комната закрыта',
|
notif_locked: 'комната закрыта',
|
||||||
notif_unlocked: 'комната открыта',
|
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 = {
|
export const setting = {
|
||||||
@ -110,3 +120,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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,6 +74,15 @@ export const locks = {
|
|||||||
notif_locked: 'miestnosť bola zamknutá',
|
notif_locked: 'miestnosť bola zamknutá',
|
||||||
notif_unlocked: 'miestnosť bola odomknutá',
|
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 = {
|
export const setting = {
|
||||||
@ -113,3 +123,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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,6 +75,15 @@ export const locks = {
|
|||||||
notif_locked: 'låste rummet',
|
notif_locked: 'låste rummet',
|
||||||
notif_unlocked: 'låste upp 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 = {
|
export const setting = {
|
||||||
@ -117,3 +127,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: '设置',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +71,15 @@ export const locks = {
|
|||||||
notif_locked: '锁上房间',
|
notif_locked: '锁上房间',
|
||||||
notif_unlocked: '解锁房间',
|
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 = {
|
export const setting = {
|
||||||
@ -110,3 +120,9 @@ export const notifications = {
|
|||||||
muted: '鸟粪 {name}',
|
muted: '鸟粪 {name}',
|
||||||
unmuted: '取消静音 {name}',
|
unmuted: '取消静音 {name}',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const files = {
|
||||||
|
downloads: '下载',
|
||||||
|
uploads: '上传',
|
||||||
|
upload_here: '点击或拖动文件到这里来上传',
|
||||||
|
}
|
||||||
|
@ -38,6 +38,10 @@ export const EVENT = {
|
|||||||
MESSAGE: 'chat/message',
|
MESSAGE: 'chat/message',
|
||||||
EMOTE: 'chat/emote',
|
EMOTE: 'chat/emote',
|
||||||
},
|
},
|
||||||
|
FILETRANSFER: {
|
||||||
|
LIST: 'filetransfer/list',
|
||||||
|
REFRESH: 'filetransfer/refresh',
|
||||||
|
},
|
||||||
SCREEN: {
|
SCREEN: {
|
||||||
CONFIGURATIONS: 'screen/configurations',
|
CONFIGURATIONS: 'screen/configurations',
|
||||||
RESOLUTION: 'screen/resolution',
|
RESOLUTION: 'screen/resolution',
|
||||||
@ -69,6 +73,7 @@ export type WebSocketEvents =
|
|||||||
| MemberEvents
|
| MemberEvents
|
||||||
| SignalEvents
|
| SignalEvents
|
||||||
| ChatEvents
|
| ChatEvents
|
||||||
|
| FileTransferEvents
|
||||||
| ScreenEvents
|
| ScreenEvents
|
||||||
| BroadcastEvents
|
| BroadcastEvents
|
||||||
| AdminEvents
|
| AdminEvents
|
||||||
@ -91,6 +96,9 @@ 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.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,7 @@ import {
|
|||||||
AdminLockMessage,
|
AdminLockMessage,
|
||||||
SystemInitPayload,
|
SystemInitPayload,
|
||||||
AdminLockResource,
|
AdminLockResource,
|
||||||
|
FileTransferListPayload,
|
||||||
} from './messages'
|
} from './messages'
|
||||||
|
|
||||||
interface NekoEvents extends BaseEvents {}
|
interface NekoEvents extends BaseEvents {}
|
||||||
@ -46,6 +47,8 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
|||||||
this.$vue = vue
|
this.$vue = vue
|
||||||
this.$accessor = vue.$accessor
|
this.$accessor = vue.$accessor
|
||||||
this.url = url
|
this.url = url
|
||||||
|
// convert ws url to http url
|
||||||
|
this.$vue.$http.defaults.baseURL = url.replace(/^ws/, 'http').replace(/\/ws$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanup() {
|
private cleanup() {
|
||||||
@ -133,8 +136,9 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
|||||||
/////////////////////////////
|
/////////////////////////////
|
||||||
// System Events
|
// System Events
|
||||||
/////////////////////////////
|
/////////////////////////////
|
||||||
protected [EVENT.SYSTEM.INIT]({ implicit_hosting, locks }: SystemInitPayload) {
|
protected [EVENT.SYSTEM.INIT]({ implicit_hosting, locks, file_transfer }: SystemInitPayload) {
|
||||||
this.$accessor.remote.setImplicitHosting(implicit_hosting)
|
this.$accessor.remote.setImplicitHosting(implicit_hosting)
|
||||||
|
this.$accessor.remote.setFileTransfer(file_transfer)
|
||||||
|
|
||||||
for (const resource in locks) {
|
for (const resource in locks) {
|
||||||
this[EVENT.ADMIN.LOCK]({
|
this[EVENT.ADMIN.LOCK]({
|
||||||
@ -351,6 +355,14 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
|||||||
this.$accessor.chat.newEmote({ type: emote })
|
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
|
// Screen Events
|
||||||
/////////////////////////////
|
/////////////////////////////
|
||||||
|
@ -8,8 +8,9 @@ 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
|
||||||
@ -59,6 +60,7 @@ export interface SystemInit extends WebSocketMessage, SystemInitPayload {
|
|||||||
export interface SystemInitPayload {
|
export interface SystemInitPayload {
|
||||||
implicit_hosting: boolean
|
implicit_hosting: boolean
|
||||||
locks: Record<string, string>
|
locks: Record<string, string>
|
||||||
|
file_transfer: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// system/disconnect
|
// system/disconnect
|
||||||
@ -192,6 +194,18 @@ export interface EmojiSendPayload {
|
|||||||
emote: string
|
emote: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FILE TRANSFER PAYLOADS
|
||||||
|
*/
|
||||||
|
export interface FileTransferListMessage extends WebSocketMessage, FileTransferListPayload {
|
||||||
|
event: FileTransferEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileTransferListPayload {
|
||||||
|
cwd: string
|
||||||
|
files: FileListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
SCREEN PAYLOADS
|
SCREEN PAYLOADS
|
||||||
*/
|
*/
|
||||||
@ -248,7 +262,7 @@ export interface AdminLockMessage extends WebSocketMessage, AdminLockPayload {
|
|||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminLockResource = 'login' | 'control'
|
export type AdminLockResource = 'login' | 'control' | 'file_transfer'
|
||||||
|
|
||||||
export interface AdminLockPayload {
|
export interface AdminLockPayload {
|
||||||
resource: AdminLockResource
|
resource: AdminLockResource
|
||||||
|
@ -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' | '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(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.sendMessage(EVENT.FILETRANSFER.REFRESH)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
@ -1,12 +1,13 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import Vuex from 'vuex'
|
import Vuex from 'vuex'
|
||||||
import { useAccessor, mutationTree, actionTree } from 'typed-vuex'
|
import { useAccessor, mutationTree, getterTree, actionTree } from 'typed-vuex'
|
||||||
import { EVENT } from '~/neko/events'
|
import { EVENT } from '~/neko/events'
|
||||||
import { AdminLockResource } from '~/neko/messages'
|
import { AdminLockResource } from '~/neko/messages'
|
||||||
import { get, set } from '~/utils/localstorage'
|
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'
|
||||||
@ -55,8 +56,12 @@ export const mutations = mutationTree(state, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const getters = getterTree(state, {
|
||||||
|
isLocked: (state) => (resource: AdminLockResource) => resource in state.locked && state.locked[resource],
|
||||||
|
})
|
||||||
|
|
||||||
export const actions = actionTree(
|
export const actions = actionTree(
|
||||||
{ state, mutations },
|
{ state, getters, mutations },
|
||||||
{
|
{
|
||||||
initialise(store) {
|
initialise(store) {
|
||||||
accessor.emoji.initialise()
|
accessor.emoji.initialise()
|
||||||
@ -79,6 +84,14 @@ export const actions = actionTree(
|
|||||||
$client.sendMessage(EVENT.ADMIN.UNLOCK, { resource })
|
$client.sendMessage(EVENT.ADMIN.UNLOCK, { resource })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleLock(_, resource: AdminLockResource) {
|
||||||
|
if (accessor.isLocked(resource)) {
|
||||||
|
accessor.unlock(resource)
|
||||||
|
} else {
|
||||||
|
accessor.lock(resource)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
login({ state }, { displayname, password }: { displayname: string; password: string }) {
|
login({ state }, { displayname, password }: { displayname: string; password: string }) {
|
||||||
accessor.setLogin({ displayname, password })
|
accessor.setLogin({ displayname, password })
|
||||||
$client.login(password, displayname)
|
$client.login(password, displayname)
|
||||||
@ -97,7 +110,8 @@ export const storePattern = {
|
|||||||
state,
|
state,
|
||||||
mutations,
|
mutations,
|
||||||
actions,
|
actions,
|
||||||
modules: { video, chat, user, remote, settings, client, emoji },
|
getters,
|
||||||
|
modules: { video, chat, files, user, remote, settings, client, emoji },
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.use(Vuex)
|
Vue.use(Vuex)
|
||||||
|
@ -13,6 +13,7 @@ export const state = () => ({
|
|||||||
clipboard: '',
|
clipboard: '',
|
||||||
locked: false,
|
locked: false,
|
||||||
implicitHosting: true,
|
implicitHosting: true,
|
||||||
|
fileTransfer: true,
|
||||||
keyboardModifierState: -1,
|
keyboardModifierState: -1,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -53,6 +54,10 @@ export const mutations = mutationTree(state, {
|
|||||||
state.implicitHosting = val
|
state.implicitHosting = val
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setFileTransfer(state, val: boolean) {
|
||||||
|
state.fileTransfer = val
|
||||||
|
},
|
||||||
|
|
||||||
reset(state) {
|
reset(state) {
|
||||||
state.id = ''
|
state.id = ''
|
||||||
state.clipboard = ''
|
state.clipboard = ''
|
||||||
|
@ -23,5 +23,5 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
devServer: {
|
devServer: {
|
||||||
disableHostCheck: true,
|
disableHostCheck: true,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
@ -8,11 +8,13 @@
|
|||||||
- Added `NEKO_PATH_PREFIX`.
|
- Added `NEKO_PATH_PREFIX`.
|
||||||
- Added screenshot function `/screenshot.jpg?pwd=<admin>`, works only for unlocked rooms.
|
- Added screenshot function `/screenshot.jpg?pwd=<admin>`, works only for unlocked rooms.
|
||||||
- Added emoji support (by @yesBad).
|
- Added emoji support (by @yesBad).
|
||||||
|
- Added file transfer (by @prophetofxenu).
|
||||||
|
|
||||||
### Misc
|
### Misc
|
||||||
- Server: Split `remote` to `desktop` and `capture`.
|
- Server: Split `remote` to `desktop` and `capture`.
|
||||||
- Server: Refactored `xorg` - added `xevent` and clipboard is handled as event (no looped polling anymore).
|
- Server: Refactored `xorg` - added `xevent` and clipboard is handled as event (no looped polling anymore).
|
||||||
- Introduced `NEKO_AUDIO_CODEC=` and `NEKO_VIDEO_CODEC=` as a new way of setting codecs.
|
- Introduced `NEKO_AUDIO_CODEC=` and `NEKO_VIDEO_CODEC=` as a new way of setting codecs.
|
||||||
|
- Added CORS.
|
||||||
|
|
||||||
## [n.eko v2.6](https://github.com/m1k1o/neko/releases/tag/v2.6)
|
## [n.eko v2.6](https://github.com/m1k1o/neko/releases/tag/v2.6)
|
||||||
|
|
||||||
|
@ -32,6 +32,7 @@ nat1to1: <ip>
|
|||||||
- Currently supported:
|
- Currently supported:
|
||||||
- `control`
|
- `control`
|
||||||
- `login`
|
- `login`
|
||||||
|
- `file_transfer`
|
||||||
- e.g. `control`
|
- e.g. `control`
|
||||||
|
|
||||||
### WebRTC
|
### WebRTC
|
||||||
@ -125,6 +126,19 @@ nat1to1: <ip>
|
|||||||
#### `NEKO_PATH_PREFIX`:
|
#### `NEKO_PATH_PREFIX`:
|
||||||
- Path prefix for HTTP requests.
|
- Path prefix for HTTP requests.
|
||||||
- e.g. `/neko/`
|
- e.g. `/neko/`
|
||||||
|
#### `NEKO_CORS`:
|
||||||
|
- Cross origin request sharing, whitespace separated list of allowed hosts, `*` for all.
|
||||||
|
- e.g. `127.0.0.1 neko.example.com`
|
||||||
|
|
||||||
|
### File Transfer
|
||||||
|
|
||||||
|
#### `NEKO_FILE_TRANSFER_ENABLED`:
|
||||||
|
- Enable file transfer feature.
|
||||||
|
- e.g. `true`
|
||||||
|
#### `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
|
||||||
|
|
||||||
@ -152,9 +166,12 @@ Flags:
|
|||||||
--broadcast_url string URL for broadcasting, setting this value will automatically enable broadcasting
|
--broadcast_url string URL for broadcasting, setting this value will automatically enable broadcasting
|
||||||
--cert string path to the SSL cert used to secure the neko server
|
--cert string path to the SSL cert used to secure the neko server
|
||||||
--control_protection control protection means, users can gain control only if at least one admin is in the room
|
--control_protection control protection means, users can gain control only if at least one admin is in the room
|
||||||
|
--cors strings list of allowed origins for CORS (default [*])
|
||||||
--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_enabled enable file transfer feature (default false)
|
||||||
|
--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
|
||||||
|
@ -3,8 +3,9 @@ module m1k1o/neko
|
|||||||
go 1.18
|
go 1.18
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
github.com/fsnotify/fsnotify v1.6.0
|
||||||
github.com/go-chi/chi v4.1.2+incompatible
|
github.com/go-chi/chi v4.1.2+incompatible
|
||||||
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/kataras/go-events v0.0.3
|
github.com/kataras/go-events v0.0.3
|
||||||
github.com/pion/ice/v2 v2.2.11 // indirect
|
github.com/pion/ice/v2 v2.2.11 // indirect
|
||||||
|
@ -65,6 +65,8 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4
|
|||||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||||
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
|
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
|
||||||
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||||
|
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||||
|
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
|
"m1k1o/neko/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@ -13,6 +16,7 @@ type Server struct {
|
|||||||
Bind string
|
Bind string
|
||||||
Static string
|
Static string
|
||||||
PathPrefix string
|
PathPrefix string
|
||||||
|
CORS []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Server) Init(cmd *cobra.Command) error {
|
func (Server) Init(cmd *cobra.Command) error {
|
||||||
@ -41,6 +45,11 @@ func (Server) Init(cmd *cobra.Command) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().StringSlice("cors", []string{"*"}, "list of allowed origins for CORS")
|
||||||
|
if err := viper.BindPFlag("cors", cmd.PersistentFlags().Lookup("cors")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,4 +59,15 @@ func (s *Server) Set() {
|
|||||||
s.Bind = viper.GetString("bind")
|
s.Bind = viper.GetString("bind")
|
||||||
s.Static = viper.GetString("static")
|
s.Static = viper.GetString("static")
|
||||||
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("path_prefix")))
|
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("path_prefix")))
|
||||||
|
|
||||||
|
s.CORS = viper.GetStringSlice("cors")
|
||||||
|
in, _ := utils.ArrayIn("*", s.CORS)
|
||||||
|
if len(s.CORS) == 0 || in {
|
||||||
|
s.CORS = []string{"*"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) AllowOrigin(r *http.Request, origin string) bool {
|
||||||
|
in, _ := utils.ArrayIn(origin, s.CORS)
|
||||||
|
return in || s.CORS[0] == "*"
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
@ -12,6 +14,9 @@ type WebSocket struct {
|
|||||||
Locks []string
|
Locks []string
|
||||||
|
|
||||||
ControlProtection bool
|
ControlProtection bool
|
||||||
|
|
||||||
|
FileTransferEnabled bool
|
||||||
|
FileTransferPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (WebSocket) Init(cmd *cobra.Command) error {
|
func (WebSocket) Init(cmd *cobra.Command) error {
|
||||||
@ -40,6 +45,18 @@ func (WebSocket) Init(cmd *cobra.Command) error {
|
|||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,4 +67,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.FileTransferEnabled = viper.GetBool("file_transfer_enabled")
|
||||||
|
s.FileTransferPath = viper.GetString("file_transfer_path")
|
||||||
|
s.FileTransferPath = filepath.Clean(s.FileTransferPath)
|
||||||
}
|
}
|
||||||
|
@ -3,13 +3,17 @@ 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"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
@ -17,6 +21,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 +37,16 @@ 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"))
|
||||||
|
|
||||||
|
router.Use(cors.Handler(cors.Options{
|
||||||
|
AllowOriginFunc: conf.AllowOrigin,
|
||||||
|
AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"},
|
||||||
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
|
ExposedHeaders: []string{"Link"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
MaxAge: 300, // Maximum value not ignored by any of major browsers
|
||||||
|
}))
|
||||||
|
|
||||||
if conf.PathPrefix != "/" {
|
if conf.PathPrefix != "/" {
|
||||||
router.Use(func(h http.Handler) http.Handler {
|
router.Use(func(h http.Handler) http.Handler {
|
||||||
@ -99,6 +115,78 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
r.ParseMultipartForm(32 << 20)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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,11 @@ const (
|
|||||||
CHAT_EMOTE = "chat/emote"
|
CHAT_EMOTE = "chat/emote"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
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"
|
||||||
|
@ -14,6 +14,7 @@ type SystemInit struct {
|
|||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
ImplicitHosting bool `json:"implicit_hosting"`
|
ImplicitHosting bool `json:"implicit_hosting"`
|
||||||
Locks map[string]string `json:"locks"`
|
Locks map[string]string `json:"locks"`
|
||||||
|
FileTransfer bool `json:"file_transfer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemMessage struct {
|
type SystemMessage struct {
|
||||||
@ -48,7 +49,7 @@ type SignalCandidate struct {
|
|||||||
|
|
||||||
type MembersList struct {
|
type MembersList struct {
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
Memebers []*types.Member `json:"members"`
|
Members []*types.Member `json:"members"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Member struct {
|
type Member struct {
|
||||||
@ -106,6 +107,12 @@ type EmoteSend struct {
|
|||||||
Emote string `json:"emote"`
|
Emote string `json:"emote"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileTransferList 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,15 @@ 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)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
@ -19,7 +19,12 @@ func (h *MessageHandler) adminLock(id string, session types.Session, payload *me
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Resource != "login" && payload.Resource != "control" {
|
// allow only known resources
|
||||||
|
switch payload.Resource {
|
||||||
|
case "login":
|
||||||
|
case "control":
|
||||||
|
case "file_transfer":
|
||||||
|
default:
|
||||||
h.logger.Debug().Msg("unknown lock resource")
|
h.logger.Debug().Msg("unknown lock resource")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
42
server/internal/websocket/handler/filetransfer.go
Normal file
42
server/internal/websocket/handler/filetransfer.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
@ -126,6 +126,10 @@ 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_REFRESH:
|
||||||
|
return errors.Wrapf(h.FileTransferRefresh(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)
|
||||||
|
@ -17,6 +17,7 @@ func (h *MessageHandler) SessionCreated(id string, session types.Session) error
|
|||||||
Event: event.SYSTEM_INIT,
|
Event: event.SYSTEM_INIT,
|
||||||
ImplicitHosting: h.webrtc.ImplicitControl(),
|
ImplicitHosting: h.webrtc.ImplicitControl(),
|
||||||
Locks: h.state.AllLocked(),
|
Locks: h.state.AllLocked(),
|
||||||
|
FileTransfer: h.state.FileTransferEnabled(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
h.logger.Warn().Str("id", id).Err(err).Msgf("sending event %s has failed", event.SYSTEM_INIT)
|
h.logger.Warn().Str("id", id).Err(err).Msgf("sending event %s has failed", event.SYSTEM_INIT)
|
||||||
return err
|
return err
|
||||||
@ -34,6 +35,13 @@ func (h *MessageHandler) SessionCreated(id string, session types.Session) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,7 +49,7 @@ func (h *MessageHandler) SessionConnected(id string, session types.Session) erro
|
|||||||
// send list of members to session
|
// send list of members to session
|
||||||
if err := session.Send(message.MembersList{
|
if err := session.Send(message.MembersList{
|
||||||
Event: event.MEMBER_LIST,
|
Event: event.MEMBER_LIST,
|
||||||
Memebers: h.sessions.Members(),
|
Members: h.sessions.Members(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
h.logger.Warn().Str("id", id).Err(err).Msgf("sending event %s has failed", event.MEMBER_LIST)
|
h.logger.Warn().Str("id", id).Err(err).Msgf("sending event %s has failed", event.MEMBER_LIST)
|
||||||
return err
|
return err
|
||||||
|
@ -1,14 +1,22 @@
|
|||||||
package state
|
package state
|
||||||
|
|
||||||
|
import "path/filepath"
|
||||||
|
|
||||||
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
|
||||||
|
fileTransferPath string // path where files are located
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *State {
|
func New(fileTransferEnabled 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,
|
||||||
|
fileTransferPath: fileTransferPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,3 +67,18 @@ 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) 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 (
|
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.FileTransferEnabled, 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
||||||
@ -187,6 +197,37 @@ func (ws *WebSocketHandler) Start() {
|
|||||||
|
|
||||||
ws.logger.Err(err).Msg("sync clipboard")
|
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 {
|
func (ws *WebSocketHandler) Shutdown() error {
|
||||||
@ -384,3 +425,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