const btnConnections = $('#connections'); const btnNavBack = $('#navBack'); const btnNavForward = $('#navForward'); const inputNavPath = $('#inputNavPath'); const btnGo = $('#pathGo'); const btnPathPopup = $('#pathPopup'); const elBelowNavBar = $('#belowNavBar'); const btnDirMenu = $('#dirMenu'); const btnDeselectAll = $('#deselectAll'); const btnUpload = $('#upload'); const btnDirCreate = $('#dirCreate'); const btnFileCreate = $('#fileCreate'); const btnSelectionCut = $('#fileCut'); const btnSelectionCopy = $('#fileCopy'); const btnSelectionPaste = $('#filePaste'); const btnRename = $('#fileRename'); const btnSelectionMoveTo = $('#fileMoveTo'); const btnSelectionCopyTo = $('#fileCopyTo'); const btnSelectionDelete = $('#fileDelete'); const btnSelectionPerms = $('#filePerms'); const btnDownload = $('#fileDownload'); const btnShare = $('#fileShare'); const btnDirSort = $('#dirSort'); const btnDirView = $('#dirView'); const btnDirSelection = $('#dirSelection'); const elFileColHeadings = $('#fileColHeadings'); const elFiles = $('#files'); const inputSearch = $('#inputNavSearch'); const btnSearchCancel = $('#navSearchCancel'); const btnSearchGo = $('#navSearchGo'); const forceTileViewWidth = 720; /** An array of paths in the back history */ let backPaths = []; /** An array of paths in the forward history */ let forwardPaths = []; /** An array of paths cut or copied to the clipboard */ let selectionClipboard = []; /** True the clipboard paste mode is cut */ let isClipboardCut = false; /** * The current file sort order * @type {'name'|'size'|'date'} */ let sortType = window.localStorage.getItem('sortType') || 'name'; /** * True of the file sort order is to be reversed * @type {boolean} */ let sortDesc = window.localStorage.getItem('sortDesc'); sortDesc = (sortDesc == null) ? false : (sortDesc === 'true'); /** * True if hidden files should be visible * @type {boolean} */ let showHidden = window.localStorage.getItem('showHidden'); showHidden = (showHidden == null) ? true : (showHidden === 'true'); /** * The current file view mode * @type {'list'|'tile'} */ let viewMode = window.localStorage.getItem('viewMode') || 'list'; /** True if an upload is in progress */ let isUploading = false; /** The index of the most recently selected file, or -1 if no files are selected */ let lastSelectedIndex = -1; /** True of a directory load is in progress * and currently visible files shouldn't be accessed */ let fileAccessLock = false; /** * True of hidden files should be visible * @type {boolean} */ let showDownloadPopup = window.localStorage.getItem('showDownloadPopup'); showDownloadPopup = (showDownloadPopup == null) ? true : (showDownloadPopup === 'true'); // Variables for file name navigation let keypressString = ''; let keypressClearTimeout; // Variables for recursive searching let searchWebsocket; let isSearching = false; /** * Saves the current state of the `connections` object to LocalStorage. */ const saveConnections = () => { window.localStorage.setItem('connections', JSON.stringify(connections)); } /** * Returns the `connections` object as a sorted array, and each value has an added `id` property. */ const getSortedConnectionsArray = () => { const connectionValues = []; for (const id of Object.keys(connections)) { const connection = connections[id]; connectionValues.push({ id: id, ...connection }); } connectionValues.sort((a, b) => { const aName = a.name.toLowerCase(); const bName = b.name.toLowerCase(); if (aName < bName) return -1; if (aName > bName) return 1; return 0; }); return connectionValues; } /** * Prompts the user to export a connection. * @param {number} id The connection ID */ const exportConnectionDialog = async (id) => { const connection = connections[id]; const exportBody = document.createElement('div'); exportBody.classList = 'col gap-10'; exportBody.style.maxWidth = '400px'; exportBody.innerHTML = /*html*/` Only share exports with credentials with people you trust! These credentials grant access to not only your server's files, but oftentimes an interactive terminal (SSH). `; new PopupBuilder() .setTitle(`Export ${connection.name}`) .addBody(exportBody) .addAction(action => action .setLabel('Export') .setIsPrimary(true) .setClickHandler(() => { const includeCredentials = $('input[name="exportCredentials"]:checked', exportBody).value == 'include'; const data = { name: connection.name, host: connection.host, port: connection.port, username: connection.username, path: connection.path }; if (includeCredentials) { if (connection.key) data.key = connection.key; if (connection.password) data.password = connection.password; } const blob = new Blob([ JSON.stringify(data) ], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${connection.name.replace(/[^a-zA-Z-_\. ]/g, '').trim() || 'connection'}.json`; a.click(); URL.revokeObjectURL(url); })) .addAction(action => action.setLabel('Cancel')) .show(); } /** * Opens a dialog popup to manage stored connection information. */ const connectionManagerDialog = () => { const popup = new PopupBuilder(); const el = document.createElement('div'); el.id = 'connectionManager'; el.classList = 'col gap-15'; const connectionValues = getSortedConnectionsArray(); for (const connection of connectionValues) { const entry = document.createElement('div'); entry.classList = 'entry row gap-10 align-center'; entry.innerHTML = /*html*/`
cloud
${connection.name}
${connection.username}@${connection.host}:${connection.port}
${connection.path}
`; $('.btn.menu', entry).addEventListener('click', () => { new ContextMenuBuilder() .addItem(option => option .setLabel('Edit...') .setIcon('edit') .setClickHandler(async() => { popup.hide(); await editConnectionDialog(connection.id); connectionManagerDialog(); })) .addItem(option => option .setLabel('Export...') .setIcon('download') .setClickHandler(async() => { exportConnectionDialog(connection.id); })) .addSeparator() .addItem(option => option .setLabel('Delete') .setIcon('delete') .setIsDanger(true) .setClickHandler(async() => { delete connections[connection.id]; saveConnections(); entry.remove(); })) .showAtCursor(); }); $('.btn.connect', entry).addEventListener('click', () => { popup.hide(); setActiveConnection(connection.id); }); el.appendChild(entry); } const elButtons = document.createElement('div'); elButtons.classList = 'row gap-10 flex-wrap'; const btnAdd = document.createElement('button'); btnAdd.classList = 'btn success small'; btnAdd.innerHTML = /*html*/`
add
New connection... `; btnAdd.addEventListener('click', async() => { popup.hide(); await addNewConnectionDialog(); connectionManagerDialog(); }); elButtons.appendChild(btnAdd); const btnImport = document.createElement('button'); btnImport.classList = 'btn secondary small'; btnImport.innerHTML = /*html*/`
cloud_upload
Import... `; btnImport.addEventListener('click', async() => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.addEventListener('change', async() => { const file = input.files[0]; if (!file) return; popup.hide(); const reader = new FileReader(); reader.addEventListener('load', async() => { try { const data = JSON.parse(reader.result); const id = Date.now(); connections[id] = data; saveConnections(); if (!data.key && !data.password) { return editConnectionDialog(id); } } catch (error) { console.error(error); } connectionManagerDialog(); }); reader.readAsText(file); }); input.click(); }); elButtons.appendChild(btnImport); el.appendChild(elButtons); popup .setTitle('Connections') .addBody(el) .addAction(action => action .setIsPrimary(true) .setLabel('Done') .setClickHandler(() => { saveConnections(); })); popup.show(); } /** * Opens a dialog to edit an existing connection by its ID. * @param {number} id The connection ID * @returns {Promise} Resolves with the ID passed in */ const editConnectionDialog = async (id) => new Promise(resolve => { const connection = connections[id]; if (!connection) throw new Error(`Connection with ID ${id} not found!`); const securityNote = thing => `Your ${thing} is saved in this browser and only persists on the server during and for a few minutes after each request.`; const el = document.createElement('div'); el.classList = 'col gap-10'; el.innerHTML = /*html*/`
${securityNote('password')}
Your private key is typically located under C:\\Users\\you\\.ssh on Windows, or /home/you/.ssh on Unix. It's not the ".pub" file! The server has to be configured to accept your public key for your private one to work. ${securityNote('private key')}
`; const inputName = $('#inputName', el); const inputHost = $('#inputHost', el); const inputPort = $('#inputPort', el); const inputUsername = $('#inputUsername', el); const authTypePassword = $('#authTypePassword', el); const authTypeKey = $('#authTypeKey', el); const inputPassword = $('#inputPassword', el); const elPasswordCont = $('#passwordCont', el); const elKeyCont = $('#keyCont', el); const inputKey = $('#inputKey', el); const btnLoadKey = $('#loadKeyFromFile', el); const inputPath = $('#inputPath', el); authTypePassword.addEventListener('change', () => { elPasswordCont.style.display = ''; elKeyCont.style.display = 'none'; }); authTypeKey.addEventListener('change', () => { elPasswordCont.style.display = 'none'; elKeyCont.style.display = ''; }); if (!connection.password && !connection.key) { authTypeKey.checked = true; authTypeKey.dispatchEvent(new Event('change')); } else if (!connection.password) { authTypeKey.checked = true; authTypeKey.dispatchEvent(new Event('change')); } else { authTypePassword.checked = true; authTypePassword.dispatchEvent(new Event('change')); } btnLoadKey.addEventListener('click', () => { const input = document.createElement('input'); input.type = 'file'; input.addEventListener('change', () => { const file = input.files[0]; if (file.size > 1024) return; const reader = new FileReader(); reader.addEventListener('load', () => { inputKey.value = reader.result; }); reader.readAsText(file); }); input.click(); }); const popup = new PopupBuilder() .setTitle('Edit connection') .addBody(el); popup.addAction(action => action .setIsPrimary(true) .setLabel('Save') .setClickHandler(() => { connection.host = inputHost.value; connection.port = inputPort.value || 22; connection.username = inputUsername.value; connection.name = inputName.value || `${connection.username}@${connection.host}`; if (authTypePassword.checked) { connection.password = inputPassword.value; delete connection.key; } else { connection.key = inputKey.value; delete connection.password; } connection.path = inputPath.value; saveConnections(); })); popup.addAction(action => action.setLabel('Cancel')); popup.show(); popup.setOnHide(() => resolve(id)); }); /** * Adds a new connection with basic placeholder data and runs `editConnectionDialog()` on it. */ const addNewConnectionDialog = async() => { const id = Date.now(); connections[id] = { name: 'New Connection', host: '', port: 22, username: '', key: '', password: '', path: '/' }; await editConnectionDialog(id); if (!connections[id].host || !connections[id].username) { delete connections[id]; } } /** * Sets the active connection to the one with the specified ID. * @param {number} id The connection ID * @param {string} path An initial directory path to override the saved one */ const setActiveConnection = (id, path) => { if (!connections[id]) { throw new Error(`Connection with ID ${id} not found!`); } backPaths = []; forwardPaths = []; activeConnection = JSON.parse(JSON.stringify(connections[id])); activeConnectionId = id; selectionClipboard = []; changePath(path, false); } /** * Fetches connection details from the server for a given connection ID. * @param {string} connectionId The connection ID * @returns {Promise} The connection details or null if failed */ const fetchConnectionDetails = async (connectionId) => { try { const response = await fetch(`/api/connect/${connectionId}`); const data = await response.json(); if (!data.success) { setStatus(`Error: ${data.error}`, true); return null; } return data.connection; } catch (error) { setStatus(`Error fetching connection details: ${error.message}`, true); return null; } }; /** * Changes the path and loads the directory or file. * @param {string} path The target path * @param {boolean} pushState If `true`, update the back/forward history */ const changePath = async(path, pushState = true) => { loadStartTime = Date.now(); if (!activeConnection) return; // Lock file selection to prevent double-clicking during load fileAccessLock = true; // Use the current path if none is specified path = path || activeConnection.path; // Disable nav buttons during load btnNavBack.disabled = true; btnNavForward.disabled = true; btnGo.disabled = true; // Stat the path to make sure it exists setStatus(`Checking path...`); const dataStats = await api.get('files/stat', { path: path }); // If there was an error if (dataStats.error) { setStatus(`Error: ${dataStats.error}`, true); // Otherwise... } else { // Get extension info const info = getFileExtInfo(dataStats.path.split('/').pop(), dataStats.stats.size); // If the path is a file if (dataStats.stats.isFile) { // If the file is viewable, open the file viewer if (info.isViewable) { openFileViewer(dataStats.path); } else { await downloadFile(dataStats.path); } // Update the path bar inputNavPath.value = activeConnection.path; // If the path is a directory } else if (dataStats.stats.isDirectory) { // Update the path bar inputNavPath.value = dataStats.path; // If the path has changed, push the old path to the back history if (pushState && activeConnection.path != path) backPaths.push(activeConnection.path); // Update the stored current path activeConnection.path = dataStats.path; // Update display document.title = `${activeConnection.name} - ${activeConnection.path}`; window.history.replaceState(null, null, `?con=${activeConnectionId}&path=${encodeURIComponent(activeConnection.path)}`); // Load the directory await loadDirectory(dataStats.path); // Otherwise, show an error } else { setStatus(`Error: Path is not a file or directory`, true); } } // Re-enable nav buttons accordingly btnNavBack.disabled = (backPaths.length == 0); btnNavForward.disabled = (forwardPaths.length == 0); btnGo.disabled = false; // Unlock file selection fileAccessLock = false; } /** * Loads a directory and populates the file list. * @param {string} path The directory path */ const loadDirectory = async path => { // Hide the file view elFiles.style.transition = 'none'; elFiles.style.pointerEvents = 'none'; requestAnimationFrame(() => { elFiles.style.opacity = 0; }); // Remove all existing file elements elFiles.innerHTML = `
Folders
Files
`; const elFilesFolders = $('.folders', elFiles); const elFilesFiles = $('.files', elFiles); lastSelectedIndex = -1; // Disable directory controls updateDirControls(); btnUpload.disabled = true; btnDirCreate.disabled = true; btnFileCreate.disabled = true; btnDownload.disabled = true; btnShare.disabled = true; btnDirSort.disabled = true; inputSearch.value = ''; isSearching = false; try { searchWebsocket.close(); } catch (error) {} // Get the directory listing setStatus(`Loading directory...`); const data = await api.get('directories/list', { path: path }); // If an error occurred, update the status bar // then set the file list to an empty array if (data.error) { setStatus(`Error: ${data.error}`, true); data.list = []; } // Add the ".." directory const list = [{ name: '..', type: 'd', longname: '-' }, ...data.list]; // Loop through the list and create file elements for (const file of list) { const elFile = getFileEntryElement(file, path); // Add the file element to the file list if (file.type == 'd') elFilesFolders.appendChild(elFile); else elFilesFiles.appendChild(elFile); } // Sort the file list sortFiles(); // Show the file view elFiles.style.transition = '0.15s var(--bezier)'; requestAnimationFrame(() => { elFiles.style.opacity = 1; setTimeout(() => { elFiles.style.pointerEvents = ''; elFiles.style.transition = 'none'; }, 200); }); // Re-enable directory controls accordingly if (!data.error) { btnUpload.disabled = false; btnDirCreate.disabled = false; btnFileCreate.disabled = false; btnDownload.disabled = false; btnShare.disabled = false; btnDirSort.disabled = false; inputSearch.placeholder = `Search within ${path.split('/').pop() || '/'}...`; updateDirControls(); setStatus(`Loaded directory with ${list.length} items in ${Date.now()-loadStartTime}ms`); } } const searchDirectory = async(path, query) => { let startTime = Date.now(); isSearching = true; setStatus(`Starting search...`); document.title = `${activeConnection.name} - Searching for "${query}" in ${path}`; // Disable controls updateDirControls(); btnUpload.disabled = true; btnDirCreate.disabled = true; btnFileCreate.disabled = true; // Remove all existing file elements elFiles.innerHTML = `
Folders
Files
`; const elFilesFolders = $('.folders', elFiles); const elFilesFiles = $('.files', elFiles); lastSelectedIndex = -1; // Get socket key const resSocketKey = await api.get('key'); const key = resSocketKey.key; // Connect to the search websocket try { searchWebsocket.close(); } catch (error) {} searchWebsocket = new WebSocket(`wss://${window.location.host}/api/sftp/directories/search?key=${key}&path=${encodeURIComponent(path)}&query=${encodeURIComponent(query)}`); let count = 0; let maxCount = 500; let finishedSuccessfully = false; searchWebsocket.addEventListener('message', e => { const data = JSON.parse(e.data); if (data.error) { setStatus(`Error: ${data.error}`, true, -1); } if (data.status == 'scanning') { setStatus(`Searching within ${data.path}...`, false, -1); } if (data.status == 'complete') { finishedSuccessfully = true; searchWebsocket.close(); } if (data.status == 'list') { // Add file elements to the file list for (const file of data.list) { const pathSplit = file.path.split('/'); pathSplit.pop(); const folderPath = pathSplit.join('/'); const elFile = getFileEntryElement(file, path); elFile.classList.add('search'); const elNameCont = $('.nameCont', elFile); elNameCont.insertAdjacentHTML('afterbegin', `
${folderPath}
`); if (file.type == 'd') elFilesFolders.appendChild(elFile); else elFilesFiles.appendChild(elFile); count++; if (count >= maxCount) { finishedSuccessfully = true; searchWebsocket.close(); } } // Sort file list sortFiles(); } }); searchWebsocket.addEventListener('close', () => { setStatus(`Found ${count >= maxCount ? `${maxCount}+` : count} file(s) in ${Date.now()-startTime}ms`); }); } /** * Generates a file list entry element with the data for a given file. * @param {object} file A file object returned from the directory list API * @param {string} dirPath The path of the directory containing this file * @returns {HTMLElement} */ const getFileEntryElement = (file, dirPath) => { const elFile = document.createElement('button'); elFile.classList = 'btn fileEntry row'; // If the file is "hidden", give it the class if (file.name != '..' && file.name.substring(0, 1) === '.') { elFile.classList.add('hidden'); } // Get icon let icon = 'insert_drive_file'; if (file.type == 'd') icon = 'folder'; if (file.type == 'l') icon = 'file_present'; if (file.type == 'b') icon = 'save'; if (file.type == 'p') icon = 'filter_alt'; if (file.type == 'c') icon = 'output'; if (file.type == 's') icon = 'wifi'; if (file.name == '..') icon = 'drive_folder_upload'; // Get formatted file info const sizeFormatted = (file.size && file.type !== 'd') ? formatSize(file.size) : '-'; const dateRelative = file.modifyTime ? getRelativeDate(file.modifyTime) : '-'; const dateAbsolute = file.modifyTime ? dayjs(file.modifyTime).format('MMM D, YYYY, h:mm A') : null; const perms = file.longname.split(' ')[0].replace(/\*/g, ''); const permsNum = permsStringToNum(perms); // Add data attributes to the file element elFile.dataset.path = file.path || `${dirPath}/${file.name}`; elFile.dataset.type = file.type; elFile.dataset.name = file.name; elFile.dataset.size = file.size; elFile.dataset.date = file.modifyTime; elFile.dataset.perms = perms; // Build the HTML let lower = []; if (dateRelative !== '-') lower.push(dateRelative); if (sizeFormatted !== '-') lower.push(sizeFormatted); elFile.innerHTML = /*html*/`
${icon}
${file.name}
${lower.length > 0 ? /*html*/`
${lower.join(' • ')}
`:''}
${dateRelative}
${sizeFormatted}
${perms}
`; // Handle access const accessFile = () => { if (fileAccessLock) return; forwardPaths = []; changePath(elFile.dataset.path); }; // Handle clicks let lastClick = 0; elFile.addEventListener('click', e => { e.stopPropagation(); if (getIsMobileDevice()) return; // If the control key isn't held if (!e.ctrlKey) { // If this is a double-click if ((Date.now()-lastClick) < 300) { accessFile(); } } // Update our last click time lastClick = Date.now(); // Return if .. if (file.name == '..') return; // Handle selection if (e.shiftKey && lastSelectedIndex >= 0) { // Select all files between the last selected file and this one // based on this file's index and lastSelectedIndex const files = [...$$('#files .fileEntry', elFiles)]; const start = Math.min(lastSelectedIndex, parseInt(elFile.dataset.index)); const end = Math.max(lastSelectedIndex, parseInt(elFile.dataset.index)); for (let j = start; j <= end; j++) { const el = files[j]; selectFile(el.dataset.path, false, false, false); } } else { // Update selection based on shift and ctrl key state const state = e.shiftKey || e.ctrlKey; selectFile(elFile.dataset.path, !state, state); } }); // Handle keypresses elFile.addEventListener('keydown', e => { // If the enter key is pressed if (e.code === 'Enter') { accessFile(); } // Focus the next file if (e.code == 'ArrowDown') { e.preventDefault(); const next = elFile.nextElementSibling; if (next) next.focus(); } // Focus the previous file if (e.code == 'ArrowUp') { e.preventDefault(); const prev = elFile.previousElementSibling; if (prev) prev.focus(); } // If the escape key is pressed, deselect all files if (e.code === 'Escape') { deselectAllFiles(); } // Return if .. if (file.name == '..') return; // If the spacebar is pressed if (e.code === 'Space') { // Prevent scrolling e.preventDefault(); // Update selection based on ctrl key state if (file.name != '..') selectFile(elFile.dataset.path, !e.ctrlKey, true); } }); elFile.addEventListener('keypress', e => { if (e.ctrlKey || e.shiftKey || e.altKey) return; clearTimeout(keypressClearTimeout); keypressString += e.key; keypressClearTimeout = setTimeout(() => { keypressString = ''; }, 500); // Get all file elements const files = [...$$('#files .fileEntry', elFiles)]; // Put all files before this one at the end of the array // This causes the search to wrap around to the beginning const filesWrapped = [ ...files.slice(parseInt(elFile.dataset.index)+1), ...files.slice(0, parseInt(elFile.dataset.index)+1) ]; // Search through file elements and select the first one // whose data-name starts with the keypress string for (const el of filesWrapped) { if (el.dataset.name.toLowerCase().startsWith(keypressString.toLowerCase())) { selectFile(el.dataset.path, true, false, true); break; } } }); // Handle right-clicks elFile.addEventListener('contextmenu', e => { e.stopPropagation(); e.preventDefault(); if (getIsMobileDevice()) return; // If the file is already selected, don't change selection if (!elFile.classList.contains('selected')) { selectFile(elFile.dataset.path, true, true); } fileContextMenu(); }); // Handle mobile touch start let timeTouchStart = 0; let initialSelectTimeout = null; let fileListScrollTopOnStart = 0; elFile.addEventListener('touchstart', e => { if (!getIsMobileDevice()) return; timeTouchStart = Date.now(); fileListScrollTopOnStart = elFiles.scrollTop; if (!checkIsSelecting()) { initialSelectTimeout = setTimeout(() => { if ((Date.now()-timeTouchStart) > 1000) return; selectFile(elFile.dataset.path, true, false); if (navigator.vibrate) navigator.vibrate(2); }, 400); } }); // Handle mobile touch end elFile.addEventListener('touchend', e => { if (!getIsMobileDevice()) return; clearTimeout(initialSelectTimeout); if ((Date.now()-timeTouchStart) > 380) return; if (elFiles.scrollTop != fileListScrollTopOnStart) return; if (checkIsSelecting()) { selectFile(elFile.dataset.path, false, true); } else { accessFile(); } }); // Handle mobile touch move elFile.addEventListener('touchmove', e => { if (!getIsMobileDevice()) return; clearTimeout(initialSelectTimeout); timeTouchStart = 0; }); return elFile; } /** * Displays a context menu with actions for the selected file(s). * @param {HTMLElement} elDisplay An HTML element to display the menu relative to */ const fileContextMenu = (elDisplay = null) => { const allVisibleFiles = [...$$('#files .fileEntry:not(.hidden)', elFiles)]; if (showHidden) allVisibleFiles.push(...[...$$('#files .fileEntry.hidden', elFiles)]); const selectedFiles = [...getSelectedFiles()]; // We have to delay button clicks to allow time for // the context menu to lose focus trap const clickButton = btn => setTimeout(() => btn.click(), 100); // Selection status shortcuts const isNoneSelected = selectedFiles.length == 0; const isSomeSelected = selectedFiles.length > 0; const isSingleSelected = selectedFiles.length == 1; const isMultiSelected = selectedFiles.length > 1; const isAllSelected = selectedFiles.length == allVisibleFiles.length-1; // Build the menu const menu = new ContextMenuBuilder(); if (isNoneSelected) menu.addItem(item => { item.setIcon($('.icon', btnUpload).innerText) .setLabel('Upload files...') .setClickHandler(() => clickButton(btnUpload)) btnUpload.disabled ? item.disable() : item.enable(); return item; }); if (isNoneSelected) menu.addItem(item => { item.setIcon($('.icon', btnDirCreate).innerText) .setLabel('New folder...') .setClickHandler(() => clickButton(btnDirCreate)) btnDirCreate.disabled ? item.disable() : item.enable(); return item; }); if (isNoneSelected) menu.addItem(item => { item.setIcon($('.icon', btnFileCreate).innerText) .setLabel('New file...') .setClickHandler(() => clickButton(btnFileCreate)) btnFileCreate.disabled ? item.disable() : item.enable(); return item; }); if (isNoneSelected) menu.addSeparator(); if (!btnSelectionCut.disabled) menu.addItem(item => { item.setIcon($('.icon', btnSelectionCut).innerText) .setLabel(`Cut`) .setClickHandler(() => clickButton(btnSelectionCut)) return item; }); if (!btnSelectionCopy.disabled) menu.addItem(item => { item.setIcon($('.icon', btnSelectionCopy).innerText) .setLabel(`Copy`) .setClickHandler(() => clickButton(btnSelectionCopy)) return item; }); if (isNoneSelected) menu.addItem(item => { item.setIcon($('.icon', btnSelectionPaste).innerText) .setLabel(`Paste`) .setClickHandler(() => clickButton(btnSelectionPaste)) btnSelectionPaste.disabled ? item.disable() : item.enable(); return item; }); if (isSomeSelected) menu.addSeparator(); if (!btnRename.disabled) menu.addItem(item => { item.setIcon($('.icon', btnRename).innerText) .setLabel('Rename...') .setClickHandler(() => clickButton(btnRename)) return item; }); if (!btnSelectionMoveTo.disabled) menu.addItem(item => { item.setIcon($('.icon', btnSelectionMoveTo).innerText) .setLabel(`Move to...`) .setClickHandler(() => clickButton(btnSelectionMoveTo)) return item; }); if (!btnSelectionCopyTo.disabled) menu.addItem(item => { item.setIcon($('.icon', btnSelectionCopyTo).innerText) .setLabel(`Copy to...`) .setClickHandler(() => clickButton(btnSelectionCopyTo)) return item; }); if (!btnSelectionDelete.disabled) menu.addItem(item => { item.setIcon($('.icon', btnSelectionDelete).innerText) .setLabel(`Delete...`) .setClickHandler(() => clickButton(btnSelectionDelete)) .setIsDanger(true) return item; }); if (!btnSelectionPerms.disabled) menu.addItem(item => { item.setIcon($('.icon', btnSelectionPerms).innerText) .setLabel(`Edit permissions...`) .setClickHandler(() => clickButton(btnSelectionPerms)) return item; }); menu.addSeparator(); menu.addItem(item => { item.setIcon('download') .setLabel(`Download`) .setClickHandler(() => clickButton(btnDownload)) btnDownload.disabled ? item.disable() : item.enable(); return item; }); if (!isLocalhost) menu.addItem(item => { item.setIcon('share') .setLabel(`Copy download link...`) .setClickHandler(() => clickButton(btnShare)) btnDownload.disabled ? item.disable() : item.enable(); return item; }); if (!isMultiSelected) menu.addSeparator(); if (!isMultiSelected) menu.addItem(item => { item.setIcon('conversion_path') .setLabel('Copy path') .setClickHandler(() => { const path = isNoneSelected ? activeConnection.path : selectedFiles[0].dataset.path; console.log('Clipboard context:', window.location.href, window.top === window ? 'Parent' : 'Iframe'); navigator.clipboard.writeText(path); setStatus(`Copied path to clipboard`); }) return item; }); if (allVisibleFiles.length > 1) menu.addSeparator(); if (!isAllSelected) menu.addItem(item => item .setIcon('select_all') .setLabel('Select all') .setTooltip('Ctrl + A') .setClickHandler(selectAllFiles)) if (isSomeSelected) menu.addItem(item => item .setIcon('select') .setLabel('Deselect all') .setTooltip('Ctrl + Shift + A') .setClickHandler(deselectAllFiles)) if (isSomeSelected && !isAllSelected) menu.addItem(item => item .setIcon('move_selection_up') .setLabel('Invert selection') .setTooltip('Ctrl + Alt + A') .setClickHandler(invertFileSelection)) if (elDisplay) { const rect = elDisplay.getBoundingClientRect(); menu.showAtCoords(rect.left, rect.bottom-5); } else { menu.showAtCursor(); } } /** * Sorts the current file list using `sortType` and `sortDesc`. */ const sortFiles = () => { deselectAllFiles(); // Define sorting functions const sortFuncs = { name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }), size: (a, b) => a.size - b.size, date: (a, b) => a.date - b.date }; // Loop through file sections const sections = [...$$('.section', elFiles)]; let i = 0; for (const section of sections) { const files = [...$$('#files .fileEntry', section)]; // Sort files files.sort((a, b) => { if (a.dataset.name == '..') return -1; if (b.dataset.name == '..') return 1; const aData = { name: a.dataset.name, size: parseInt(a.dataset.size), date: parseInt(a.dataset.date) }; const bData = { name: b.dataset.name, size: parseInt(b.dataset.size), date: parseInt(b.dataset.date) }; const sortFunc = sortFuncs[sortType]; return sortFunc(aData, bData) * (sortDesc ? -1 : 1); }); // Append files to the file list for (const file of files) { file.dataset.index = i; section.appendChild(file); i++; } } } /** * Changes the file sort type and re-sorts the file list. * @param {sortType} type The new sort type */ const changeFileSortType = type => { sortType = type; window.localStorage.setItem('sortType', sortType); sortFiles(); } /** * Changes the file sort direction and re-sorts the file list. * @param {sortDesc} descending */ const changeFileSortDirection = descending => { sortDesc = descending; window.localStorage.setItem('sortDesc', sortDesc); sortFiles(); } /** * Changes the file view mode and updates the file list. * @param {viewMode} type The new view mode */ const changeFileViewMode = type => { if (type == 'list' && window.innerWidth < forceTileViewWidth) { return new PopupBuilder() .setTitle(`Can't switch to list view`) .addBodyHTML(`

