feat(filetransfer): allow file deletion and add user delete config option (#683)

* feat(filetransfer): allow file deletion and add user delete config option

* fix(client): make user_delete required boolean in payload

* lint.

* feat(filetransfer): add confirmation dialog for file deletion.

* feat(filetransfer): add delete file confirmation messages in multiple languages

* lint.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
This commit is contained in:
Abdellatif Anaflous
2026-08-07 17:12:02 +01:00
committed by GitHub
parent 30d8e1c595
commit d6a4200b36
26 changed files with 205 additions and 10 deletions
+37
View File
@@ -10,6 +10,12 @@
<p class="file-name" :title="item.name">{{ item.name }}</p> <p class="file-name" :title="item.name">{{ item.name }}</p>
<p class="file-size">{{ fileSize(item.size) }}</p> <p class="file-size">{{ fileSize(item.size) }}</p>
<i v-if="item.type !== 'dir' && canDownload" class="fas fa-download download" @click="download(item)" /> <i v-if="item.type !== 'dir' && canDownload" class="fas fa-download download" @click="download(item)" />
<i
v-if="item.type !== 'dir' && canDelete"
class="fas fa-trash delete"
:title="$t('files.delete')"
@click="deleteFile(item)"
/>
</div> </div>
</div> </div>
<div class="transfer-area"> <div class="transfer-area">
@@ -181,10 +187,15 @@
.refresh:hover, .refresh:hover,
.download:hover, .download:hover,
.delete:hover,
.remove-transfer:hover { .remove-transfer:hover {
cursor: pointer; cursor: pointer;
} }
.delete {
margin-left: 0.5em;
}
.transfer-area { .transfer-area {
margin-top: auto; margin-top: auto;
} }
@@ -286,6 +297,10 @@
return this.$accessor.user.admin || this.$accessor.files.userDownload return this.$accessor.user.admin || this.$accessor.files.userDownload
} }
get canDelete() {
return this.$accessor.user.admin || this.$accessor.files.userDelete
}
get canUpload() { get canUpload() {
return this.$accessor.user.admin || this.$accessor.files.userUpload return this.$accessor.user.admin || this.$accessor.files.userUpload
} }
@@ -450,6 +465,28 @@
this.$accessor.files.removeTransfer(transfer) this.$accessor.files.removeTransfer(transfer)
} }
async deleteFile(item: FileListItem) {
const { isConfirmed } = await this.$swal({
title: this.$t('files.delete_title', { name: item.name }) as string,
text: this.$t('files.delete_confirm', { name: item.name }) as string,
icon: 'warning',
showCancelButton: true,
confirmButtonText: this.$t('context.confirm.button_yes') as string,
cancelButtonText: this.$t('context.confirm.button_cancel') as string,
})
if (!isConfirmed) {
return
}
const url =
'/file?pwd=' + encodeURIComponent(this.$accessor.password) + '&filename=' + encodeURIComponent(item.name)
try {
await this.$http.delete(url)
this.$accessor.files.refresh()
} catch (error: any) {
this.$log.error(error)
}
}
fileIcon(file: FileListItem) { fileIcon(file: FileListItem) {
let className = 'file-icon fas ' let className = 'file-icon fas '
// if is directory // if is directory
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: 'Herunterladen', downloads: 'Herunterladen',
uploads: 'Hochladen', uploads: 'Hochladen',
upload_here: 'Klicken oder ziehen Sie Dateien zum Hochladen hierher', upload_here: 'Klicken oder ziehen Sie Dateien zum Hochladen hierher',
delete: 'Löschen',
delete_title: '"{name}" löschen?',
delete_confirm: 'Möchten Sie diese Datei wirklich entfernen?',
} }
+3
View File
@@ -127,4 +127,7 @@ export const files = {
downloads: 'Downloads', downloads: 'Downloads',
uploads: 'Uploads', uploads: 'Uploads',
upload_here: 'Click or drag files here to upload', upload_here: 'Click or drag files here to upload',
delete: 'Delete',
delete_title: 'Delete "{name}"?',
delete_confirm: 'Do you really want to remove this file?',
} }
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: 'Descargas', downloads: 'Descargas',
uploads: 'Subidas', uploads: 'Subidas',
upload_here: 'Haz clic o arrastra archivos aquí para subirlos', upload_here: 'Haz clic o arrastra archivos aquí para subirlos',
delete: 'Eliminar',
delete_title: '¿Eliminar "{name}"?',
delete_confirm: '¿Realmente deseas eliminar este archivo?',
} }
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: 'Lataukset', downloads: 'Lataukset',
uploads: 'Lataa', uploads: 'Lataa',
upload_here: 'Klikkaa tai vedä tiedostoja tähän ladataksesi', upload_here: 'Klikkaa tai vedä tiedostoja tähän ladataksesi',
delete: 'Poista',
delete_title: 'Poistetaanko "{name}"?',
delete_confirm: 'Haluatko varmasti poistaa tämän tiedoston?',
} }
+6 -3
View File
@@ -2,7 +2,7 @@ export const logout = 'Se déconnecter'
export const unsupported = 'ce navigateur ne prend pas en charge WebRTC' export const unsupported = 'ce navigateur ne prend pas en charge WebRTC'
export const admin_loggedin = "Vous êtes connecté en tant qu'admin" export const admin_loggedin = "Vous êtes connecté en tant qu'admin"
export const you = 'Vous' export const you = 'Vous'
export const somebody = 'Quelqu\'un' // TODO: vérifier la traduction export const somebody = "Quelqu'un" // TODO: vérifier la traduction
export const send_a_message = 'Envoyer un message' export const send_a_message = 'Envoyer un message'
export const side = { export const side = {
@@ -18,7 +18,7 @@ export const connect = {
password: 'Mot de passe', password: 'Mot de passe',
connect: 'Connexion', connect: 'Connexion',
error: 'Erreur de connexion', error: 'Erreur de connexion',
empty_displayname: 'Le nom d\'affichage ne peut pas être vide.', // TODO: vérifier la traduction empty_displayname: "Le nom d'affichage ne peut pas être vide.", // TODO: vérifier la traduction
} }
export const context = { export const context = {
@@ -51,7 +51,7 @@ export const controls = {
lock: 'Vérouiller le contrôle', lock: 'Vérouiller le contrôle',
unlock: 'Débloquer le contrôle', unlock: 'Débloquer le contrôle',
has: 'Vous avez le contrôle', // TODO: vérifier la traduction has: 'Vous avez le contrôle', // TODO: vérifier la traduction
hasnot: 'Vous n\'avez pas le contrôle', // TODO: vérifier la traduction hasnot: "Vous n'avez pas le contrôle", // TODO: vérifier la traduction
} }
export const locks = { export const locks = {
@@ -124,4 +124,7 @@ export const files = {
downloads: 'Téléchargements', downloads: 'Téléchargements',
uploads: 'Envois', uploads: 'Envois',
upload_here: 'Cliquez ou faites glisser les fichiers ici pour les envoyer', upload_here: 'Cliquez ou faites glisser les fichiers ici pour les envoyer',
delete: 'Supprimer',
delete_title: 'Supprimer "{name}" ?',
delete_confirm: 'Voulez-vous vraiment supprimer ce fichier ?',
} }
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: 'Unduhan', downloads: 'Unduhan',
uploads: 'Unggahan', uploads: 'Unggahan',
upload_here: 'Klik atau seret berkas ke sini untuk mengunggah', upload_here: 'Klik atau seret berkas ke sini untuk mengunggah',
delete: 'Hapus',
delete_title: 'Hapus "{name}"?',
delete_confirm: 'Apakah Anda benar-benar ingin menghapus berkas ini?',
} }
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: 'ダウンロード', downloads: 'ダウンロード',
uploads: 'アップロード', uploads: 'アップロード',
upload_here: 'アップロードするにはここをクリックするかファイルをドラッグしてください', upload_here: 'アップロードするにはここをクリックするかファイルをドラッグしてください',
delete: '削除',
delete_title: '"{name}" を削除しますか?',
delete_confirm: '本当にこのファイルを削除しますか?',
} }
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: '다운로드', downloads: '다운로드',
uploads: '업로드', uploads: '업로드',
upload_here: '업로드할 파일을 여기로 클릭하거나 드래그하세요', upload_here: '업로드할 파일을 여기로 클릭하거나 드래그하세요',
delete: '삭제',
delete_title: '"{name}" 을(를) 삭제하시겠습니까?',
delete_confirm: '정말로 이 파일을 삭제하시겠습니까?',
} }
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: 'Nedlastinger', downloads: 'Nedlastinger',
uploads: 'Opplastinger', uploads: 'Opplastinger',
upload_here: 'Klikk eller dra filer hit for å laste opp', upload_here: 'Klikk eller dra filer hit for å laste opp',
delete: 'Slett',
delete_title: 'Slette "{name}"?',
delete_confirm: 'Er du sikker på at du vil slette denne filen?',
} }
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: 'Pobrane pliki', downloads: 'Pobrane pliki',
uploads: 'Wysyłane pliki', uploads: 'Wysyłane pliki',
upload_here: 'Kliknij lub przeciągnij pliki tutaj, aby przesłać', upload_here: 'Kliknij lub przeciągnij pliki tutaj, aby przesłać',
delete: 'Usuń',
delete_title: 'Usunąć "{name}"?',
delete_confirm: 'Czy na pewno chcesz usunąć ten plik?',
} }
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: 'Загрузки', downloads: 'Загрузки',
uploads: 'Загрузить', uploads: 'Загрузить',
upload_here: 'Нажмите или перетащите сюда файлы для загрузки', upload_here: 'Нажмите или перетащите сюда файлы для загрузки',
delete: 'Удалить',
delete_title: 'Удалить "{name}"?',
delete_confirm: 'Вы действительно хотите удалить этот файл?',
} }
+3
View File
@@ -130,4 +130,7 @@ export const files = {
downloads: 'Stiahnutia', downloads: 'Stiahnutia',
uploads: 'Nahrávanie', uploads: 'Nahrávanie',
upload_here: 'Kliknutím alebo pretiahnutím súborov sem ich môžete nahrať', upload_here: 'Kliknutím alebo pretiahnutím súborov sem ich môžete nahrať',
delete: 'Odstrániť',
delete_title: 'Odstrániť "{name}"?',
delete_confirm: 'Naozaj chcete odstrániť tento súbor?',
} }
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: 'Nedladdningar', downloads: 'Nedladdningar',
uploads: 'Ladda upp', uploads: 'Ladda upp',
upload_here: 'Klicka eller dra filer hit för att ladda upp dem', upload_here: 'Klicka eller dra filer hit för att ladda upp dem',
delete: 'Ta bort',
delete_title: 'Ta bort "{name}"?',
delete_confirm: 'Vill du verkligen ta bort den här filen?',
} }
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: '下载', downloads: '下载',
uploads: '上传', uploads: '上传',
upload_here: '点击或拖动文件到此处上传', upload_here: '点击或拖动文件到此处上传',
delete: '删除',
delete_title: '删除"{name}"?',
delete_confirm: '确定要删除此文件吗?',
} }
+3
View File
@@ -124,4 +124,7 @@ export const files = {
downloads: '下載', downloads: '下載',
uploads: '上傳', uploads: '上傳',
upload_here: '點選或拖曳檔案至此上傳', upload_here: '點選或拖曳檔案至此上傳',
delete: '刪除',
delete_title: '刪除「{name}」?',
delete_confirm: '確定要刪除此檔案嗎?',
} }
+8 -1
View File
@@ -361,11 +361,18 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
///////////////////////////// /////////////////////////////
// File Transfer Events // File Transfer Events
///////////////////////////// /////////////////////////////
protected [EVENT.FILETRANSFER.LIST]({ cwd, user_download, user_upload, files }: FileTransferListPayload) { protected [EVENT.FILETRANSFER.LIST]({
cwd,
user_download,
user_upload,
user_delete,
files,
}: FileTransferListPayload) {
this.$accessor.files.setCwd(cwd) this.$accessor.files.setCwd(cwd)
this.$accessor.files.setFileList(files) this.$accessor.files.setFileList(files)
this.$accessor.files.setUserDownload(user_download) this.$accessor.files.setUserDownload(user_download)
this.$accessor.files.setUserUpload(user_upload) this.$accessor.files.setUserUpload(user_upload)
this.$accessor.files.setUserDelete(user_delete)
} }
///////////////////////////// /////////////////////////////
+1
View File
@@ -206,6 +206,7 @@ export interface FileTransferListPayload {
cwd: string cwd: string
user_download: boolean user_download: boolean
user_upload: boolean user_upload: boolean
user_delete: boolean
files: FileListItem[] files: FileListItem[]
} }
+9
View File
@@ -9,6 +9,7 @@ export const state = () => ({
transfers: [] as FileTransfer[], transfers: [] as FileTransfer[],
userDownload: false, userDownload: false,
userUpload: false, userUpload: false,
userDelete: false,
}) })
export const getters = getterTree(state, { export const getters = getterTree(state, {
@@ -32,6 +33,10 @@ export const mutations = mutationTree(state, {
state.userUpload = val state.userUpload = val
}, },
_setUserDelete(state, val: boolean) {
state.userDelete = val
},
_addTransfer(state, transfer: FileTransfer) { _addTransfer(state, transfer: FileTransfer) {
state.transfers = [...state.transfers, transfer] state.transfers = [...state.transfers, transfer]
}, },
@@ -60,6 +65,10 @@ export const actions = actionTree(
accessor.files._setUserUpload(val) accessor.files._setUserUpload(val)
}, },
setUserDelete(store, val: boolean) {
accessor.files._setUserDelete(val)
},
addTransfer(store, transfer: FileTransfer) { addTransfer(store, transfer: FileTransfer) {
if (transfer.status !== 'pending') { if (transfer.status !== 'pending') {
return return
+28
View File
@@ -388,6 +388,34 @@ func (h *LegacyHandler) Route(r types.Router) {
return err 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)
// create a new session
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)
}
// copy the body to the response writer
_, err = io.Copy(w, body)
return err
})
r.Get("/health", func(w http.ResponseWriter, r *http.Request) error { r.Get("/health", func(w http.ResponseWriter, r *http.Request) error {
_, err := w.Write([]byte("true")) _, err := w.Write([]byte("true"))
return err return err
@@ -113,6 +113,7 @@ type FileTransferList struct {
Cwd string `json:"cwd"` Cwd string `json:"cwd"`
UserDownload bool `json:"user_download"` UserDownload bool `json:"user_download"`
UserUpload bool `json:"user_upload"` UserUpload bool `json:"user_upload"`
UserDelete bool `json:"user_delete"`
Files []types.FileListItem `json:"files"` Files []types.FileListItem `json:"files"`
} }
@@ -611,6 +611,7 @@ func (s *session) wsToClient(msg []byte) error {
Cwd: request.RootDir, Cwd: request.RootDir,
UserDownload: request.UserDownload, UserDownload: request.UserDownload,
UserUpload: request.UserUpload, UserUpload: request.UserUpload,
UserDelete: request.UserDelete,
Files: files, Files: files,
}) })
@@ -14,6 +14,7 @@ type Config struct {
RefreshInterval time.Duration RefreshInterval time.Duration
UserDownload bool UserDownload bool
UserUpload bool UserUpload bool
UserDelete bool
} }
func (Config) Init(cmd *cobra.Command) error { func (Config) Init(cmd *cobra.Command) error {
@@ -42,6 +43,11 @@ func (Config) Init(cmd *cobra.Command) error {
return err return err
} }
cmd.PersistentFlags().Bool("filetransfer.user_delete", false, "allow non-admin users to delete files")
if err := viper.BindPFlag("filetransfer.user_delete", cmd.PersistentFlags().Lookup("filetransfer.user_delete")); err != nil {
return err
}
// v2 config // v2 config
cmd.PersistentFlags().Bool("file_transfer_enabled", false, "enable file transfer feature") cmd.PersistentFlags().Bool("file_transfer_enabled", false, "enable file transfer feature")
@@ -64,6 +70,7 @@ func (s *Config) Set() {
s.RefreshInterval = viper.GetDuration("filetransfer.refresh_interval") s.RefreshInterval = viper.GetDuration("filetransfer.refresh_interval")
s.UserDownload = viper.GetBool("filetransfer.user_download") s.UserDownload = viper.GetBool("filetransfer.user_download")
s.UserUpload = viper.GetBool("filetransfer.user_upload") s.UserUpload = viper.GetBool("filetransfer.user_upload")
s.UserDelete = viper.GetBool("filetransfer.user_delete")
// v2 config // v2 config
@@ -107,6 +107,7 @@ func (m *Manager) broadcastUpdate() {
RootDir: m.config.RootDir, RootDir: m.config.RootDir,
UserDownload: m.config.UserDownload, UserDownload: m.config.UserDownload,
UserUpload: m.config.UserUpload, UserUpload: m.config.UserUpload,
UserDelete: m.config.UserDelete,
Files: fileList, Files: fileList,
}) })
} }
@@ -121,6 +122,7 @@ func (m *Manager) sendUpdate(session types.Session) {
RootDir: m.config.RootDir, RootDir: m.config.RootDir,
UserDownload: m.config.UserDownload, UserDownload: m.config.UserDownload,
UserUpload: m.config.UserUpload, UserUpload: m.config.UserUpload,
UserDelete: m.config.UserDelete,
Files: fileList, Files: fileList,
}) })
} }
@@ -206,6 +208,59 @@ func (m *Manager) Start() error {
return nil 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")
}
if !session.Profile().IsAdmin && !m.config.UserDelete {
return utils.HttpForbidden("file delete is not allowed for non-admin users")
}
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 {
if os.IsNotExist(err) {
return utils.HttpNotFound("file not found")
}
return utils.HttpInternalServerError().
WithInternalErr(err).
Msg("error deleting file")
}
err, changed := m.refresh()
if err != nil {
m.logger.Err(err).Msg("unable to refresh file list after delete")
}
if changed {
m.broadcastUpdate()
}
return nil
}
func (m *Manager) Shutdown() error { func (m *Manager) Shutdown() error {
close(m.shutdown) close(m.shutdown)
return nil return nil
@@ -214,6 +269,7 @@ func (m *Manager) Shutdown() error {
func (m *Manager) Route(r types.Router) { func (m *Manager) Route(r types.Router) {
r.Get("/", m.downloadFileHandler) r.Get("/", m.downloadFileHandler)
r.Post("/", m.uploadFileHandler) r.Post("/", m.uploadFileHandler)
r.Delete("/", m.deleteFileHandler)
} }
func (m *Manager) WebSocketHandler(session types.Session, msg types.WebSocketMessage) bool { func (m *Manager) WebSocketHandler(session types.Session, msg types.WebSocketMessage) bool {
@@ -15,6 +15,7 @@ type Message struct {
RootDir string `json:"root_dir"` RootDir string `json:"root_dir"`
UserDownload bool `json:"user_download"` UserDownload bool `json:"user_download"`
UserUpload bool `json:"user_upload"` UserUpload bool `json:"user_upload"`
UserDelete bool `json:"user_delete"`
Files []Item `json:"files"` Files []Item `json:"files"`
} }
+2
View File
@@ -61,6 +61,7 @@ The file transfer plugin is a simple pre-loaded internal plugin that allows you
}, },
'filetransfer.user_download': false, 'filetransfer.user_download': false,
'filetransfer.user_upload': false, 'filetransfer.user_upload': false,
'filetransfer.user_delete': false,
}} /> }} />
- <Def id="filetransfer.enabled" /> enables the file transfer support. If set to `false`, the file transfer is disabled. - <Def id="filetransfer.enabled" /> enables the file transfer support. If set to `false`, the file transfer is disabled.
@@ -68,6 +69,7 @@ The file transfer plugin is a simple pre-loaded internal plugin that allows you
- <Def id="filetransfer.refresh_interval" /> refers to the interval at which the file list is refreshed. - <Def id="filetransfer.refresh_interval" /> refers to the interval at which the file list is refreshed.
- <Def id="filetransfer.user_download" /> allows non-admin users to download files. If set to `false` (default), only admins can download files. - <Def id="filetransfer.user_download" /> allows non-admin users to download files. If set to `false` (default), only admins can download files.
- <Def id="filetransfer.user_upload" /> allows non-admin users to upload files. If set to `false` (default), only admins can upload files. - <Def id="filetransfer.user_upload" /> allows non-admin users to upload files. If set to `false` (default), only admins can upload files.
- <Def id="filetransfer.user_delete" /> allows non-admin users to delete files. If set to `false` (default), only admins can delete files.
The file transfer plugin extends user profile and room settings by adding the following fields: The file transfer plugin extends user profile and room settings by adding the following fields: