Archived
2
0

Merge pull request #221 from prophetofxenu/master

In app file transfer
This commit is contained in:
Miroslav Šedivý 2022-11-19 22:29:16 +01:00 committed by GitHub
commit db87229f16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1271 additions and 44 deletions

View File

@ -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",

View 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>

View File

@ -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">&bull;</span> <span v-if="showBadge" class="badge">&bull;</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`))

View File

@ -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)
} }

View File

@ -40,7 +40,7 @@ const exportMixin = {
}, },
$client() { $client() {
return window.$client return window.$client
} },
}, },
} }
@ -53,14 +53,7 @@ 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)

View File

@ -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',
}

View File

@ -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',
}

View File

@ -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',
}

View File

@ -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',
}

View File

@ -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',
}

View File

@ -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: '업로드할 파일을 여기로 클릭하거나 드래그하세요.',
}

View File

@ -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',
}

View File

@ -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: 'Нажмите или перетащите сюда файлы для загрузки',
}

View File

@ -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ť',
}

View File

@ -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',
}

View File

@ -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: '点击或拖动文件到这里来上传',
}

View File

@ -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 =

View File

@ -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
///////////////////////////// /////////////////////////////

View File

@ -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

View File

@ -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
View 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)
},
},
)

View File

@ -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)

View File

@ -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 = ''

View File

@ -23,5 +23,5 @@ module.exports = {
}, },
devServer: { devServer: {
disableHostCheck: true, disableHostCheck: true,
} },
} }

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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=

View File

@ -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] == "*"
} }

View File

@ -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)
} }

View File

@ -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"))
}) })

View File

@ -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"

View File

@ -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"`

View File

@ -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"`
} }

View 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
}

View File

@ -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
} }

View 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)
}

View File

@ -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)

View File

@ -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

View File

@ -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
}

View File

@ -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
}