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