diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..306e947d6 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Skill(update-config)", + "Skill(update-config:*)", + "WebSearch", + "Bash(docker pull *)", + "Bash(docker build *)", + "Bash(docker run *)", + "Bash(docker rm *)", + "Bash(curl -s http://localhost:8080/api/stats)", + "Bash(docker exec *)", + "Bash(ipconfig)", + "Bash(docker compose *)", + "Bash(git add *)", + "Bash(git commit *)" + ] + } +} diff --git a/.claude/skills/dev-guide/SKILL.md b/.claude/skills/dev-guide/SKILL.md new file mode 100644 index 000000000..26da6d0ee --- /dev/null +++ b/.claude/skills/dev-guide/SKILL.md @@ -0,0 +1,194 @@ +--- +name: dev-guide +description: Development guide for the neko project. Use when asked to add features, fix bugs, or make changes to the server (Go) or client (Vue 2) codebase. Covers architecture, conventions, build commands, and common patterns. +--- + +# Neko Development Guide + +## Project Overview + +Neko is a self-hosted virtual browser/desktop sharing system. It streams a Docker container's X11 desktop to multiple users via WebRTC, with bidirectional mouse/keyboard control through WebRTC DataChannels. + +## Repository Structure + +``` +neko/ +├── server/ # Go backend +│ ├── cmd/ # CLI entrypoints (cobra) +│ ├── internal/ # Private packages +│ │ ├── api/ # HTTP REST API (chi router) +│ │ ├── capture/ # GStreamer screen capture +│ │ ├── desktop/ # X11/Xorg input injection +│ │ ├── http/ # HTTP server & WebSocket +│ │ ├── member/ # Auth providers +│ │ ├── plugins/ # Plugin system +│ │ ├── session/ # Session management +│ │ └── webrtc/ # WebRTC (pion) +│ └── pkg/types/ # Shared interfaces +├── client/ # Vue 2 frontend +│ └── src/ +│ ├── components/ +│ ├── store/ # Vuex modules +│ └── api/ # Axios API client +├── apps/ # Per-browser Docker configs +│ ├── firefox/ +│ ├── chromium/ +│ └── ... +└── webpage/ # Docusaurus docs site +``` + +## Tech Stack + +### Backend (Go 1.25) +- **Router**: go-chi/chi v5 +- **WebRTC**: pion/webrtc v4 (pure Go) +- **WebSocket**: gorilla/websocket +- **Config**: spf13/viper + cobra +- **Logging**: rs/zerolog (structured, use `log.Info().Str("key", val).Msg("...")`) +- **Metrics**: prometheus/client_golang +- **Desktop**: X11/Xorg via CGo bindings +- **Capture**: GStreamer via CGo bindings + +### Frontend (TypeScript + Vue 2) +- **Framework**: Vue 2.7 with Class-style components (`vue-class-component` + `vue-property-decorator`) +- **State**: Vuex 3 + typed-vuex +- **Build**: Vue CLI 5 (webpack) +- **Styles**: SCSS + +## Build Commands + +### Server +```bash +cd server +go build ./... # build +go test ./... # run tests +./dev/build # dev build script +./dev/start # start dev server +./dev/fmt # format (gofmt) +./dev/lint # lint +``` + +### Client +```bash +cd client +npm install +npm run serve # dev server with HMR +npm run build # production build +npm run lint # ESLint +``` + +### Docker (full stack) +```bash +docker compose up # uses docker-compose.yaml at root +``` + +## Server Conventions + +### Adding a new API endpoint + +1. Add handler method to the relevant controller in `server/internal/api/` +2. Register the route in `server/internal/api/router.go` +3. Follow the existing pattern — handlers receive `(w http.ResponseWriter, r *http.Request)` and use chi's context for path params + +```go +// Example handler pattern +func (h *RoomHandler) screenGet(w http.ResponseWriter, r *http.Request) { + session := h.sessions.GetSession(r) + // ... logic + utils.HttpSuccess(w, response) +} +``` + +### Adding a new plugin + +Plugins live in `server/internal/plugins//`. Each plugin implements the `types.Plugin` interface: + +```go +type Plugin interface { + Name() string + Start() error + Stop() error +} +``` + +Register in `server/cmd/plugins.go`. + +### Configuration + +All config is in `server/internal/config/`. Each subsystem has its own config struct with viper bindings. Environment variables follow the pattern `NEKO__` (e.g., `NEKO_WEBRTC_EPR`). + +### Logging + +Use zerolog — never `fmt.Println` or `log.Printf`: + +```go +h.logger.Info().Str("session_id", session.ID()).Msg("session connected") +h.logger.Error().Err(err).Msg("failed to process event") +``` + +## Client Conventions + +### Component style + +Use Class-style components with decorators: + +```typescript +@Component({ components: { MyChild } }) +export default class MyComponent extends Vue { + @Prop({ required: true }) readonly value!: string + @Watch('value') onValueChange(val: string) { ... } + + get computed() { return this.$store.state.something } +} +``` + +### Vuex store + +Modules live in `client/src/store/`. Use `typed-vuex` accessors — avoid direct `this.$store.commit()` calls; use the typed accessor instead. + +### API calls + +Use the existing axios client in `client/src/api/`. Don't create new axios instances. + +### Styles + +- SCSS only, no plain CSS files +- Component-scoped styles with ` @@ -271,6 +426,19 @@ import Content from './context.vue' import { FileTransfer, FileListItem } from '~/neko/types' + const IMAGE_EXTS = ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'tiff', 'webp', 'ico'] + const VIDEO_EXTS = ['avi', 'mkv', 'mov', 'mpeg', 'mp4', 'webm'] + const AUDIO_EXTS = ['aac', 'flac', 'mp3', 'ogg', 'wav', 'm4a'] + const DOC_EXTS = ['pdf', 'txt', 'md', 'csv', 'json', 'xml', 'html', 'log', 'yaml', 'yml', + 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'] + const ARCHIVE_EXTS = ['zip', 'rar', '7z', 'gz', 'tar', 'bz2'] + const TEXT_PREVIEW_EXTS = ['txt', 'md', 'csv', 'json', 'xml', 'html', 'log', 'yaml', 'yml', + 'js', 'ts', 'py', 'sh', 'css', 'scss'] + + function getExt(name: string): string { + return (name.split('.').pop() || '').toLowerCase() + } + @Component({ name: 'neko-files', components: { @@ -279,137 +447,137 @@ }, }) 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') + public uploadAreaDrag = false + public renaming: string | null = null + public renameValue = '' + public activeFilter = 'all' + public preview: FileListItem | null = null + public previewUrl = '' + public previewText: string | null = null + public transferAreaCollapsed = false + + readonly filterOptions = [ + { key: 'all', label: '全部', icon: 'fas fa-list' }, + { key: 'image', label: '图片', icon: 'fas fa-image' }, + { key: 'video', label: '视频', icon: 'fas fa-film' }, + { key: 'audio', label: '音频', icon: 'fas fa-music' }, + { key: 'doc', label: '文档', icon: 'fas fa-file-alt' }, + { key: 'archive', label: '压缩包', icon: 'fas fa-archive' }, + { key: 'other', label: '其他', icon: 'fas fa-file' }, + ] + + 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.transfers.filter((t) => t.direction === 'download') } + get uploads() { return this.transfers.filter((t) => t.direction === 'upload') } + + get filteredFiles(): FileListItem[] { + if (this.activeFilter === 'all') return this.files + return this.files.filter((f) => this.fileCategory(f) === this.activeFilter) + } + + fileCategory(file: FileListItem): string { + if (file.type === 'dir') return 'other' + const ext = getExt(file.name) + if (IMAGE_EXTS.includes(ext)) return 'image' + if (VIDEO_EXTS.includes(ext)) return 'video' + if (AUDIO_EXTS.includes(ext)) return 'audio' + if (DOC_EXTS.includes(ext)) return 'doc' + if (ARCHIVE_EXTS.includes(ext)) return 'archive' + return 'other' + } + + isPreviewable(file: FileListItem): boolean { + if (file.type === 'dir') return false + const ext = getExt(file.name) + return IMAGE_EXTS.includes(ext) || VIDEO_EXTS.includes(ext) || + AUDIO_EXTS.includes(ext) || ext === 'pdf' || TEXT_PREVIEW_EXTS.includes(ext) + } + + isImage(file: FileListItem) { return IMAGE_EXTS.includes(getExt(file.name)) } + isVideo(file: FileListItem) { return VIDEO_EXTS.includes(getExt(file.name)) } + isAudio(file: FileListItem) { return AUDIO_EXTS.includes(getExt(file.name)) } + isPdf(file: FileListItem) { return getExt(file.name) === 'pdf' } + + async openPreview(item: FileListItem) { + this.preview = item + this.previewText = null + this.previewUrl = '' + const url = '/file?pwd=' + encodeURIComponent(this.$accessor.password) + + '&filename=' + encodeURIComponent(item.name) + const ext = getExt(item.name) + + if (TEXT_PREVIEW_EXTS.includes(ext)) { + try { + const res = await this.$http.get(url, { responseType: 'text', withCredentials: false }) + this.previewText = res.data + } catch { + this.previewText = '加载失败' + } + } else { + try { + const res = await this.$http.get(url, { responseType: 'blob', withCredentials: false }) + this.previewUrl = URL.createObjectURL(res.data) + } catch { + this.preview = null + } + } } - refresh() { - this.$accessor.files.refresh() - } + refresh() { this.$accessor.files.refresh() } download(item: FileListItem) { - if (this.downloads.map((t) => t.name).includes(item.name)) { - return - } - - const url = - '/file?pwd=' + encodeURIComponent(this.$accessor.password) + '&filename=' + encodeURIComponent(item.name) + if (this.downloads.map((t) => t.name).includes(item.name)) return + const url = '/file?pwd=' + encodeURIComponent(this.$accessor.password) + + '&filename=' + encodeURIComponent(item.name) const abortController = new AbortController() - - let transfer: FileTransfer = { + const transfer: FileTransfer = { id: Math.round(Math.random() * 10000), - name: item.name, - direction: 'download', - // this may be smaller than the actual transfer amount, but for large files the - // content length is not sent (chunked transfer) - size: item.size, - progress: 0, - status: 'pending', - abortController: abortController, + name: item.name, direction: 'download', + size: item.size, progress: 0, status: 'pending', abortController, } - - this.$http - .get(url, { - responseType: 'blob', - signal: abortController.signal, - withCredentials: false, - onDownloadProgress: (x) => { - transfer.progress = x.loaded - - if (x.total && transfer.size !== x.total) { - transfer.size = x.total - } - if (transfer.progress === transfer.size) { - transfer.status = 'completed' - } else if (transfer.status !== 'inprogress') { - transfer.status = 'inprogress' - } - }, - }) - .then((res) => { - const url = window.URL.createObjectURL(new Blob([res.data])) - const link = document.createElement('a') - link.href = url - link.setAttribute('download', item.name) - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - - transfer.progress = transfer.size - transfer.status = 'completed' - }) - .catch((error) => { - this.$log.error(error) - - transfer.status = 'failed' - transfer.error = error.message - }) - + this.$http.get(url, { + responseType: 'blob', signal: abortController.signal, withCredentials: false, + onDownloadProgress: (x) => { + transfer.progress = x.loaded + if (x.total && transfer.size !== x.total) transfer.size = x.total + transfer.status = transfer.progress === transfer.size ? 'completed' : 'inprogress' + }, + }).then((res) => { + const a = document.createElement('a') + a.href = URL.createObjectURL(new Blob([res.data])) + a.setAttribute('download', item.name) + document.body.appendChild(a); a.click(); document.body.removeChild(a) + transfer.progress = transfer.size; transfer.status = 'completed' + }).catch((error) => { + this.$log.error(error); transfer.status = 'failed'; transfer.error = error.message + }) this.$accessor.files.addTransfer(transfer) } upload(dt: DataTransfer) { const url = '/file?pwd=' + encodeURIComponent(this.$accessor.password) this.uploadAreaDrag = false - for (const file of dt.files) { const abortController = new AbortController() - const formdata = new FormData() formdata.append('files', file, file.name) - - let transfer: FileTransfer = { + const transfer: FileTransfer = { id: Math.round(Math.random() * 10000), - name: file.name, - direction: 'upload', - size: file.size, - progress: 0, - status: 'pending', - abortController: abortController, + name: file.name, direction: 'upload', + size: file.size, progress: 0, status: 'pending', abortController, } - - this.$http - .post(url, formdata, { - signal: abortController.signal, - withCredentials: false, - onUploadProgress: (x: any) => { - transfer.progress = x.loaded - - if (transfer.size !== x.total) { - transfer.size = x.total - } - if (transfer.progress === transfer.size) { - transfer.status = 'completed' - } else if (transfer.status !== 'inprogress') { - transfer.status = 'inprogress' - } - }, - }) - .catch((error) => { - this.$log.error(error) - - transfer.status = 'failed' - transfer.error = error.message - }) - + this.$http.post(url, formdata, { + signal: abortController.signal, withCredentials: false, + onUploadProgress: (x: any) => { + transfer.progress = x.loaded + if (transfer.size !== x.total) transfer.size = x.total + transfer.status = transfer.progress === transfer.size ? 'completed' : 'inprogress' + }, + }).catch((error) => { + this.$log.error(error); transfer.status = 'failed'; transfer.error = error.message + }) this.$accessor.files.addTransfer(transfer) } } @@ -419,102 +587,73 @@ input.type = 'file' input.setAttribute('multiple', 'true') input.onchange = (e: Event) => { - if (e === null) return - + if (!e) return const dt = new DataTransfer() const target = e.target as HTMLInputElement - if (target.files === null) return - - for (const f of target.files) { - dt.items.add(f) - } - + if (!target.files) return + for (const f of target.files) dt.items.add(f) this.upload(dt) } input.click() } removeTransfer(transfer: FileTransfer) { - if (transfer.status !== 'completed') { - transfer.abortController?.abort() - } + if (transfer.status !== 'completed') transfer.abortController?.abort() this.$accessor.files.removeTransfer(transfer) } + async deleteFile(item: FileListItem) { + const url = '/file?pwd=' + encodeURIComponent(this.$accessor.password) + + '&filename=' + encodeURIComponent(item.name) + try { await this.$http.delete(url, { withCredentials: false }) } + catch (err: any) { this.$log.error(err) } + } + + startRename(item: FileListItem) { + this.renaming = item.name + this.renameValue = item.name + this.$nextTick(() => { + const input = this.$refs.renameInput as HTMLInputElement | HTMLInputElement[] + const el = Array.isArray(input) ? input[0] : input + if (el) { el.focus(); el.select() } + }) + } + + async confirmRename(item: FileListItem) { + const newName = this.renameValue.trim() + if (!newName || newName === item.name) { this.renaming = null; return } + const url = '/file?pwd=' + encodeURIComponent(this.$accessor.password) + + '&filename=' + encodeURIComponent(item.name) + try { + await this.$http.patch(url, { new_name: newName }, { withCredentials: false }) + this.renaming = null + } catch (err: any) { this.$log.error(err); this.renaming = null } + } + fileIcon(file: FileListItem) { - let className = 'file-icon fas ' - // if is directory - if (file.type === 'dir') { - className += 'fa-folder' - return className - } - // try to get file extension - const ext = file.name.split('.').pop() - if (ext === undefined) { - className += 'fa-file' - return className + const cls = 'file-icon fas ' + if (file.type === 'dir') return cls + 'fa-folder' + const ext = getExt(file.name) + if (IMAGE_EXTS.includes(ext)) return cls + 'fa-image' + if (VIDEO_EXTS.includes(ext)) return cls + 'fa-film' + if (AUDIO_EXTS.includes(ext)) return cls + 'fa-music' + if (ARCHIVE_EXTS.includes(ext)) return cls + 'fa-archive' + switch (ext) { + case 'pdf': return cls + 'fa-file-pdf' + case 'txt': case 'md': case 'log': return cls + 'fa-file-text' + case 'doc': case 'docx': return cls + 'fa-file-word' + case 'xls': case 'xlsx': return cls + 'fa-file-excel' + case 'ppt': case 'pptx': return cls + 'fa-file-powerpoint' + default: return cls + 'fa-file' } - // try to find icon - switch (ext.toLowerCase()) { - case 'txt': - case 'md': - className += 'fa-file-text' - break - case 'pdf': - className += 'fa-file-pdf' - break - case 'zip': - case 'rar': - case '7z': - case 'gz': - className += 'fa-archive' - break - case 'aac': - case 'flac': - case 'midi': - case 'mp3': - case 'ogg': - case 'wav': - className += 'fa-music' - break - case 'avi': - case 'mkv': - case 'mov': - case 'mpeg': - case 'mp4': - case 'webm': - className += 'fa-film' - break - case 'bmp': - case 'gif': - case 'jpeg': - case 'jpg': - case 'png': - case 'svg': - case 'tiff': - case 'webp': - className += 'fa-image' - break - default: - className += 'fa-file' - } - return className } fileSize(size: number) { - if (size < 1024) { - return size + ' B' - } - if (size < 1024 * 1024) { - return Math.round(size / 1024) + ' KB' - } - if (size < 1024 * 1024 * 1024) { - return Math.round(size / (1024 * 1024)) + ' MB' - } - if (size < 1024 * 1024 * 1024 * 1024) { - return Math.round(size / (1024 * 1024 * 1024)) + ' GB' - } - return Math.round(size / (1024 * 1024 * 1024 * 1024)) + ' TB' + if (size < 1024) return size + ' B' + if (size < 1024 ** 2) return Math.round(size / 1024) + ' KB' + if (size < 1024 ** 3) return Math.round(size / 1024 ** 2) + ' MB' + if (size < 1024 ** 4) return Math.round(size / 1024 ** 3) + ' GB' + return Math.round(size / 1024 ** 4) + ' TB' } } diff --git a/client/src/components/resolution.vue b/client/src/components/resolution.vue index f4bb16a1e..28f809832 100644 --- a/client/src/components/resolution.vue +++ b/client/src/components/resolution.vue @@ -11,6 +11,47 @@ {{ conf.rate }} +
  • + + 适应窗口大小 + {{ windowSize }} +
  • +
  • + + + x + + @ + + +
  • @@ -35,23 +76,14 @@ scrollbar-width: thin; scrollbar-color: $background-secondary transparent; - &::-webkit-scrollbar { - width: 8px; - } - - &::-webkit-scrollbar-track { - background-color: transparent; - } - + &::-webkit-scrollbar { width: 8px; } + &::-webkit-scrollbar-track { background-color: transparent; } &::-webkit-scrollbar-thumb { background-color: $background-secondary; border: 2px solid $background-floating; border-radius: 4px; } - - &::-webkit-scrollbar-thumb:hover { - background-color: $background-floating; - } + &::-webkit-scrollbar-thumb:hover { background-color: $background-floating; } > li { margin: 0; @@ -63,14 +95,8 @@ cursor: pointer; border-radius: 3px; - i { - margin-right: 10px; - } - - span { - flex-grow: 1; - } - + i { margin-right: 10px; } + span { flex-grow: 1; } small { font-size: 0.7em; justify-self: flex-end; @@ -85,13 +111,67 @@ color: $interactive-hover; } - &:focus { - outline: 0; - } + &:focus { outline: 0; } + } + + &:focus { outline: 0; } + + .fit-row { + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin-top: 4px; + padding-top: 8px; + } + + .custom-row { + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin-top: 4px; + padding-top: 8px; + cursor: default; + gap: 4px; + align-items: center; + + &:hover { background-color: transparent; } + + i { flex-shrink: 0; } } - &:focus { - outline: 0; + .custom-sep { + flex-grow: 0; + color: rgba(255, 255, 255, 0.4); + font-size: 0.85em; + } + + .custom-input { + width: 52px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 3px; + color: inherit; + font-size: 0.8em; + padding: 2px 4px; + outline: none; + text-align: center; + -moz-appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { -webkit-appearance: none; } + + &:focus { border-color: rgba(255, 255, 255, 0.5); } + } + + .custom-input-rate { width: 38px; } + + .custom-btn { + background: rgba(114, 137, 218, 0.6); + border: none; + border-radius: 3px; + color: #fff; + cursor: pointer; + font-size: 0.85em; + padding: 2px 6px; + flex-shrink: 0; + + &:hover { background: rgba(114, 137, 218, 0.9); } } } @@ -112,28 +192,45 @@ export default class extends Vue { @Ref('context') readonly context!: VueContext - get width() { - return this.$accessor.video.width - } + public customWidth: number | string = '' + public customHeight: number | string = '' + public customRate: number | string = '' - get height() { - return this.$accessor.video.height - } - - get rate() { - return this.$accessor.video.rate - } + get width() { return this.$accessor.video.width } + get height() { return this.$accessor.video.height } + get rate() { return this.$accessor.video.rate } + get configurations() { return this.$accessor.video.configurations } - get configurations() { - return this.$accessor.video.configurations + get windowSize() { + return `${Math.floor(window.innerWidth / 2) * 2}x${Math.floor(window.innerHeight / 2) * 2}` } open(event: MouseEvent) { + // Pre-fill with current resolution + this.customWidth = this.width + this.customHeight = this.height + this.customRate = this.rate this.context.open(event) } + fitToWindow() { + const w = Math.floor(window.innerWidth / 2) * 2 // ensure even number + const h = Math.floor(window.innerHeight / 2) * 2 + this.$accessor.video.screenSet({ width: w, height: h, rate: this.rate }) + this.context.close() + } + screenSet(resolution: ScreenResolution) { this.$accessor.video.screenSet(resolution) } + + applyCustom() { + const w = parseInt(String(this.customWidth), 10) + const h = parseInt(String(this.customHeight), 10) + const r = parseInt(String(this.customRate), 10) + if (!w || !h || !r || w < 320 || h < 240 || r < 1) return + this.$accessor.video.screenSet({ width: w, height: h, rate: r }) + this.context.close() + } } diff --git a/client/src/components/video.vue b/client/src/components/video.vue index 051493e1b..7666d10de 100644 --- a/client/src/components/video.vue +++ b/client/src/components/video.vue @@ -28,7 +28,14 @@ @touchend.stop.prevent="onTouchHandler" @compositionstart="onCompositionStartHandler" @compositionend="onCompositionEndHandler" + @paste.stop.prevent="onPaste" + @dragover.stop.prevent="onDragOver" + @dragleave.stop.prevent="onDragLeave" + @drop.stop.prevent="onDrop" /> +
    + +
    @@ -37,8 +44,12 @@
    -
      -
    • +
        +
      • + +
      • +
      -
        +
        • @@ -201,6 +212,25 @@ resize: none; } + .drop-overlay { + position: absolute; + top: 0; + bottom: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: rgba($color: #000, $alpha: 0.5); + pointer-events: none; + + i::before { + font-size: 120px; + text-align: center; + color: rgba($color: #fff, $alpha: 0.8); + } + } + .player-aspect { display: block; padding-bottom: 56.25%; @@ -253,6 +283,12 @@ private fullscreen = false private mutedOverlay = true private lastTextAreaValue = '' + public dropOverlay = false + private _ctrlHeld = false + + get webFullscreen() { + return this.$accessor.client.webFullscreen + } get admin() { return this.$accessor.user.admin @@ -459,7 +495,7 @@ @Watch('clipboard') async onClipboardChanged(clipboard: string) { - if (this.clipboard_write_available) { + if (this.clipboard_write_available && window.document.hasFocus()) { try { await navigator.clipboard.writeText(clipboard) this.$accessor.remote.setClipboard(clipboard) @@ -521,6 +557,18 @@ return true } + // Track Ctrl key state + if (key === this.KeyTable.XK_Control_L || key === this.KeyTable.XK_Control_R) { + this._ctrlHeld = true + } + + // Let Ctrl+V pass through so the paste event fires and we can + // intercept clipboard files/images in onPaste + if (key === 0x76 && this._ctrlHeld) { + this.$client.sendData('keydown', { key: this.keyMap(key) }) + return true // don't preventDefault — allow paste event + } + this.$client.sendData('keydown', { key: this.keyMap(key) }) return false } @@ -529,6 +577,11 @@ return } + // Track Ctrl key state + if (key === this.KeyTable.XK_Control_L || key === this.KeyTable.XK_Control_R) { + this._ctrlHeld = false + } + this.$client.sendData('keyup', { key: this.keyMap(key) }) } this.keyboard.listenTo(this._overlay) @@ -639,6 +692,16 @@ this.$accessor.remote.request() } + requestWebFullscreen() { + this.$accessor.client.setWebFullscreen(true) + this.onResize() + } + + exitWebFullscreen() { + this.$accessor.client.setWebFullscreen(false) + this.onResize() + } + requestFullscreen() { // try to fullscreen player element if (elementRequestFullscreen(this._player)) { @@ -837,6 +900,135 @@ } } + async onPaste(e: ClipboardEvent) { + if (!this.hosting || this.locked) return + + const items = e.clipboardData?.items + if (!items) return + + this.$log.info(`paste event: ${items.length} items`) + for (let i = 0; i < items.length; i++) { + this.$log.info(` item[${i}]: kind=${items[i].kind} type=${items[i].type}`) + } + + const files: File[] = [] + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (item.kind === 'file') { + const file = item.getAsFile() + if (!file) continue + + const now = new Date() + const ts = now.getFullYear().toString() + + String(now.getMonth() + 1).padStart(2, '0') + + String(now.getDate()).padStart(2, '0') + + String(now.getHours()).padStart(2, '0') + + String(now.getMinutes()).padStart(2, '0') + + String(now.getSeconds()).padStart(2, '0') + + let name: string + if (item.type.startsWith('image/')) { + const ext = item.type.split('/')[1].replace('jpeg', 'jpg') + name = `img_${ts}.${ext}` + } else if (item.type.startsWith('video/')) { + const ext = item.type.split('/')[1] + name = `vid_${ts}.${ext}` + } else if (item.type.startsWith('audio/')) { + const ext = item.type.split('/')[1] + name = `aud_${ts}.${ext}` + } else { + // For documents and other files, keep original name but append timestamp before extension + const mimeToExt: Record = { + 'application/pdf': 'pdf', + 'text/plain': 'txt', + 'text/csv': 'csv', + 'text/html': 'html', + 'application/msword': 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + 'application/vnd.ms-excel': 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', + 'application/vnd.ms-powerpoint': 'ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx', + 'application/zip': 'zip', + 'application/x-rar-compressed': 'rar', + 'application/x-7z-compressed': '7z', + 'application/json': 'json', + 'application/xml': 'xml', + 'text/xml': 'xml', + } + const knownExt = mimeToExt[item.type] + if (knownExt) { + name = `doc_${ts}.${knownExt}` + } else if (file.name && file.name.includes('.')) { + const ext = file.name.split('.').pop() + const base = file.name.replace(/\.[^.]+$/, '') + name = `${base}_${ts}.${ext}` + } else { + name = `file_${ts}.bin` + } + } + + files.push(new File([file], name, { type: item.type })) + } + } + + if (files.length === 0) return + + const pwd = encodeURIComponent(this.$accessor.password) + for (const file of files) { + const formData = new FormData() + formData.append('files', file, file.name) + try { + await this.$http.post('/file?pwd=' + pwd, formData, { withCredentials: false }) + this.$log.info(`uploaded ${file.name} to downloads`) + } catch (err: any) { + this.$log.error(err) + } + } + } + + onDragOver(e: DragEvent) { + if (!this.hosting || this.locked) return + if (e.dataTransfer?.types.includes('Files')) { + e.dataTransfer.dropEffect = 'copy' + this.dropOverlay = true + } + } + + onDragLeave() { + this.dropOverlay = false + } + + async onDrop(e: DragEvent) { + this.dropOverlay = false + if (!this.hosting || this.locked) return + + const files = e.dataTransfer?.files + if (!files || files.length === 0) return + + const { w, h } = this.$accessor.video.resolution + const rect = this._overlay.getBoundingClientRect() + const x = Math.round((w / rect.width) * (e.clientX - rect.left)) + const y = Math.round((h / rect.height) * (e.clientY - rect.top)) + + const formData = new FormData() + formData.append('x', String(x)) + formData.append('y', String(y)) + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i], files[i].name) + } + + try { + await this.$http.post( + '/upload/drop?pwd=' + encodeURIComponent(this.$accessor.password), + formData, + { withCredentials: false }, + ) + } catch (err: any) { + this.$log.error(err) + } + } + onMouseMove(e: MouseEvent) { if (!this.hosting || this.locked) { return @@ -873,7 +1065,7 @@ } onResize() { - const { offsetWidth, offsetHeight } = !this.fullscreen ? this._component : document.body + const { offsetWidth, offsetHeight } = (this.fullscreen || this.webFullscreen) ? document.body : this._component this._player.style.width = `${offsetWidth}px` this._player.style.height = `${offsetHeight}px` this._container.style.maxWidth = `${(this.horizontal / this.vertical) * offsetHeight}px` @@ -883,6 +1075,7 @@ @Watch('focused') @Watch('hosting') @Watch('locked') + @Watch('webFullscreen') onFocus() { // focus opens the keyboard on mobile if (this.is_touch_device) { diff --git a/client/src/locale/en-us.ts b/client/src/locale/en-us.ts index 47f3c47ef..7bc8b57cd 100644 --- a/client/src/locale/en-us.ts +++ b/client/src/locale/en-us.ts @@ -127,4 +127,11 @@ export const files = { downloads: 'Downloads', uploads: 'Uploads', upload_here: 'Click or drag files here to upload', + download: 'Download', + rename: 'Rename', + delete: 'Delete', + preview: 'Preview', + no_files: 'No files', + preview_unsupported: 'Preview not supported for this file type', + transfers: 'Transfers', } diff --git a/client/src/locale/zh-cn.ts b/client/src/locale/zh-cn.ts index 7bcc05499..e83d58b26 100644 --- a/client/src/locale/zh-cn.ts +++ b/client/src/locale/zh-cn.ts @@ -124,4 +124,10 @@ export const files = { downloads: '下载', uploads: '上传', upload_here: '点击或拖动文件到此处上传', + download: '下载', + rename: '重命名', + delete: '删除', + preview: '预览', + no_files: '暂无文件', + preview_unsupported: '该文件类型不支持预览', } diff --git a/client/src/neko/base.ts b/client/src/neko/base.ts index 8ea87659f..bc5b8d935 100644 --- a/client/src/neko/base.ts +++ b/client/src/neko/base.ts @@ -192,6 +192,11 @@ export abstract class BaseClient extends EventEmitter { return } + if (!this._channel || this._channel.readyState !== 'open') { + this.emit('warn', `attempting to send data while data channel is not open`) + return + } + let buffer: ArrayBuffer let payload: DataView switch (event) { diff --git a/client/src/neko/index.ts b/client/src/neko/index.ts index d40fa568b..4e6d47b5c 100644 --- a/client/src/neko/index.ts +++ b/client/src/neko/index.ts @@ -4,6 +4,7 @@ import { BaseClient, BaseEvents } from './base' import { Member } from './types' import { EVENT } from './events' import { accessor } from '~/store' +import { get } from '~/utils/localstorage' import { SystemMessagePayload, @@ -104,6 +105,16 @@ export class NekoClient extends BaseClient implements EventEmitter { duration: 5000, speed: 1000, }) + + // Restore saved resolution for admins + if (this.$accessor.user.admin) { + const w = get('screen_width', 0) + const h = get('screen_height', 0) + const r = get('screen_rate', 0) + if (w > 0 && h > 0 && r > 0) { + this.$accessor.video.screenSet({ width: w, height: h, rate: r }) + } + } } protected [EVENT.DISCONNECTED](reason?: Error) { diff --git a/client/src/store/client.ts b/client/src/store/client.ts index 7d63baa53..4157501a4 100644 --- a/client/src/store/client.ts +++ b/client/src/store/client.ts @@ -8,6 +8,7 @@ export const state = () => ({ tab: get('tab', 'chat'), about: false, about_page: '', + webFullscreen: false, }) export const getters = getterTree(state, {}) @@ -31,6 +32,9 @@ export const mutations = mutationTree(state, { state.side = side set('side', side) }, + setWebFullscreen(state, val: boolean) { + state.webFullscreen = val + }, }) export const actions = actionTree({ state, getters, mutations }, {}) diff --git a/client/src/store/video.ts b/client/src/store/video.ts index 9959500ec..b0f3ffbe0 100644 --- a/client/src/store/video.ts +++ b/client/src/store/video.ts @@ -190,6 +190,10 @@ export const actions = actionTree( return } + set('screen_width', resolution.width) + set('screen_height', resolution.height) + set('screen_rate', resolution.rate) + $client.sendMessage(EVENT.SCREEN.SET, resolution) }, }, diff --git a/config.yml b/config.yml index 8cb20f2c3..54bc50a79 100644 --- a/config.yml +++ b/config.yml @@ -1,5 +1,7 @@ desktop: screen: "1920x1080@60" + upload_drop: true + file_chooser_dialog: true member: # needed for legacy API ad there is no user management @@ -7,11 +9,19 @@ member: multiuser: admin_password: "admin" user_password: "neko" + user_profile: | + { + "can_login": true, + "can_connect": true, + "can_watch": true, + "can_host": true, + "can_share_media": true, + "can_access_clipboard": true + } session: merciful_reconnect: true - # default setting for legacy API - implicit_hosting: false + implicit_hosting: true cookie: # needed for legacy API enabled: false diff --git a/docker-compose.nvidia.yaml b/docker-compose.nvidia.yaml new file mode 100644 index 000000000..7cdcd699d --- /dev/null +++ b/docker-compose.nvidia.yaml @@ -0,0 +1,44 @@ +services: + neko: + image: neko-chrome-nvidia-dev + build: + context: . + dockerfile: Dockerfile.nvidia-dev + container_name: neko-chrome-nvidia-dev + restart: unless-stopped + shm_size: "${SHM_SIZE:-2gb}" + ports: + - "${HOST_PORT:-8080}:8080" + - "${NEKO_WEBRTC_EPR:-52000-52100}:${NEKO_WEBRTC_EPR:-52000-52100}/udp" + environment: + - NEKO_DESKTOP_SCREEN=${NEKO_DESKTOP_SCREEN} + - NEKO_MEMBER_MULTIUSER_USER_PASSWORD=${NEKO_USER_PASSWORD} + - NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=${NEKO_ADMIN_PASSWORD} + - NEKO_WEBRTC_EPR=${NEKO_WEBRTC_EPR} + - NEKO_WEBRTC_ICELITE=${NEKO_WEBRTC_ICELITE} + - NEKO_NAT1TO1=${NEKO_NAT1TO1} + - NEKO_FILETRANSFER_ENABLED=true + - NEKO_FILETRANSFER_DIR=/home/neko/Downloads + - NEKO_WEBRTC_VIDEO_CODEC=${NEKO_VIDEO_CODEC} + - NEKO_WEBRTC_VIDEO_BITRATE=${NEKO_VIDEO_BITRATE} + - NEKO_WEBRTC_MAX_FPS=${NEKO_MAX_FPS} + - NEKO_WEBRTC_AUDIO_CODEC=${NEKO_AUDIO_CODEC} + - NEKO_WEBRTC_AUDIO_BITRATE=${NEKO_AUDIO_BITRATE} + # HTTP Proxy + - HTTP_PROXY=${HTTP_PROXY:-""} + - HTTPS_PROXY=${HTTPS_PROXY:-""} + - NO_PROXY=${NO_PROXY:-""} + # CUDA / NVIDIA + - NVIDIA_VISIBLE_DEVICES=${NVIDIA_VISIBLE_DEVICES:-all} + - NVIDIA_DRIVER_CAPABILITIES=${NVIDIA_DRIVER_CAPABILITIES:-all} + - VGL_DISPLAY=${VGL_DISPLAY:-egl} + + volumes: + - ./data/downloads:/home/neko/Downloads + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] diff --git a/docker-compose.yaml b/docker-compose.yaml index 5d7860df3..1f5016af5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,16 +1,29 @@ services: neko: - image: "ghcr.io/m1k1o/neko/firefox:latest" - restart: "unless-stopped" - shm_size: "2gb" + image: neko-chrome-dev + container_name: neko-chrome-dev + restart: unless-stopped + shm_size: "${SHM_SIZE:-2gb}" ports: - - "8080:8080" - - "52000-52100:52000-52100/udp" + - "${HOST_PORT:-8080}:8080" + - "${NEKO_WEBRTC_EPR:-52000-52100}:${NEKO_WEBRTC_EPR:-52000-52100}/udp" environment: - NEKO_DESKTOP_SCREEN: 1920x1080@30 - NEKO_MEMBER_MULTIUSER_USER_PASSWORD: neko - NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD: admin - NEKO_WEBRTC_EPR: 52000-52100 - NEKO_WEBRTC_ICELITE: 1 - # See: https://neko.m1k1o.net/docs/v3/configuration/webrtc#ip - # NEKO_NAT1TO1: + - NEKO_DESKTOP_SCREEN=${NEKO_DESKTOP_SCREEN} + - NEKO_MEMBER_MULTIUSER_USER_PASSWORD=${NEKO_USER_PASSWORD} + - NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=${NEKO_ADMIN_PASSWORD} + - NEKO_WEBRTC_EPR=${NEKO_WEBRTC_EPR} + - NEKO_WEBRTC_ICELITE=${NEKO_WEBRTC_ICELITE} + - NEKO_NAT1TO1=${NEKO_NAT1TO1} + - NEKO_FILETRANSFER_ENABLED=true + - NEKO_FILETRANSFER_DIR=/home/neko/Downloads + - NEKO_WEBRTC_VIDEO_CODEC=${NEKO_VIDEO_CODEC} + - NEKO_WEBRTC_VIDEO_BITRATE=${NEKO_VIDEO_BITRATE} + - NEKO_WEBRTC_MAX_FPS=${NEKO_MAX_FPS} + - NEKO_WEBRTC_AUDIO_CODEC=${NEKO_AUDIO_CODEC} + - NEKO_WEBRTC_AUDIO_BITRATE=${NEKO_AUDIO_BITRATE} + # HTTP Proxy + - HTTP_PROXY=${HTTP_PROXY:-""} + - HTTPS_PROXY=${HTTPS_PROXY:-""} + - NO_PROXY=${NO_PROXY:-""} + volumes: + - ./data/downloads:/home/neko/Downloads diff --git a/server/internal/api/room/clipboard.go b/server/internal/api/room/clipboard.go index a422cb136..68cb61388 100644 --- a/server/internal/api/room/clipboard.go +++ b/server/internal/api/room/clipboard.go @@ -1,11 +1,9 @@ package room import ( - // TODO: Unused now. - //"bytes" - //"strings" - + "bytes" "net/http" + "strings" "github.com/m1k1o/neko/server/pkg/types" "github.com/m1k1o/neko/server/pkg/utils" @@ -59,7 +57,18 @@ func (h *RoomHandler) clipboardGetImage(w http.ResponseWriter, r *http.Request) return err } -/* TODO: Unused now. +/* TODO: Refactor. If there would be implemented custom target +retrieval, this endpoint would be useful. +func (h *RoomHandler) clipboardGetTargets(w http.ResponseWriter, r *http.Request) error { + targets, err := h.desktop.ClipboardGetTargets() + if err != nil { + return utils.HttpInternalServerError().WithInternalErr(err) + } + + return utils.HttpSuccess(w, targets) +} +*/ + func (h *RoomHandler) clipboardSetImage(w http.ResponseWriter, r *http.Request) error { err := r.ParseMultipartForm(multipartFormMaxMemory) if err != nil { @@ -94,14 +103,3 @@ func (h *RoomHandler) clipboardSetImage(w http.ResponseWriter, r *http.Request) return utils.HttpSuccess(w) } - -func (h *RoomHandler) clipboardGetTargets(w http.ResponseWriter, r *http.Request) error { - targets, err := h.desktop.ClipboardGetTargets() - if err != nil { - return utils.HttpInternalServerError().WithInternalErr(err) - } - - return utils.HttpSuccess(w, targets) -} - -*/ diff --git a/server/internal/api/room/handler.go b/server/internal/api/room/handler.go index 3c33a33fa..c79615dbb 100644 --- a/server/internal/api/room/handler.go +++ b/server/internal/api/room/handler.go @@ -70,15 +70,7 @@ func (h *RoomHandler) Route(r types.Router) { r.Get("/", h.clipboardGetText) r.Post("/", h.clipboardSetText) r.Get("/image.png", h.clipboardGetImage) - - // TODO: Refactor. xclip is failing to set propper target type - // and this content is sent back to client as text in another - // clipboard update. Therefore endpoint is not usable! - //r.Post("/image", h.clipboardSetImage) - - // TODO: Refactor. If there would be implemented custom target - // retrieval, this endpoint would be useful. - //r.Get("/targets", h.clipboardGetTargets) + r.Post("/image.png", h.clipboardSetImage) }) r.With(auth.CanHostOnly).Route("/keyboard", func(r types.Router) { diff --git a/server/internal/http/legacy/handler.go b/server/internal/http/legacy/handler.go index 263f70fd6..8c78dbfc7 100644 --- a/server/internal/http/legacy/handler.go +++ b/server/internal/http/legacy/handler.go @@ -388,6 +388,106 @@ func (h *LegacyHandler) Route(r types.Router) { return err }) + r.Post("/clipboard/image", func(w http.ResponseWriter, r *http.Request) error { + if h.isBanned(r) { + return utils.HttpForbidden("banned ip") + } + + s := h.newSession(r) + + username := r.URL.Query().Get("usr") + password := r.URL.Query().Get("pwd") + err := s.create(username, password) + if err != nil { + return utils.HttpForbidden(err.Error()) + } + defer s.destroy() + + body, _, err := s.req(http.MethodPost, "/api/room/clipboard/image.png", r.Header, r.Body) + if err != nil { + return utils.HttpInternalServerError().WithInternalErr(err) + } + + _, err = io.Copy(w, body) + return err + }) + + r.Post("/upload/drop", func(w http.ResponseWriter, r *http.Request) error { + if h.isBanned(r) { + return utils.HttpForbidden("banned ip") + } + + s := h.newSession(r) + + username := r.URL.Query().Get("usr") + password := r.URL.Query().Get("pwd") + err := s.create(username, password) + if err != nil { + return utils.HttpForbidden(err.Error()) + } + defer s.destroy() + + body, _, err := s.req(http.MethodPost, "/api/room/upload/drop", r.Header, r.Body) + if err != nil { + return utils.HttpInternalServerError().WithInternalErr(err) + } + + _, err = io.Copy(w, body) + return err + }) + + r.Delete("/file", func(w http.ResponseWriter, r *http.Request) error { + if h.isBanned(r) { + return utils.HttpForbidden("banned ip") + } + + s := h.newSession(r) + + username := r.URL.Query().Get("usr") + password := r.URL.Query().Get("pwd") + err := s.create(username, password) + if err != nil { + return utils.HttpForbidden(err.Error()) + } + defer s.destroy() + + filename := r.URL.Query().Get("filename") + + body, _, err := s.req(http.MethodDelete, "/api/filetransfer?filename="+url.QueryEscape(filename), r.Header, nil) + if err != nil { + return utils.HttpInternalServerError().WithInternalErr(err) + } + + _, err = io.Copy(w, body) + return err + }) + + r.Patch("/file", func(w http.ResponseWriter, r *http.Request) error { + if h.isBanned(r) { + return utils.HttpForbidden("banned ip") + } + + s := h.newSession(r) + + username := r.URL.Query().Get("usr") + password := r.URL.Query().Get("pwd") + err := s.create(username, password) + if err != nil { + return utils.HttpForbidden(err.Error()) + } + defer s.destroy() + + filename := r.URL.Query().Get("filename") + + body, _, err := s.req(http.MethodPatch, "/api/filetransfer?filename="+url.QueryEscape(filename), r.Header, r.Body) + if err != nil { + return utils.HttpInternalServerError().WithInternalErr(err) + } + + _, err = io.Copy(w, body) + return err + }) + r.Get("/health", func(w http.ResponseWriter, r *http.Request) error { _, err := w.Write([]byte("true")) return err diff --git a/server/internal/plugins/filetransfer/manager.go b/server/internal/plugins/filetransfer/manager.go index e805b35a1..669fbd12f 100644 --- a/server/internal/plugins/filetransfer/manager.go +++ b/server/internal/plugins/filetransfer/manager.go @@ -210,6 +210,8 @@ func (m *Manager) Shutdown() error { func (m *Manager) Route(r types.Router) { r.With(auth.AdminsOnly).Get("/", m.downloadFileHandler) r.With(auth.AdminsOnly).Post("/", m.uploadFileHandler) + r.With(auth.AdminsOnly).Delete("/", m.deleteFileHandler) + r.With(auth.AdminsOnly).Patch("/", m.renameFileHandler) } func (m *Manager) WebSocketHandler(session types.Session, msg types.WebSocketMessage) bool { @@ -331,3 +333,100 @@ func (m *Manager) uploadFileHandler(w http.ResponseWriter, r *http.Request) erro return nil } + +func (m *Manager) deleteFileHandler(w http.ResponseWriter, r *http.Request) error { + session, ok := auth.GetSession(r) + if !ok { + return utils.HttpUnauthorized("session not found") + } + + enabled, err := m.isEnabledForSession(session) + if err != nil { + return utils.HttpInternalServerError(). + WithInternalErr(err). + Msg("error checking file transfer permissions") + } + + if !enabled { + return utils.HttpForbidden("file transfer is disabled") + } + + filename := r.URL.Query().Get("filename") + badChars, err := regexp.MatchString(`(?m)\.\.(?:\/|$)`, filename) + if filename == "" || badChars || err != nil { + return utils.HttpBadRequest(). + WithInternalErr(err). + Msg("bad filename") + } + + filename = filepath.Clean(filename) + filename = filepath.Base(filename) + filePath := filepath.Join(m.config.RootDir, filename) + + if err := os.Remove(filePath); err != nil { + return utils.HttpInternalServerError(). + WithInternalErr(err). + Msg("error deleting file") + } + + return utils.HttpSuccess(w) +} + +type RenamePayload struct { + NewName string `json:"new_name"` +} + +func (m *Manager) renameFileHandler(w http.ResponseWriter, r *http.Request) error { + session, ok := auth.GetSession(r) + if !ok { + return utils.HttpUnauthorized("session not found") + } + + enabled, err := m.isEnabledForSession(session) + if err != nil { + return utils.HttpInternalServerError(). + WithInternalErr(err). + Msg("error checking file transfer permissions") + } + + if !enabled { + return utils.HttpForbidden("file transfer is disabled") + } + + filename := r.URL.Query().Get("filename") + badChars, err := regexp.MatchString(`(?m)\.\.(?:\/|$)`, filename) + if filename == "" || badChars || err != nil { + return utils.HttpBadRequest(). + WithInternalErr(err). + Msg("bad filename") + } + + filename = filepath.Clean(filename) + filename = filepath.Base(filename) + + data := &RenamePayload{} + if err := utils.HttpJsonRequest(w, r, data); err != nil { + return err + } + + badNewName, err := regexp.MatchString(`(?m)\.\.(?:\/|$)`, data.NewName) + if data.NewName == "" || badNewName || err != nil { + return utils.HttpBadRequest(). + WithInternalErr(err). + Msg("bad new filename") + } + + newName := filepath.Clean(data.NewName) + newName = filepath.Base(newName) + + oldPath := filepath.Join(m.config.RootDir, filename) + newPath := filepath.Join(m.config.RootDir, newName) + + if err := os.Rename(oldPath, newPath); err != nil { + return utils.HttpInternalServerError(). + WithInternalErr(err). + Msg("error renaming file") + } + + return utils.HttpSuccess(w) +}