Your screen is too narrow to switch to list view! Rotate your device or move to a larger screen, then try again.

`) .addAction(action => action.setLabel('Okay').setIsPrimary(true)) .show(); } viewMode = type; window.localStorage.setItem('viewMode', viewMode); elFiles.classList.remove('list', 'tiles'); elFiles.classList.add(viewMode); elFileColHeadings.classList.toggle('tiles', type == 'tiles'); } /** Toggles the visibility of hidden files. */ const toggleHiddenFileVisibility = () => { showHidden = !showHidden; window.localStorage.setItem('showHidden', showHidden); elFiles.classList.toggle('showHidden', showHidden); } /** * Opens a file preview/editor tab/window. * @param {string} path The file path. */ const openFileViewer = path => { const url = `/file.html?con=${activeConnectionId}&path=${encodeURIComponent(path)}`; const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.matchMedia('(display-mode: minimal-ui)').matches; if (!isStandalone) { // Open in new tab window.open(url, '_blank'); setStatus(`File opened in new tab`); } else { // Set size const viewerWidth = parseInt(window.localStorage.getItem('viewerWidth')) || window.innerWidth; const viewerHeight = parseInt(window.localStorage.getItem('viewerHeight')) || window.innerHeight; const coords = { // Center the new window on top of this one x: window.screenX + (window.innerWidth - viewerWidth)/2, y: window.screenY + (window.innerHeight - viewerHeight)/2, w: viewerWidth, h: viewerHeight }; // Open window window.open(url, path, `width=${coords.w},height=${coords.h},left=${coords.x},top=${coords.y}`); setStatus(`File opened in new window`); } } /** * Resolves with a download URL for a zip file containing all of the files and directories specified, or `false` if an error occurred. * @param {string[]} paths An array of file and/or directory paths * @param {string} rootPath The directory path to start at inside the zip file - leave undefined to use `'/'` * @returns {Promise} */ const getZipDownloadUrl = async(paths, rootPath = '/') => { const pathsJson = JSON.stringify(paths); if ((pathsJson.length+rootPath.length) > 1900) { return setStatus(`Error: Too many selected paths for zip download`, true); } setStatus(`Getting zip file download URL...`); const res = await api.get('files/get/multi/url', { paths: pathsJson, rootPath: rootPath }); if (res.error) { return setStatus(`Error: ${res.error}`, true); } if (res.download_url) { return res.download_url; } return false; } /** * Starts a zip file download for all of the paths specified. * @param {string[]} paths An array of file and/or directory paths * @param {string} [rootPath='/'] The directory path to start at inside the zip file */ const downloadZip = async(paths, rootPath = '/') => { const url = await getZipDownloadUrl(paths, rootPath); if (url) { downloadUrl(url); setStatus(`Zip file download started`); } } /** * Updates the disabled/enabled state of all control buttons depending on the currently selected file entries. */ const updateDirControls = () => { const selectedFiles = $$('.selected', elFiles); btnSelectionCut.disabled = true; btnSelectionCopy.disabled = true; btnSelectionPaste.disabled = true; btnRename.disabled = true; btnSelectionMoveTo.disabled = true; btnSelectionCopyTo.disabled = true; btnSelectionDelete.disabled = true; btnSelectionPerms.disabled = true; if (isSearching) { btnDownload.disabled = true; btnShare.disabled = true; } btnDeselectAll.style.display = 'none'; // When no files are selected if (selectedFiles.length == 0) { btnDirMenu.classList.remove('info'); // When files are selected } else { btnDirMenu.classList.add('info'); // When a single file is selected if (selectedFiles.length == 1) { btnRename.disabled = false; } btnSelectionCut.disabled = false; btnSelectionCopy.disabled = false; btnSelectionMoveTo.disabled = false; btnSelectionCopyTo.disabled = false; btnSelectionDelete.disabled = false; btnSelectionPerms.disabled = false; btnDownload.disabled = false; btnShare.disabled = false; btnDeselectAll.style.display = ''; } // When there are files in the clipboard if (selectionClipboard.length > 0 && !isSearching) { btnSelectionPaste.disabled = false; } } /** * An array of all selected file elements. * @returns {HTMLElement[]} */ const getSelectedFiles = () => [...$$('.selected', elFiles)]; /** * Updates the selected state of the file element with the specified path. * @param {string} path The path of the file to select in the list * @param {boolean} [deselectOthers] If `true`, other files will be deselected - defaults to `true` * @param {boolean} [toggle] If `true`, toggle the selected state of this file - defaults to `false` * @param {boolean} [focus] If `true`, focus this file element in the list - defaults to `false` */ const selectFile = (path, deselectOthers = true, toggle = false, focus = false) => { const el = $(`.fileEntry[data-path="${path}"]`, elFiles); if (!el) return; if (el.dataset.name == '..') return; const isSelected = el.classList.contains('selected'); if (deselectOthers) deselectAllFiles(); if (toggle) el.classList.toggle('selected', !isSelected); else el.classList.add('selected'); if (focus) el.focus(); deselectHiddenFiles(); lastSelectedIndex = parseInt(el.dataset.index); updateDirControls(); } /** * Selects all files in the file list, excluding hidden ones if they aren't visible. */ const selectAllFiles = () => { const files = [...$$('.fileEntry', elFiles)]; for (const el of files) { if (el.dataset.name == '..') continue; el.classList.add('selected'); } deselectHiddenFiles(); lastSelectedIndex = parseInt($('.fileEntry.selected:last-child', elFiles).dataset.index || -1); updateDirControls(); } /** * Deselects all files in the file list. */ const deselectAllFiles = () => { const selected = getSelectedFiles(); for (const el of selected) { el.classList.remove('selected'); } lastSelectedIndex = -1; updateDirControls(); } /** * Deselects all invisible files in the file list. Nothing will happen if hidden files are visible. */ const deselectHiddenFiles = () => { if (showHidden) return; const hidden = [...$$('.hidden', elFiles)]; for (const el of hidden) { el.classList.remove('selected'); } updateDirControls(); } /** * Inverts the current file selection, only including visible files. */ const invertFileSelection = () => { const files = [...$$('#files .fileEntry', elFiles)]; files.shift(); for (const el of files) { el.classList.toggle('selected'); } lastSelectedIndex = parseInt($('#files .fileEntry:last-child', elFiles).dataset.index) || -1; deselectHiddenFiles(); updateDirControls(); } /** * Returns `true` if there are currently files selected, `false` otherwise. * @returns {boolean} */ const checkIsSelecting = () => { const selected = getSelectedFiles(); return selected.length > 0; } /** * Opens a dialog prompting the user to create a directory. */ const createDirectoryDialog = () => { const el = document.createElement('div'); el.innerHTML = /*html*/`
`; const inputDirName = $('#inputDirName', el); const popup = new PopupBuilder() .setTitle('New folder') .addBody(el) .addAction(action => action .setIsPrimary(true) .setLabel('Create') .setClickHandler(async() => { const name = inputDirName.value; if (!name) return; const path = `${activeConnection.path}/${name}`; const data = await api.post('directories/create', { path: path }); if (data.error) { setStatus(`Error: ${data.error}`, true); } else { await changePath(); selectFile(data.path, true, false, true); } })) .addAction(action => action.setLabel('Cancel')) .show(); inputDirName.focus(); inputDirName.addEventListener('keydown', e => { if (e.key === 'Enter') { $('.btn:first-of-type', popup.el).click(); } }); } /** * Opens a dialog prompting the user to rename the file with the specified path. * @param {string} path The file path */ const renameFileDialog = async(path, shouldReload = true) => new Promise(resolve => { const el = document.createElement('div'); const currentName = path.split('/').pop(); el.innerHTML = /*html*/`
`; const input = $('#inputFileName', el); const popup = new PopupBuilder() .setTitle(`Rename file`) .addBody(el) .addAction(action => action .setIsPrimary(true) .setLabel('Rename') .setClickHandler(async() => { popup.setOnHide(() => {}); const name = input.value; if (!name) return resolve(path); const pathOld = path; const dir = pathOld.split('/').slice(0, -1).join('/'); let pathNew = `${dir}/${name}`; if (pathNew == pathOld) return resolve(path); // Check if the new path exists if (await checkFileExists(pathNew)) { if ((await fileConflictDialog(pathNew, false, true)).type == 'skip') { setStatus(`Rename cancelled`); return resolve(); } pathNew = await getAvailableFileName(dir, name); } const data = await api.put('files/move', { pathOld, pathNew }); if (data.error) { setStatus(`Error: ${data.error}`, true); } else if (shouldReload) { const pathNewDir = data.pathNew.split('/').slice(0, -1).join('/'); await changePath(pathNewDir); selectFile(data.pathNew, true, false, true); } resolve(data.pathNew || path); })) .addAction(action => action.setLabel('Cancel')) .setOnHide(() => resolve(path)) .show(); input.focus(); input.select(); input.addEventListener('keydown', e => { if (e.key === 'Enter') { $('.btn:first-of-type', popup.el).click(); } }); }); /** * Opens a dialog prompting the user to select a directory with an interactive browser. * @param {string} [startPath] The directory to start in * @param {string} [title] The popup title * @param {string} [actionLabel] The label of the confirm button * @returns {Promise} A promise resolving to the selected directory path, or `null` if cancelled */ const selectDirDialog = async(startPath = activeConnection.path, title = 'Select folder', actionLabel = 'Select') => new Promise(resolve => { const el = document.createElement('div'); el.innerHTML = /*html*/`
`; const input = $('#inputDirPath', el); const btnGo = $('.btn.go', el); const elFolders = $('.folders', el); const loadFolders = async dir => { elFolders.innerHTML = ''; const data = await api.get('directories/list', { path: dir, dirsOnly: true }); const subDirs = data.list || []; subDirs.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); if (dir != '/') subDirs.unshift({ name: '..' }); for (const subDir of subDirs) { const elDir = document.createElement('button'); elDir.classList = 'btn fileEntry row gap-10'; if (subDir.name != '..' && subDir.name.substring(0, 1) === '.') elDir.classList.add('hidden'); let subDirPath = path = `${dir}/${subDir.name}`; elDir.innerHTML = /*html*/`
folder
${subDir.name}
`; elDir.addEventListener('click', () => { loadFolders(subDirPath); }); elFolders.appendChild(elDir); } if (data.path) input.value = data.path; }; btnGo.addEventListener('click', () => loadFolders(input.value || startPath || '/')); btnGo.click(); input.addEventListener('keydown', e => { if (e.key === 'Enter') { btnGo.click(); } }); new PopupBuilder() .setTitle(title) .addBody(el) .addAction(action => action .setIsPrimary(true) .setLabel(actionLabel) .setClickHandler(() => resolve(input.value))) .addAction(action => action.setLabel('Cancel')) .setOnHide(() => resolve(null)) .show(); }); /** * Moves files from their original directories into a single new directory while keeping their names. * @param {string} newDirPath The new directory * @param {string[]} filePaths An array of file paths to move * @returns {Promise} An array of new paths of the files successfully moved, or `null` if no files were moved */ const moveFiles = async(newDirPath, filePaths) => { // Loop through selected files const newPaths = []; let i = 0; let replaceStatus = { type: 'skip', all: false }; for (const pathOld of filePaths) { const name = pathOld.split('/').pop(); let pathNew = `${newDirPath}/${name}`; if (pathOld == pathNew) continue; setStatus(`Moving file: ${pathOld}`, false, Math.round((i/filePaths.length)*100)); i++; // Check if the new path exists // If it does, prompt the user to replace it if (await checkFileExists(pathNew)) { if (!replaceStatus.all) { replaceStatus = await fileConflictDialog(pathNew, true, true); } if (replaceStatus.type == 'skip') { setStatus(`File move skipped`); continue; } if (replaceStatus.type == 'replace') { const resDelete = await deleteFile(pathNew); if (resDelete.error) return; } if (replaceStatus.type == 'rename') { pathNew = await getAvailableFileName(newDirPath, name); } } const data = await api.put('files/move', { pathOld, pathNew }); if (data.error) { setStatus(`Error: ${data.error}`, true); break; } const el = $(`#files .fileEntry[data-path="${pathOld}"]`, elFiles); if (el) el.remove(); newPaths.push(data.pathNew); } if (newPaths.length > 0) { setStatus(`Moved ${newPaths.length} file(s) to ${newDirPath}`); return newPaths; } return null; } /** * Copies files into the provided directory. * @param {string} newDirPath The target directory * @param {string[]} filePaths An array of file paths to copy * @returns {Promise} An array of new paths of the files successfully copied, or `null` if no files were copied */ const copyFiles = async(newDirPath, filePaths) => { // Loop through selected files const newPaths = []; let i = 0; let replaceStatus = { type: 'skip', all: false }; for (const pathSource of filePaths) { const name = pathSource.split('/').pop(); let pathDest = `${newDirPath}/${name}`; setStatus(`Copying file: ${pathSource}`, false, Math.round((i/filePaths.length)*100)); i++; // Check if the new path exists // If it does, prompt the user to replace it if (await checkFileExists(pathDest)) { if (!replaceStatus.all) { replaceStatus = await fileConflictDialog(name, true, true); } if (replaceStatus.type == 'skip') { setStatus(`File copy skipped`); continue; } if (replaceStatus.type == 'replace') { const res = await deleteFile(pathDest); if (res.error) return; } if (replaceStatus.type == 'rename') { pathDest = await getAvailableFileName(newDirPath, name); } } const data = await api.put('files/copy', { pathSrc: pathSource, pathDest: pathDest }); if (data.error) { setStatus(`Error: ${data.error}`, true); return false; } newPaths.push(data.pathDest); } if (newPaths.length > 0) { setStatus(`Copied ${newPaths.length} file(s) to ${newDirPath}`); return newPaths; } return false; } /** * Opens a dialog prompting the user to select a directory to transfer the selected files to. * @param {boolean} copy If `true`, copy the files instead of moving them * @returns {Promise} An array of new file paths, or `null` if no files were transferred */ const moveFilesDialog = async(copy = false) => { const selectedPaths = [...getSelectedFiles()].map(el => el.dataset.path); // Prompt the user to select a directory const newDirPath = await selectDirDialog(undefined, `${copy ? 'Copy':'Move'} ${selectedPaths.length > 1 ? `${selectedPaths.length} files`:'file'}`, `${copy ? 'Copy':'Move'} here`); if (!newDirPath) return null; // Move or copy the files if (copy) return copyFiles(newDirPath, selectedPaths); else return moveFiles(newDirPath, selectedPaths); } /** * Prompts the user if they want to skip or replace the current file in the current transfer process, with the additional option of doing this for the all remaining conflicts. * @param {string} fileName The file's name or path to display to the user * @returns {Promise<'skip'|'skipAll'|'replace'|'replaceAll'>} One of 4 states representing the user's choice: `skip`, `skipAll`, `replace`, `replaceAll` */ const fileConflictDialog = (fileName, allowReplace = true, allowDuplicate = false) => new Promise(resolve => { const el = document.createElement('div'); el.innerHTML = `

${fileName} already exists. What do you want to do?

`; const checkbox = $('input', el); const popup = new PopupBuilder() .setClickOutside(false) .setTitle(`File exists`) .addBody(el); popup.addAction(action => action .setLabel('Skip') .setIsPrimary(true) .setClickHandler(() => resolve({ type: 'skip', all: checkbox.checked }))); if (allowReplace) popup.addAction(action => action .setLabel('Replace') .setClickHandler(() => resolve({ type: 'replace', all: checkbox.checked }))); if (allowDuplicate) popup.addAction(action => action .setLabel('Rename') .setClickHandler(() => resolve({ type: 'rename', all: checkbox.checked }))); popup.show(); }); /** * Checks if a file exists on the server, returns `null` if error. * @param {string} path The file path */ const checkFileExists = async path => { const res = await api.get('files/exists', { path: path }); if (res.error) return null; return res.exists ? true : false; } /** * Appends a number to the end of a file name until it's unique in the specified directory. * @param {string} dir The directory to check within * @param {string} name The initial file name * @returns {Promise} A promise resolving to the new file path */ const getAvailableFileName = async(dir, name) => { let i = 1; let path = `${dir}/${name}`; const nameWithoutExt = name.split('.').slice(0, -1).join('.'); const ext = name.split('.').pop(); while (await checkFileExists(path)) { path = `${dir}/${nameWithoutExt}-${i}.${ext}`; i++; } return path; } /** * Uploads input files to the active server. * @param {FileSystemHandle[]} inputFiles The input files */ const uploadFiles = async inputFiles => { if (isUploading) return new PopupBuilder() .setTitle('Upload in progress') .addBodyHTML('

An upload is already in progress. Wait for it to finish before uploading more files.

') .addAction(action => action.setIsPrimary(true).setLabel('Okay')) .show(); isUploading = true; let isCancelled = false; let replaceStatus = { type: 'skip', all: false }; let dirPath = activeConnection.path; // Handle status and progress bar let lastStatusSet = 0; const setUploadStatus = (text, progress = 0) => { if ((Date.now()-lastStatusSet) < 500) return; setStatus(`Cancel | ${text}`, false, progress); const anchor = $('a', elStatusBar); anchor.addEventListener('click', e => { isCancelled = true; }); lastStatusSet = Date.now(); }; // Sort input files inputFiles = [...inputFiles]; inputFiles.sort((a, b) => a.name.localeCompare(b.name)); // Loop through selected files let startTime = Date.now(); let totalBytesUploaded = 0; const paths = []; for (const file of inputFiles) { if (isCancelled) break; setUploadStatus(`Uploading file: ${file.name}`); // If the file exists, prompt the user to replace it let fileName = file.name; let path = `${dirPath}/${fileName}`; if (await checkFileExists(path)) { if (!replaceStatus.all) { replaceStatus = await fileConflictDialog(fileName, true, true); } if (replaceStatus.type == 'skip') { setStatus(`Upload skipped`); continue; } if (replaceStatus.type == 'replace') { const resDelete = await deleteFile(path); if (resDelete.error) return; } if (replaceStatus.type == 'rename') { path = await getAvailableFileName(dirPath, fileName); fileName = path.split('/').pop(); } } // Make a promise to upload the file await new Promise(async(resolve, reject) => { let isUploadComplete = false; // Get socket key const resSocketKey = await api.get('key'); const key = resSocketKey.key; // Connect to the file append websocket const url = `${wsProtocol}://${apiHost}/api/sftp/files/append?path=${encodeURIComponent(path)}&key=${key}`; const ws = new WebSocket(url); // Resolve with error if the websocket closes or errors // before the upload is complete ws.addEventListener('close', () => { if (!isUploadComplete) { isUploading = false; setStatus(`Error: Websocket unexpectedly closed`, true) resolve('unexpectedClose'); } }); ws.addEventListener('error', (e) => { if (!isUploadComplete) { isUploading = false; setStatus(`Error: Websocket error`, true) resolve('wsError'); } }); // Handle messages const messageHandlers = []; ws.addEventListener('message', e => { const data = JSON.parse(e.data); console.log(`Message from upload websocket:`, data); if (!data.success) { isUploading = false; setStatus(`Error: ${data.error}`, true) resolve('error'); } const handler = messageHandlers.shift(); if (handler) handler(data.success || false); }); // Wait for the websocket to open await new Promise(resolve2 => { messageHandlers.push(resolve2); }); console.log(`Opened websocket: ${url}`); // Upload the file in chunks const fileSize = file.size; const bytesPerChunk = 1024*1024*1; const chunkCount = Math.ceil(file.size / bytesPerChunk); for (let i = 0; i < chunkCount; i++) { if (isCancelled) break; const startByte = i * bytesPerChunk; const endByte = Math.min(file.size, (i+1) * bytesPerChunk); const thisChunkSize = endByte - startByte; const chunk = file.slice(startByte, endByte); // Upload the chunk const res = await new Promise(resolve2 => { // Resolve when the chunk is uploaded // and server sends a success message messageHandlers.push(resolve2); ws.send(chunk); }); if (!res) break; // Update status with progress totalBytesUploaded += thisChunkSize; const bytesUploaded = Math.min((i+1)*bytesPerChunk, fileSize); const bytesPerSecond = totalBytesUploaded / ((Date.now()-startTime)/1000); const percentUploaded = Math.round((bytesUploaded/fileSize)*100); setUploadStatus(`Uploading file: ${fileName} | ${formatSize(bytesUploaded)} of ${formatSize(fileSize)} (${formatSize(bytesPerSecond)}/s)`, percentUploaded); } isUploadComplete = true; ws.close(); resolve('done'); }); if (!isUploading) return; // If the upload was cancelled, delete the file if (isCancelled) { await deleteFile(path); setStatus(`Upload cancelled`); break; } // Add the path to the list of uploaded files paths.push(path); // Add the file to the file list if (dirPath == activeConnection.path) { const elExisting = $(`.fileEntry[data-path="${path}"]`, elFiles); if (elExisting) elExisting.remove(); const elFile = getFileEntryElement({ name: fileName, type: '-', size: file.size, modifyTime: Date.now(), longname: '-' }, dirPath); $('.section.files', elFiles).appendChild(elFile); sortFiles(); } } isUploading = false; if (paths.length == 0) return; // Select all new files for (const path of paths) { selectFile(path, false, false, true); } setStatus(`Uploaded ${paths.length} file(s)`); } /** * Opens a system file picker and uploads the selected files to the active server. */ const uploadFilesPrompt = async() => { // Prompt user to select files const input = document.createElement('input'); input.type = 'file'; input.multiple = true; input.click(); // When files are selected input.addEventListener('change', () => { uploadFiles(input.files); }); } /** * Deletes a file from the active server. * @param {string} path The file path * @param {boolean} refresh If `true`, refresh the file list after deleting the file - defaults to `true` * @returns {Promise} The API response object */ const deleteFile = async(path) => { const data = await api.delete('files/delete', { path: path }); if (data.error) { setStatus(`Error: ${data.error}`, true); } else { const elFile = $(`.fileEntry[data-path="${path}"]`, elFiles); if (elFile) elFile.remove(); } return data; } /** * Deletes a directory from the active server. * @param {string} path The directory path * @param {boolean} refresh If `true`, refresh the file list after deleting the directory - defaults to `true` * @returns {Promise} The API response object */ const deleteDirectory = async(path) => { const data = await api.delete('directories/delete', { path: path }); if (data.error) { setStatus(`Error: ${data.error}`, true); } else { const elFile = $(`.fileEntry[data-path="${path}"]`, elFiles); if (elFile) elFile.remove(); } return data; } /** * Shows a context menu containing a set of navigable file paths. * @param {Event} e The `ContextMenu` event * @param {HTMLElement} btn The button that was clicked * @param {string[]} paths An array of paths to show in the menu * @param {ContextMenuBuilder} menu An existing menu object to add items to */ const historyContextMenu = (e, btn, paths, menu = new ContextMenuBuilder()) => { if (btn.disabled) return; e.preventDefault(); paths = JSON.parse(JSON.stringify(paths)).reverse(); for (let i = 0; i < 10; i++) { let path = paths[i]; if (!path) break; let split = path.split('/'); let base = split.pop(); let dir = `/${split.join('/')}/`.replace(/\/\//g, '/'); menu.addItem(item => { const html = /*html*/` ${escapeHTML(dir)}${escapeHTML(base)} `; item.elLabel.innerHTML = html; const span = $('span', item.elLabel); span.title = html; item.setClickHandler(() => { changePath(path, false); }); return item; }); } menu.el.style.maxWidth = '100%'; menu.setIconVisibility(false); const rect = btn.getBoundingClientRect(); menu.showAtCoords(rect.left, rect.bottom-5); } btnConnections.addEventListener('click', () => { const menu = new ContextMenuBuilder(); const connectionValues = getSortedConnectionsArray(); for (const connection of connectionValues) { menu.addItem(option => option .setLabel(connection.name) .setIcon('cloud') .setTooltip(`Click to connect to ${connection.name}
${connection.username}@${connection.host}:${connection.port}
${connection.path}
`) .setClickHandler(() => { setActiveConnection(connection.id); })); }; menu.addSeparator(); menu.addItem(option => option .setLabel('Manage connections...') .setIcon('smb_share') .setClickHandler(connectionManagerDialog)); menu.addItem(option => option .setLabel('New connection...') .setIcon('library_add') .setClickHandler(addNewConnectionDialog)); menu.addSeparator().addItem(item => item .setIcon('code') .setLabel('SFTP Browser GitHub') .setClickHandler(() => { window.open('https://github.com/CyberGen49/sftp-browser'); })); const rect = btnConnections.getBoundingClientRect(); menu.showAtCoords(rect.left, rect.bottom-5); }); btnNavBack.addEventListener('click', () => { if (backPaths.length > 0) { forwardPaths.push(activeConnection.path); changePath(backPaths.pop(), false); } }); btnNavForward.addEventListener('click', () => { if (forwardPaths.length > 0) { backPaths.push(activeConnection.path); changePath(forwardPaths.pop(), false); } }); btnNavBack.addEventListener('contextmenu', (e) => { historyContextMenu(e, btnNavBack, backPaths); }); btnNavForward.addEventListener('contextmenu', (e) => { historyContextMenu(e, btnNavForward, forwardPaths); }); inputNavPath.addEventListener('keydown', e => { if (e.key === 'Enter') { btnGo.click(); } }); btnGo.addEventListener('click', () => { changePath(inputNavPath.value || '/'); }); btnPathPopup.addEventListener('click', () => { const menu = new ContextMenuBuilder() .addItem(item => item .setIcon('pin_drop') .setLabel('Go to path...') .setClickHandler(() => { const popup = new PopupBuilder() .setTitle('Go to path') .addAction(action => action .setIsPrimary(true) .setLabel('Go') .setClickHandler(() => { const path = $('#inputGoToPath', popup.el).value || activeConnection.path; if (path == activeConnection.path) return; changePath(path); popup.hide(); })) .addAction(action => action.setLabel('Cancel')) const elBody = $('.body', popup.el); elBody.innerHTML = /*html*/`
`; const input = $('#inputGoToPath', elBody); input.addEventListener('keydown', e => { if (e.key === 'Enter') { $('.btn:first-of-type', popup.el).click(); } }); popup.show(); setTimeout(() => { input.focus(); input.select(); }, 100); })); const pathSplit = activeConnection.path.split('/'); pathSplit.pop(); if (pathSplit.length > 0) { menu.addSeparator(); let path = ''; for (const node of pathSplit) { path += `/${node}`; menu.addItem(item => item .setIcon('folder_open') .setLabel(node || '/') .setClickHandler(() => { changePath(path); })); } } const rect = btnPathPopup.getBoundingClientRect(); menu.showAtCoords(rect.right, rect.bottom-5); }); btnDirMenu.addEventListener('click', () => { fileContextMenu(btnDirMenu); }); btnDeselectAll.addEventListener('click', deselectAllFiles); btnUpload.addEventListener('click', uploadFilesPrompt); btnDirCreate.addEventListener('click', createDirectoryDialog); btnRename.addEventListener('click', () => { renameFileDialog(getSelectedFiles()[0].dataset.path); }); btnSelectionCut.addEventListener('click', () => { selectionClipboard = [...getSelectedFiles()].map(el => el.dataset.path); const prevEls = [...$$('.cut', elFiles), ...$$('.copied', elFiles)]; for (const el of prevEls) el.classList.remove('cut', 'copied'); for (const path of selectionClipboard) { const el = $(`#files .fileEntry[data-path="${path}"]`, elFiles); el.classList.add('cut'); } isClipboardCut = true; setStatus(`Cut ${selectionClipboard.length} file path(s) to selection clipboard`); updateDirControls(); }); btnSelectionCopy.addEventListener('click', () => { selectionClipboard = [...getSelectedFiles()].map(el => el.dataset.path); const prevEls = [...$$('.cut', elFiles), ...$$('.copied', elFiles)]; for (const el of prevEls) el.classList.remove('cut', 'copied'); for (const path of selectionClipboard) { const el = $(`#files .fileEntry[data-path="${path}"]`, elFiles); el.classList.add('copied'); } isClipboardCut = false; setStatus(`Copied ${selectionClipboard.length} file path(s) to selection clipboard`); updateDirControls(); }); btnSelectionPaste.addEventListener('click', async() => { const newDirPath = activeConnection.path; if (!newDirPath) return; // Move files let newPaths = true; if (isClipboardCut) { // Move the files newPaths = await moveFiles(newDirPath, selectionClipboard); if (!newPaths) return; // Clear the clipboard selectionClipboard = []; // Copy files } else { // Copy the files newPaths = await copyFiles(newDirPath, selectionClipboard); if (!newPaths) return; } // Reload directory await changePath(); // Select the new files for (const path of newPaths) { selectFile(path, false, false, true); } }); btnSelectionMoveTo.addEventListener('click', () => moveFilesDialog(false)); btnSelectionCopyTo.addEventListener('click', () => moveFilesDialog(true)); btnFileCreate.addEventListener('click', async() => { let dir = activeConnection.path; let filePath = await getAvailableFileName(dir, 'file.txt'); const data = await api.post('files/create', { path: filePath }, ''); if (data.error) { return setStatus(`Error: ${data.error}`, true); } filePath = await renameFileDialog(filePath, false); console.log(filePath) await changePath(dir); selectFile(filePath, true, false, true); }); btnSelectionDelete.addEventListener('click', async() => { const selected = [...getSelectedFiles()]; const containsDirs = selected.some(el => el.dataset.type === 'd'); new PopupBuilder() .setTitle(`Delete ${selected.length == 1 ? 'file':`${selected.length} files`}`) .addBodyHTML(`

