Merge branch 'master' of github.com:prophetofxenu/neko
This commit is contained in:
commit
472a3c3355
@ -22,7 +22,7 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.2.0",
|
||||
"animejs": "^3.2.0",
|
||||
"axios": "^0.21.4",
|
||||
"axios": "^0.24.0",
|
||||
"date-fns": "^2.29.3",
|
||||
"emoji-datasource": "^6.0.1",
|
||||
"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 />
|
||||
</label>
|
||||
</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">
|
||||
<div>
|
||||
<span>{{ $t('setting.broadcast_title') }}</span>
|
||||
@ -366,6 +380,22 @@
|
||||
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() {
|
||||
return this.$accessor.settings.broadcast_is_active
|
||||
}
|
||||
|
@ -6,6 +6,10 @@
|
||||
<i class="fas fa-comment-alt" />
|
||||
<span>{{ $t('side.chat') }}</span>
|
||||
</li>
|
||||
<li v-if="filetransferAllowed" :class="{ active: tab === 'files' }" @click.stop.prevent="change('files')">
|
||||
<i class="fas fa-file" />
|
||||
<span>{{ $t('side.files') }}</span>
|
||||
</li>
|
||||
<li :class="{ active: tab === 'settings' }" @click.stop.prevent="change('settings')">
|
||||
<i class="fas fa-sliders-h" />
|
||||
<span>{{ $t('side.settings') }}</span>
|
||||
@ -14,6 +18,7 @@
|
||||
</div>
|
||||
<div class="page-container">
|
||||
<neko-chat v-if="tab === 'chat'" />
|
||||
<neko-files v-if="tab === 'files'" />
|
||||
<neko-settings v-if="tab === 'settings'" />
|
||||
</div>
|
||||
</aside>
|
||||
@ -78,15 +83,31 @@
|
||||
|
||||
import Settings from '~/components/settings.vue'
|
||||
import Chat from '~/components/chat.vue'
|
||||
import Files from '~/components/files.vue'
|
||||
|
||||
@Component({
|
||||
name: 'neko',
|
||||
components: {
|
||||
'neko-settings': Settings,
|
||||
'neko-chat': Chat,
|
||||
'neko-files': Files
|
||||
},
|
||||
})
|
||||
export default class extends Vue {
|
||||
|
||||
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() {
|
||||
return this.$accessor.client.tab
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ export const send_a_message = 'Sende eine Nachricht'
|
||||
|
||||
export const side = {
|
||||
chat: 'Chat',
|
||||
files: 'Dateien',
|
||||
settings: 'Einstellungen',
|
||||
}
|
||||
|
||||
@ -77,6 +78,8 @@ export const setting = {
|
||||
ignore_emotes: 'Emotes ignorieren',
|
||||
chat_sound: 'Chat-Sound abspielen',
|
||||
keyboard_layout: 'Tastaturbelegung',
|
||||
file_transfer: 'Dateiübertragung',
|
||||
unpriv_file_transfer: 'Übertragung von Benutzerdateien',
|
||||
broadcast_title: 'Live-Übertragung',
|
||||
}
|
||||
|
||||
@ -108,3 +111,9 @@ export const notifications = {
|
||||
muted: '{name} stummgeschaltet',
|
||||
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 = {
|
||||
chat: 'Chat',
|
||||
files: 'Files',
|
||||
settings: 'Settings',
|
||||
}
|
||||
|
||||
@ -79,6 +80,8 @@ export const setting = {
|
||||
ignore_emotes: 'Ignore Emotes',
|
||||
chat_sound: 'Play Chat Sound',
|
||||
keyboard_layout: 'Keyboard Layout',
|
||||
file_transfer: 'File Transfer',
|
||||
unpriv_file_transfer: 'Non-admin File Transfer',
|
||||
broadcast_title: 'Live Broadcast',
|
||||
}
|
||||
|
||||
@ -110,3 +113,9 @@ export const notifications = {
|
||||
muted: 'muted {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 = {
|
||||
chat: 'Chat',
|
||||
files: 'Archivos',
|
||||
settings: 'Configuración',
|
||||
}
|
||||
|
||||
@ -83,6 +84,8 @@ export const setting = {
|
||||
ignore_emotes: 'Ignorar Emotes',
|
||||
chat_sound: 'Reproducir Sonidos Chat',
|
||||
keyboard_layout: 'Keyboard Layout',
|
||||
file_transfer: 'Transferencia de archivos',
|
||||
unpriv_file_transfer: 'Transferencia de archivos de usuario',
|
||||
// TODO
|
||||
//broadcast_title: 'Live Broadcast',
|
||||
}
|
||||
@ -117,3 +120,9 @@ export const notifications = {
|
||||
muted: '{name} 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 = {
|
||||
chat: 'Chatti',
|
||||
files: 'Tiedostot',
|
||||
settings: 'Asetukset',
|
||||
}
|
||||
|
||||
@ -79,6 +80,8 @@ export const setting = {
|
||||
ignore_emotes: 'Estä emojit',
|
||||
chat_sound: 'Soita viesti ääni',
|
||||
keyboard_layout: 'Näppäimistöasettelu',
|
||||
file_transfer: 'Tiedoston siirto',
|
||||
unpriv_file_transfer: 'Käyttäjän tiedostojen siirto',
|
||||
broadcast_title: 'Suora Lähetys',
|
||||
}
|
||||
|
||||
@ -110,3 +113,9 @@ export const notifications = {
|
||||
muted: 'mykistetty {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 = {
|
||||
chat: 'Chat',
|
||||
files: 'Fichiers',
|
||||
settings: 'Paramètres',
|
||||
}
|
||||
|
||||
@ -83,6 +84,8 @@ export const setting = {
|
||||
ignore_emotes: 'Ignorer les Emotes',
|
||||
chat_sound: 'Jouer le son du tchat',
|
||||
keyboard_layout: 'Langue du clavier',
|
||||
file_transfer: 'Transfert de fichiers',
|
||||
unpriv_file_transfer: 'Transfert de fichiers d\'utilisateurs',
|
||||
// TODO
|
||||
//broadcast_title: 'Live Broadcast',
|
||||
}
|
||||
@ -117,3 +120,9 @@ export const notifications = {
|
||||
muted: 'a 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 = {
|
||||
chat: '채팅',
|
||||
files: '파일',
|
||||
settings: '설정',
|
||||
}
|
||||
|
||||
@ -77,6 +78,8 @@ export const setting = {
|
||||
ignore_emotes: '이모지 무시',
|
||||
chat_sound: '채팅 소리 재생',
|
||||
keyboard_layout: '키보드 레이아웃',
|
||||
file_transfer: '파일 전송',
|
||||
unpriv_file_transfer: '사용자 파일 전송',
|
||||
broadcast_title: '실시간 방송',
|
||||
}
|
||||
|
||||
@ -108,3 +111,9 @@ export const notifications = {
|
||||
muted: '{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 = {
|
||||
chat: 'Sludring',
|
||||
files: 'Filer',
|
||||
settings: 'Innstillinger',
|
||||
}
|
||||
|
||||
@ -83,6 +84,8 @@ export const setting = {
|
||||
ignore_emotes: 'Ignorer smilefjes',
|
||||
chat_sound: 'Sludringslyd',
|
||||
keyboard_layout: 'Tastaturoppsett',
|
||||
file_transfer: 'Filoverførsel',
|
||||
unpriv_file_transfer: 'Overførsel af brugerfiler',
|
||||
// TODO
|
||||
//broadcast_title: 'Live Broadcast',
|
||||
}
|
||||
@ -117,3 +120,9 @@ export const notifications = {
|
||||
muted: 'forstummet {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 = {
|
||||
chat: 'Чат',
|
||||
files: 'Файлы',
|
||||
settings: 'Настройки',
|
||||
}
|
||||
|
||||
@ -79,6 +80,8 @@ export const setting = {
|
||||
ignore_emotes: 'Игнорировать эмоции',
|
||||
chat_sound: 'Проигрывать звук чата',
|
||||
keyboard_layout: 'Раскладка клавиатуры',
|
||||
file_transfer: 'Передача файлов',
|
||||
unpriv_file_transfer: 'Передача файлов пользователей',
|
||||
broadcast_title: 'Прямой эфир',
|
||||
}
|
||||
|
||||
@ -110,3 +113,9 @@ export const notifications = {
|
||||
muted: 'заглушен {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 = {
|
||||
chat: 'Chat',
|
||||
files: 'Súbory',
|
||||
settings: 'Nastavenia',
|
||||
}
|
||||
|
||||
@ -82,6 +83,8 @@ export const setting = {
|
||||
ignore_emotes: 'Ignorovať smajlíky',
|
||||
chat_sound: 'Prehrávať zvuky chatu',
|
||||
keyboard_layout: 'Rozloženie klávesnice',
|
||||
file_transfer: 'Prenos súborov',
|
||||
unpriv_file_transfer: 'Prenos súborov používateľa',
|
||||
broadcast_title: 'Živé vysielanie',
|
||||
}
|
||||
|
||||
@ -113,3 +116,9 @@ export const notifications = {
|
||||
muted: 'zakázal 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 = {
|
||||
chat: 'Chatt',
|
||||
files: 'Filer',
|
||||
settings: 'Inställningar',
|
||||
}
|
||||
|
||||
@ -83,6 +84,8 @@ export const setting = {
|
||||
ignore_emotes: 'Ignorera Emotes',
|
||||
chat_sound: 'Spela Chatt Ljud',
|
||||
keyboard_layout: 'Tangentbordslayout',
|
||||
file_transfer: 'Överföring av filer',
|
||||
unpriv_file_transfer: 'Överföring av användarfiler',
|
||||
// TODO
|
||||
//broadcast_title: 'Live Broadcast',
|
||||
}
|
||||
@ -117,3 +120,9 @@ export const notifications = {
|
||||
muted: 'tystade {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 = {
|
||||
chat: '聊天',
|
||||
files: '文件',
|
||||
settings: '设置',
|
||||
}
|
||||
|
||||
@ -79,6 +80,8 @@ export const setting = {
|
||||
ignore_emotes: '忽略表情符号',
|
||||
chat_sound: '播放聊天声音',
|
||||
keyboard_layout: '键盘布局',
|
||||
file_transfer: '文件传输',
|
||||
unpriv_file_transfer: '用户文件传输',
|
||||
broadcast_title: '现场流媒体',
|
||||
}
|
||||
|
||||
@ -110,3 +113,9 @@ export const notifications = {
|
||||
muted: '鸟粪 {name}',
|
||||
unmuted: '取消静音 {name}',
|
||||
}
|
||||
|
||||
export const files = {
|
||||
downloads: '下载',
|
||||
uploads: '上传',
|
||||
upload_here: '点击或拖动文件到这里来上传'
|
||||
}
|
||||
|
@ -38,6 +38,11 @@ export const EVENT = {
|
||||
MESSAGE: 'chat/message',
|
||||
EMOTE: 'chat/emote',
|
||||
},
|
||||
FILETRANSFER: {
|
||||
STATUS: 'filetransfer/status',
|
||||
LIST: 'filetransfer/list',
|
||||
REFRESH: 'filetransfer/refresh'
|
||||
},
|
||||
SCREEN: {
|
||||
CONFIGURATIONS: 'screen/configurations',
|
||||
RESOLUTION: 'screen/resolution',
|
||||
@ -69,6 +74,7 @@ export type WebSocketEvents =
|
||||
| MemberEvents
|
||||
| SignalEvents
|
||||
| ChatEvents
|
||||
| FileTransferEvents
|
||||
| ScreenEvents
|
||||
| BroadcastEvents
|
||||
| AdminEvents
|
||||
@ -91,6 +97,12 @@ export type SignalEvents =
|
||||
| typeof EVENT.SIGNAL.CANDIDATE
|
||||
|
||||
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 BroadcastEvents =
|
||||
|
@ -24,6 +24,8 @@ import {
|
||||
AdminLockMessage,
|
||||
SystemInitPayload,
|
||||
AdminLockResource,
|
||||
FileTransferListPayload,
|
||||
FileTransferStatusPayload,
|
||||
} from './messages'
|
||||
|
||||
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
|
||||
/////////////////////////////
|
||||
@ -351,6 +361,18 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
|
||||
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
|
||||
/////////////////////////////
|
||||
|
@ -8,8 +8,14 @@ import {
|
||||
ChatEvents,
|
||||
ScreenEvents,
|
||||
AdminEvents,
|
||||
FileTransferEvents,
|
||||
} from './events'
|
||||
import { Member, ScreenConfigurations, ScreenResolution } from './types'
|
||||
import {
|
||||
FileListItem,
|
||||
Member,
|
||||
ScreenConfigurations,
|
||||
ScreenResolution
|
||||
} from './types'
|
||||
|
||||
export type WebSocketMessages =
|
||||
| WebSocketMessage
|
||||
@ -38,6 +44,7 @@ export type WebSocketPayloads =
|
||||
| ChatPayload
|
||||
| ChatSendPayload
|
||||
| EmojiSendPayload
|
||||
| FileTransferStatusPayload
|
||||
| ScreenResolutionPayload
|
||||
| ScreenConfigurationsPayload
|
||||
| AdminPayload
|
||||
@ -192,6 +199,24 @@ export interface EmojiSendPayload {
|
||||
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
|
||||
*/
|
||||
|
@ -22,3 +22,20 @@ export interface ScreenResolution {
|
||||
height: number
|
||||
rate: number
|
||||
}
|
||||
|
||||
export interface FileListItem {
|
||||
name: string,
|
||||
type: 'file' | 'dir',
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface FileTransfer {
|
||||
id: number,
|
||||
name: string,
|
||||
direction: 'upload' | 'download',
|
||||
size: number,
|
||||
progress: number,
|
||||
status: 'pending' | 'inprogress' | 'completed',
|
||||
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 chat from './chat'
|
||||
import * as files from './files'
|
||||
import * as remote from './remote'
|
||||
import * as user from './user'
|
||||
import * as settings from './settings'
|
||||
@ -97,7 +98,7 @@ export const storePattern = {
|
||||
state,
|
||||
mutations,
|
||||
actions,
|
||||
modules: { video, chat, user, remote, settings, client, emoji },
|
||||
modules: { video, chat, files, user, remote, settings, client, emoji },
|
||||
}
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
@ -20,6 +20,9 @@ export const state = () => {
|
||||
|
||||
keyboard_layouts_list: {} as KeyboardLayouts,
|
||||
|
||||
file_transfer: false,
|
||||
unpriv_file_transfer: false,
|
||||
|
||||
broadcast_is_active: false,
|
||||
broadcast_url: '',
|
||||
}
|
||||
@ -58,6 +61,14 @@ export const mutations = mutationTree(state, {
|
||||
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) {
|
||||
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 }) {
|
||||
accessor.settings.setBroadcastStatus({ url, isActive })
|
||||
},
|
||||
|
@ -126,6 +126,19 @@ nat1to1: <ip>
|
||||
- Path prefix for HTTP requests.
|
||||
- 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
|
||||
|
||||
#### `NEKO_DISPLAY`:
|
||||
@ -155,6 +168,8 @@ Flags:
|
||||
--device string audio device to capture (default "auto_null.monitor")
|
||||
--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")
|
||||
--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
|
||||
--h264 DEPRECATED: use video_codec
|
||||
-h, --help help for serve
|
||||
@ -179,6 +194,7 @@ Flags:
|
||||
--static string path to neko client files to serve (default "./www")
|
||||
--tcpmux int single TCP 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_bitrate int video bitrate in kbit/s (default 3072)
|
||||
--video_codec string video codec to be used (default "vp8")
|
||||
|
@ -12,6 +12,10 @@ type WebSocket struct {
|
||||
Locks []string
|
||||
|
||||
ControlProtection bool
|
||||
|
||||
FileTransfer bool
|
||||
UnprivFileTransfer bool
|
||||
FileTransferPath string
|
||||
}
|
||||
|
||||
func (WebSocket) Init(cmd *cobra.Command) error {
|
||||
@ -40,6 +44,21 @@ func (WebSocket) Init(cmd *cobra.Command) error {
|
||||
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
|
||||
}
|
||||
|
||||
@ -50,4 +69,8 @@ func (s *WebSocket) Set() {
|
||||
s.Locks = viper.GetStringSlice("locks")
|
||||
|
||||
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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
@ -17,6 +20,8 @@ import (
|
||||
"m1k1o/neko/internal/types"
|
||||
)
|
||||
|
||||
const FILE_UPLOAD_BUF_SIZE = 65000
|
||||
|
||||
type Server struct {
|
||||
logger zerolog.Logger
|
||||
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.RequestLogger(&logformatter{logger}))
|
||||
router.Use(middleware.Recoverer) // Recover from panics without crashing server
|
||||
router.Use(middleware.Compress(5, "application/octet-stream"))
|
||||
|
||||
if conf.PathPrefix != "/" {
|
||||
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) {
|
||||
_, _ = w.Write([]byte("true"))
|
||||
})
|
||||
|
@ -34,6 +34,12 @@ const (
|
||||
CHAT_EMOTE = "chat/emote"
|
||||
)
|
||||
|
||||
const (
|
||||
FILETRANSFER_STATUS = "filetransfer/status"
|
||||
FILETRANSFER_LIST = "filetransfer/list"
|
||||
FILETRANSFER_REFRESH = "filetransfer/refresh"
|
||||
)
|
||||
|
||||
const (
|
||||
SCREEN_CONFIGURATIONS = "screen/configurations"
|
||||
SCREEN_RESOLUTION = "screen/resolution"
|
||||
|
@ -106,6 +106,22 @@ type EmoteSend struct {
|
||||
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 {
|
||||
Event string `json:"event"`
|
||||
ID string `json:"id"`
|
||||
|
@ -34,4 +34,12 @@ type WebSocketHandler interface {
|
||||
Stats() Stats
|
||||
IsLocked(resource string) bool
|
||||
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)
|
||||
}), "%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
|
||||
case event.SCREEN_RESOLUTION:
|
||||
return errors.Wrapf(h.screenResolution(id, session), "%s failed", header.Event)
|
||||
|
@ -3,12 +3,20 @@ package state
|
||||
type State struct {
|
||||
banned map[string]string // IP -> session ID (that banned it)
|
||||
locked map[string]string // resource name -> session ID (that locked it)
|
||||
|
||||
fileTransferEnabled bool // 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{
|
||||
banned: 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 {
|
||||
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 (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
@ -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 {
|
||||
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 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")
|
||||
}
|
||||
|
||||
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
|
||||
for _, lock := range conf.Locks {
|
||||
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
|
||||
if session.Admin() {
|
||||
ws.lastAdminLeftAt = nil
|
||||
@ -187,6 +224,28 @@ func (ws *WebSocketHandler) Start() {
|
||||
|
||||
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 {
|
||||
@ -314,6 +373,50 @@ func (ws *WebSocketHandler) IsAdmin(password string) (bool, error) {
|
||||
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) {
|
||||
passwords, ok := r.URL.Query()["password"]
|
||||
if !ok || len(passwords[0]) < 1 {
|
||||
|
Reference in New Issue
Block a user