Archived
2
0

Merge branch 'master' of github.com:prophetofxenu/neko

This commit is contained in:
Miroslav Šedivý 2022-11-19 15:46:11 +01:00
commit 472a3c3355
33 changed files with 1130 additions and 5 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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