diff --git a/client/package.json b/client/package.json
index fe48e211..b138b7ed 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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",
diff --git a/client/src/components/files.vue b/client/src/components/files.vue
new file mode 100644
index 00000000..b40553af
--- /dev/null
+++ b/client/src/components/files.vue
@@ -0,0 +1,427 @@
+
+
+
+
+
+
+
{{ item.name }}
+
{{ fileSize(item.size) }}
+
download(item)" />
+
+
+
+
+
{{ $t('files.downloads') }}
+
+
+
+
{{ download.name }}
+
{{ Math.min(100, Math.round(download.progress / download.size * 100))}}%
+
removeTransfer(download)">
+
+
+
+
{{ $t('files.uploads' )}}
+
+
+
+
{{ upload.name }}
+
{{ Math.min(100, Math.round(upload.progress / upload.size * 100))}}%
+
removeTransfer(upload)">
+
+
+
+
+
uploadAreaDrag = true" @dragleave.prevent="() => uploadAreaDrag = false"
+ @drop.prevent="(e) => upload(e.dataTransfer)" @click="openFileBrowser">
+
+
{{ $t('files.upload_here') }}
+
+
+
+
+
+
+
+
diff --git a/client/src/components/settings.vue b/client/src/components/settings.vue
index e6c991bd..c95bd67d 100644
--- a/client/src/components/settings.vue
+++ b/client/src/components/settings.vue
@@ -44,6 +44,20 @@
+
+ {{ $t('setting.file_transfer') }}
+
+
+
+ {{ $t('setting.unpriv_file_transfer') }}
+
+
{{ $t('setting.broadcast_title') }}
@@ -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
}
diff --git a/client/src/components/side.vue b/client/src/components/side.vue
index db1ff814..282065a9 100644
--- a/client/src/components/side.vue
+++ b/client/src/components/side.vue
@@ -6,6 +6,10 @@
{{ $t('side.chat') }}
+
+
+ {{ $t('side.files') }}
+
{{ $t('side.settings') }}
@@ -14,6 +18,7 @@
+
@@ -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
}
diff --git a/client/src/locale/de-de.ts b/client/src/locale/de-de.ts
index dd68028f..4a6677c7 100644
--- a/client/src/locale/de-de.ts
+++ b/client/src/locale/de-de.ts
@@ -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'
+}
diff --git a/client/src/locale/en-us.ts b/client/src/locale/en-us.ts
index 91d98d9c..7fab0d62 100644
--- a/client/src/locale/en-us.ts
+++ b/client/src/locale/en-us.ts
@@ -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'
+}
diff --git a/client/src/locale/es-sp.ts b/client/src/locale/es-sp.ts
index 3c33b1d3..7bcac54c 100644
--- a/client/src/locale/es-sp.ts
+++ b/client/src/locale/es-sp.ts
@@ -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'
+}
diff --git a/client/src/locale/fi-fi.ts b/client/src/locale/fi-fi.ts
index c7b95b30..e5be7835 100644
--- a/client/src/locale/fi-fi.ts
+++ b/client/src/locale/fi-fi.ts
@@ -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'
+}
diff --git a/client/src/locale/fr-fr.ts b/client/src/locale/fr-fr.ts
index 7a7c9c67..34945fcc 100644
--- a/client/src/locale/fr-fr.ts
+++ b/client/src/locale/fr-fr.ts
@@ -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'
+}
diff --git a/client/src/locale/ko-kr.ts b/client/src/locale/ko-kr.ts
index 3ed0ca76..7aa7cbef 100644
--- a/client/src/locale/ko-kr.ts
+++ b/client/src/locale/ko-kr.ts
@@ -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: '업로드할 파일을 여기로 클릭하거나 드래그하세요.'
+}
diff --git a/client/src/locale/nb-no.ts b/client/src/locale/nb-no.ts
index f19e9999..c283dd49 100644
--- a/client/src/locale/nb-no.ts
+++ b/client/src/locale/nb-no.ts
@@ -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'
+}
diff --git a/client/src/locale/ru-ru.ts b/client/src/locale/ru-ru.ts
index ee6f481a..b7cb4ad5 100644
--- a/client/src/locale/ru-ru.ts
+++ b/client/src/locale/ru-ru.ts
@@ -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: 'Нажмите или перетащите сюда файлы для загрузки'
+}
diff --git a/client/src/locale/sk-sk.ts b/client/src/locale/sk-sk.ts
index 93fe5c9c..a2a6625a 100644
--- a/client/src/locale/sk-sk.ts
+++ b/client/src/locale/sk-sk.ts
@@ -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ť'
+}
diff --git a/client/src/locale/sv-se.ts b/client/src/locale/sv-se.ts
index ad9904ab..be831a32 100644
--- a/client/src/locale/sv-se.ts
+++ b/client/src/locale/sv-se.ts
@@ -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'
+}
diff --git a/client/src/locale/zh-cn.ts b/client/src/locale/zh-cn.ts
index 566d1af6..81d2c50c 100644
--- a/client/src/locale/zh-cn.ts
+++ b/client/src/locale/zh-cn.ts
@@ -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: '点击或拖动文件到这里来上传'
+}
diff --git a/client/src/neko/events.ts b/client/src/neko/events.ts
index 7b637a69..d73c1854 100644
--- a/client/src/neko/events.ts
+++ b/client/src/neko/events.ts
@@ -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 =
diff --git a/client/src/neko/index.ts b/client/src/neko/index.ts
index 35f057fb..dc4b5eed 100644
--- a/client/src/neko/index.ts
+++ b/client/src/neko/index.ts
@@ -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 {
})
}
+ 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 {
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
/////////////////////////////
diff --git a/client/src/neko/messages.ts b/client/src/neko/messages.ts
index 65c3969b..b71d73d3 100644
--- a/client/src/neko/messages.ts
+++ b/client/src/neko/messages.ts
@@ -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
*/
diff --git a/client/src/neko/types.ts b/client/src/neko/types.ts
index a4df6476..31bb44ab 100644
--- a/client/src/neko/types.ts
+++ b/client/src/neko/types.ts
@@ -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 | null,
+ abortController: AbortController | null
+}
diff --git a/client/src/store/files.ts b/client/src/store/files.ts
new file mode 100644
index 00000000..c4122aff
--- /dev/null
+++ b/client/src/store/files.ts
@@ -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()
+ }
+ }
+)
diff --git a/client/src/store/index.ts b/client/src/store/index.ts
index 3abb348b..11bafe2c 100644
--- a/client/src/store/index.ts
+++ b/client/src/store/index.ts
@@ -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)
diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts
index 3d99fad3..f68a4681 100644
--- a/client/src/store/settings.ts
+++ b/client/src/store/settings.ts
@@ -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 })
},
diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md
index 8026d0c1..26a8db2c 100644
--- a/docs/getting-started/configuration.md
+++ b/docs/getting-started/configuration.md
@@ -126,6 +126,19 @@ nat1to1:
- 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")
diff --git a/server/internal/config/websocket.go b/server/internal/config/websocket.go
index 91050185..653411e4 100644
--- a/server/internal/config/websocket.go
+++ b/server/internal/config/websocket.go
@@ -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")
}
diff --git a/server/internal/http/http.go b/server/internal/http/http.go
index 06b08638..9b8a2370 100644
--- a/server/internal/http/http.go
+++ b/server/internal/http/http.go
@@ -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"))
})
diff --git a/server/internal/types/event/events.go b/server/internal/types/event/events.go
index fca67f8a..4b6b047c 100644
--- a/server/internal/types/event/events.go
+++ b/server/internal/types/event/events.go
@@ -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"
diff --git a/server/internal/types/message/messages.go b/server/internal/types/message/messages.go
index 29542be5..27a83d6d 100644
--- a/server/internal/types/message/messages.go
+++ b/server/internal/types/message/messages.go
@@ -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"`
diff --git a/server/internal/types/websocket.go b/server/internal/types/websocket.go
index 968bbb8b..cb80fccd 100644
--- a/server/internal/types/websocket.go
+++ b/server/internal/types/websocket.go
@@ -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"`
}
diff --git a/server/internal/utils/files.go b/server/internal/utils/files.go
new file mode 100644
index 00000000..56c714d7
--- /dev/null
+++ b/server/internal/utils/files.go
@@ -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
+}
diff --git a/server/internal/websocket/handler/files.go b/server/internal/websocket/handler/files.go
new file mode 100644
index 00000000..fb1d1907
--- /dev/null
+++ b/server/internal/websocket/handler/files.go
@@ -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,
+ })
+}
diff --git a/server/internal/websocket/handler/handler.go b/server/internal/websocket/handler/handler.go
index b2b529cb..985f0278 100644
--- a/server/internal/websocket/handler/handler.go
+++ b/server/internal/websocket/handler/handler.go
@@ -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)
diff --git a/server/internal/websocket/state/state.go b/server/internal/websocket/state/state.go
index 3b38797f..3eba130d 100644
--- a/server/internal/websocket/state/state.go
+++ b/server/internal/websocket/state/state.go
@@ -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
+}
diff --git a/server/internal/websocket/websocket.go b/server/internal/websocket/websocket.go
index a4ec5729..022067a2 100644
--- a/server/internal/websocket/websocket.go
+++ b/server/internal/websocket/websocket.go
@@ -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 {