Are you sure you want to delete ${selected.length == 1 ? `${selected[0].dataset.name}`:`these files`}?

${containsDirs ? `

${selected.length == 1 ? 'This file is a directory':'Your selection contains directories'}! Deleting ${selected.length == 1 ? 'it':'them'} will also delete everything inside of ${selected.length == 1 ? 'it':'them'}.

`:''}

This usually can't be undone!

`) .addAction(action => action .setIsDanger(true) .setLabel('Delete') .setClickHandler(async() => { let i = 0; for (const el of selected) { setStatus(`Deleting file: ${el.dataset.path}`, false, Math.round((i/selected.length)*100)); let res = null; if (el.dataset.type === 'd') { res = await deleteDirectory(el.dataset.path); } else { res = await deleteFile(el.dataset.path); } i++; } setStatus(`Deleted ${selected.length} file(s)`); updateDirControls(); })) .addAction(action => action.setLabel('Cancel')) .show(); }); btnSelectionPerms.addEventListener('click', async() => { const selected = [...getSelectedFiles()]; // File permissions matrix // Columns are read, write, execute // Rows are owner, group, other let permsMatrix = [ [ 0, 0, 0 ], [ 0, 0, 0 ], [ 0, 0, 0 ] ]; for (const el of selected) { const perms = el.dataset.perms.padEnd(10, '-').split(''); if (perms[1] != '-') permsMatrix[0][0]++; if (perms[2] != '-') permsMatrix[0][1]++; if (perms[3] != '-') permsMatrix[0][2]++; if (perms[4] != '-') permsMatrix[1][0]++; if (perms[5] != '-') permsMatrix[1][1]++; if (perms[6] != '-') permsMatrix[1][2]++; if (perms[7] != '-') permsMatrix[2][0]++; if (perms[8] != '-') permsMatrix[2][1]++; if (perms[9] != '-') permsMatrix[2][2]++; } const elMatrix = document.createElement('div'); elMatrix.classList = 'col permsMatrix'; elMatrix.innerHTML = /*html*/`
Read
Write
Execute
User
Group
Other
`; for (let i = 0; i < 3; i++) { for (let ii = 0; ii < 3; ii++) { permsMatrix[i][ii] = Math.round(permsMatrix[i][ii] / selected.length); if (permsMatrix[i][ii] == 1) { $(`input[data-row="${i+1}"][data-col="${ii+1}"]`, elMatrix).checked = true; } } } new PopupBuilder() .setTitle(`Edit file permissions`) .addBody(elMatrix) .addAction(action => action .setIsPrimary(true) .setLabel('Save') .setClickHandler(async() => { // Get permissions number let str = '-'; for (let i = 0; i < 3; i++) { for (let ii = 0; ii < 3; ii++) { const checkbox = $(`input[data-row="${i+1}"][data-col="${ii+1}"]`, elMatrix); if (checkbox.checked) { str += 'rwx'[ii]; } else { str += '-'; } } } let perms = permsStringToNum(str); // Set permissions const changedPaths = []; try { let i = 0; for (const file of selected) { setStatus(`Updating permissions for ${file.dataset.path}...`, false, Math.round((i/selected.length)*100)); const res = await api.put('files/chmod', { path: file.dataset.path, mode: perms }); if (res.error) throw new Error(res.error); changedPaths.push(file.dataset.path); i++; } // Reload directory and select changed files if (changedPaths.length > 0) { await changePath(); for (const file of selected) { selectFile(file.dataset.path, false, false, true); } } } catch (error) { setStatus(`Error: ${error}`, true); } })) .addAction(action => action.setLabel('Cancel')) .show(); }); btnDownload.addEventListener('click', () => { const selected = [...getSelectedFiles()]; const rootPath = activeConnection.path; if (selected.length == 1) { if (selected[0].dataset.type === 'd') { downloadZip([ selected[0].dataset.path ], rootPath); } else { downloadFile(selected[0].dataset.path); } } else if (selected.length > 1) { downloadZip(selected.map(el => el.dataset.path), rootPath); } else { downloadZip([ activeConnection.path ], rootPath); } }); btnShare.addEventListener('click', async() => { new PopupBuilder() .setTitle('Copy download link') .addBodyHTML(`

This link will allow anyone to download your selected files and folders for the next 24 hours without the need for any credentials. Make sure you aren't sharing anything sensitive!

`) .addAction(action => action .setLabel('Copy') .setIsPrimary(true) .setClickHandler(async() => { let url; let selected = [...getSelectedFiles()]; const isNoneSelected = selected.length == 0; const isSingleSelected = selected.length == 1; const isMultiSelected = selected.length > 1; if (isNoneSelected) url = await getZipDownloadUrl([activeConnection.path], activeConnection.path); else if (isSingleSelected) { const el = selected[0]; if (el.dataset.type == 'd') url = await getZipDownloadUrl([el.dataset.path], activeConnection.path); else url = await getFileDownloadUrl(el.dataset.path); } else if (isMultiSelected) url = await getZipDownloadUrl(selected.map(el => el.dataset.path), activeConnection.path); if (url) { try { await navigator.clipboard.writeText(url); setStatus(`Copied download link to clipboard`); } catch (err) { console.error('Clipboard error:', err); // Fallback const textarea = document.createElement('textarea'); textarea.value = url; document.body.appendChild(textarea); textarea.select(); try { document.execCommand('copy'); setStatus(`Copied download link to clipboard (fallback)`); } catch (fallbackErr) { console.error('Fallback failed:', fallbackErr); setStatus(`Failed to copy link. Please copy manually: ${url}`, true); } document.body.removeChild(textarea); } } })) .addAction(action => action.setLabel('Cancel')) .show(); }); if (isLocalhost) btnShare.style.display = 'none'; btnDirView.addEventListener('click', () => { const menu = new ContextMenuBuilder(); menu.addItem(item => item .setIcon(viewMode === 'list' ? 'check' : '') .setLabel('List') .setClickHandler(() => changeFileViewMode('list'))); menu.addItem(item => item .setIcon(viewMode === 'tiles' ? 'check' : '') .setLabel('Tiles') .setClickHandler(() => changeFileViewMode('tiles'))); menu.addSeparator(); menu.addItem(item => item .setIcon(showHidden ? 'check' : '') .setLabel('Show hidden files') .setClickHandler(() => toggleHiddenFileVisibility())); const rect = btnDirView.getBoundingClientRect(); menu.showAtCoords(rect.left, rect.bottom-5); }); elFiles.classList.toggle('showHidden', showHidden); elFiles.classList.add(viewMode); elFileColHeadings.classList.toggle('tiles', elFiles.classList.contains('tiles')); btnDirSort.addEventListener('click', () => { const menu = new ContextMenuBuilder(); menu.addItem(item => item .setIcon(sortType === 'name' ? 'check' : '') .setLabel('Name') .setClickHandler(() => changeFileSortType('name'))); menu.addItem(item => item .setIcon(sortType === 'date' ? 'check' : '') .setLabel('Modified') .setClickHandler(() => changeFileSortType('date'))); menu.addItem(item => item .setIcon(sortType === 'size' ? 'check' : '') .setLabel('Size') .setClickHandler(() => changeFileSortType('size'))); menu.addSeparator(); menu.addItem(item => item .setIcon(!sortDesc ? 'check' : '') .setLabel('Ascending') .setClickHandler(() => changeFileSortDirection(false))); menu.addItem(item => item .setIcon(sortDesc ? 'check' : '') .setLabel('Descending') .setClickHandler(() => changeFileSortDirection(true))); const rect = btnDirSort.getBoundingClientRect(); menu.showAtCoords(rect.left, rect.bottom-5); }); btnDirSelection.addEventListener('click', () => { const menu = new ContextMenuBuilder() .addItem(item => item .setIcon('select_all') .setLabel('Select all') .setTooltip('Ctrl + A') .setClickHandler(selectAllFiles)) .addItem(item => item .setIcon('select') .setLabel('Deselect all') .setTooltip('Ctrl + Shift + A') .setClickHandler(deselectAllFiles)) .addItem(item => item .setIcon('move_selection_up') .setLabel('Invert selection') .setTooltip('Ctrl + Alt + A') .setClickHandler(invertFileSelection)); const rect = btnDirSelection.getBoundingClientRect(); menu.showAtCoords(rect.left, rect.bottom-5); }); elFiles.addEventListener('dragover', e => { e.preventDefault(); e.stopPropagation(); elFiles.classList.add('dragover'); }); elFiles.addEventListener('dragleave', e => { e.preventDefault(); e.stopPropagation(); elFiles.classList.remove('dragover'); }); elFiles.addEventListener('drop', e => { e.preventDefault(); e.stopPropagation(); elFiles.classList.remove('dragover'); const files = []; for (const file of e.dataTransfer.files) { if (file.type !== '') { files.push(file); } } uploadFiles(files); }); elFiles.addEventListener('contextmenu', e => { e.preventDefault(); deselectAllFiles(); fileContextMenu(); }); inputSearch.addEventListener('keydown', e => { if (e.key === 'Enter') { btnSearchGo.click(); } }); btnSearchGo.addEventListener('click', () => { const value = inputSearch.value.trim(); if (value) searchDirectory(activeConnection.path, inputSearch.value); }); btnSearchCancel.addEventListener('click', () => { if (isSearching) changePath(activeConnection.path); }); window.addEventListener('click', e => { const matchIds = [ 'controls', 'files', 'filesFiles', 'filesFolders', 'fileColHeadings', 'statusBar' ]; if (!matchIds.includes(e.target.id)) return; if (!e.ctrlKey) { if (getIsMobileDevice()) return; deselectAllFiles(); } }); window.addEventListener('keydown', e => { const elActive = document.activeElement; const isCtrlF = (e.ctrlKey && e.code == 'KeyF'); if (elActive.tagName == 'INPUT' && !isCtrlF) return; let func = (() => { if (e.ctrlKey) { if (e.shiftKey) { // Ctrl + Shift if (e.code === 'Space') return () => connectionManagerDialog(); if (e.code === 'KeyA') return () => deselectAllFiles(); } if (e.altKey) { // Ctrl + Alt if (e.code === 'KeyA') return () => invertFileSelection(); } // Ctrl if (e.code === 'KeyX') return () => btnSelectionCut.click(); if (e.code === 'KeyC') return () => btnSelectionCopy.click(); if (e.code === 'KeyV') return () => btnSelectionPaste.click(); if (e.code === 'KeyA') return () => selectAllFiles(); if (e.code === 'KeyR') return () => btnGo.click(); if (e.code === 'KeyF') return () => { inputSearch.focus(); inputSearch.select(); } } // Shift if (e.shiftKey) { if (e.code === 'KeyD') return () => btnDownload.click(); if (e.code === 'KeyH') return () => toggleHiddenFileVisibility(); if (e.code === 'KeyN') return () => btnDirCreate.click(); if (e.code === 'KeyU') return () => btnUpload.click(); if (e.code === 'KeyM') return () => btnSelectionMoveTo.click(); if (e.code === 'KeyC') return () => btnSelectionCopyTo.click(); } // Alt if (e.altKey) { if (e.code === 'ArrowLeft') return () => btnNavBack.click(); if (e.code === 'ArrowRight') return () => btnNavForward.click(); } // No modifiers if (e.code === 'F2') return () => btnRename.click(); if (e.code === 'Delete') return () => btnSelectionDelete.click(); })(); if (func) { console.log(`Handling press of ${e.code}`); e.preventDefault(); func(); } }); window.addEventListener('resize', () => { if (window.innerWidth < forceTileViewWidth) { changeFileViewMode('tiles'); } }); window.addEventListener('load', async () => { if ('serviceWorker' in navigator) { const registration = await navigator.serviceWorker.register('/worker.js'); console.log('Service Worker registered with scope:', registration.scope); } // Check if the URL matches /connect/:connectionId const pathMatch = window.location.pathname.match(/^\/connect\/([^/]+)$/); if (pathMatch) { const connectionId = pathMatch[1]; const connectionDetails = await fetchConnectionDetails(connectionId); if (connectionDetails) { // Clear all existing connections to prevent duplicates for (const id in connections) { delete connections[id]; } // Add the new connection using the connectionId as the key connections[connectionId] = { name: `${connectionDetails.username}@${connectionDetails.host}`, host: connectionDetails.host, port: connectionDetails.port || 22, username: connectionDetails.username, password: connectionDetails.password || '', key: connectionDetails.privateKey || '', path: '/minecraft' }; saveConnections(); setActiveConnection(connectionId); } else { // Show connection manager if connection fails connectionManagerDialog(); } } else { // Existing logic for query parameters const params = new URLSearchParams(window.location.search); const connection = connections[params.get('con') || '0']; if (connection) { setActiveConnection(params.get('con'), params.get('path')); } else { connectionManagerDialog(); } } window.dispatchEvent(new Event('resize')); }); // Dynamically update file list relative dates setInterval(() => { if (document.hidden) return; const els = $$('.fileEntry[data-date]', elFiles); if (els.length > 1000) return; for (const el of els) { const timestamp = parseInt(el.dataset.date); if (!timestamp) continue; const elDateMain = $('.date', el); requestAnimationFrame(() => { const newText = getRelativeDate(timestamp); if (elDateMain.innerText == newText) return; elDateMain.innerText = newText; }); } }, 1000*60);