From 229e85d608f626c02701a7d877cb5cac7939b40a Mon Sep 17 00:00:00 2001 From: "Robin E. R. Davies" Date: Thu, 27 Feb 2025 03:34:27 -0500 Subject: [PATCH] Downloads --- src/MimeTypes.cpp | 4 + src/PiPedalModel.cpp | 5 + src/PiPedalModel.hpp | 2 + src/RingBuffer.hpp | 102 ++++++++++++++--- src/RingBufferReader.hpp | 1 + src/WebServerConfig.cpp | 47 +++++++- vite/package-lock.json | 2 +- vite/package.json | 2 +- vite/src/pipedal/FilePropertyDialog.tsx | 145 +++++++++++++++++------- vite/src/pipedal/PiPedalModel.tsx | 10 ++ vite/src/pipedal/PluginControlView.tsx | 2 +- 11 files changed, 263 insertions(+), 59 deletions(-) diff --git a/src/MimeTypes.cpp b/src/MimeTypes.cpp index 702580d..86c3c99 100644 --- a/src/MimeTypes.cpp +++ b/src/MimeTypes.cpp @@ -167,6 +167,10 @@ MimeTypes::MimeTypes() AddMimeType("MPG", "video/mp2p"); AddMimeType("MPEG", "video/mp2p"); + // custom. + AddMimeType("NAM", "application/x-nam+json"); + + } const MimeTypes&MimeTypes::instance() diff --git a/src/PiPedalModel.cpp b/src/PiPedalModel.cpp index deb1109..b05acc5 100644 --- a/src/PiPedalModel.cpp +++ b/src/PiPedalModel.cpp @@ -2879,3 +2879,8 @@ void PiPedalModel::OnAlsaDriverTerminatedAbnormally() { } }); } + +bool PiPedalModel::IsInUploadsDirectory(const std::string &path) +{ + return !storage.IsInUploadsDirectory(path); +} \ No newline at end of file diff --git a/src/PiPedalModel.hpp b/src/PiPedalModel.hpp index ffb016f..55583f8 100644 --- a/src/PiPedalModel.hpp +++ b/src/PiPedalModel.hpp @@ -456,6 +456,8 @@ namespace pipedal std::string RenameFilePropertyFile(const std::string &oldRelativePath, const std::string &newRelativePath, const UiFileProperty &uiFileProperty); FilePropertyDirectoryTree::ptr GetFilePropertydirectoryTree(const UiFileProperty &uiFileProperty,const std::string&selectedPath); + bool IsInUploadsDirectory(const std::string &path); + std::string UploadUserFile(const std::string &directory, int64_t instanceId, const std::string &patchProperty, const std::string &filename, std::istream &inputStream, size_t streamLength); uint64_t CreateNewPreset(); diff --git a/src/RingBuffer.hpp b/src/RingBuffer.hpp index e4b02e8..f807451 100644 --- a/src/RingBuffer.hpp +++ b/src/RingBuffer.hpp @@ -153,7 +153,7 @@ namespace pipedal } } template - RingBufferStatus readWait_until(size_t size,const std::chrono::time_point &time_point) + RingBufferStatus readWait_until(size_t size, const std::chrono::time_point &time_point) { while (true) { @@ -161,7 +161,7 @@ namespace pipedal { std::unique_lock lock(mutex); size_t available = readSpace_(); - if (available >= size) + if (available >= size) { return RingBufferStatus::Ready; } @@ -215,12 +215,69 @@ namespace pipedal return (size_t)size; } - size_t readSpace() { std::unique_lock lock(mutex); return readSpace_(); } + + bool write_packet(size_t bytes, void *data) + { + if (MULTI_WRITER) + { + std::lock_guard writeLock{writeMutex}; + if (writeSpace() < bytes + sizeof(bytes)) + { + return false; + } + size_t index = this->writePosition; + for (size_t i = 0; i < sizeof(bytes); ++i) + { + buffer[(index++() & ringBufferMask] = ((uint8_t*)(&bytes))[i]; + + } + for (size_t i = 0; i < bytes; ++i) + { + buffer[(index++) & ringBufferMask] = data[i]; + } + { + std::lock_guard lock(mutex); + this->writePosition = (index) & ringBufferMask; + } + if (SEMAPHORE_READER) + { + cvRead.notify_all(); + } + return true; + } + else + { + if (writeSpace() < bytes + sizeof(bytes)) + { + return false; + } + size_t index = this->writePosition; + for (size_t i = 0; i < sizeof(bytes); ++i) + { + buffer[(index++() & ringBufferMask] = ((uint8_t*)(&bytes))[i]; + + } + for (size_t i = 0; i < bytes; ++i) + { + buffer[(index++) & ringBufferMask] = data[i]; + } + { + std::lock_guard lock(mutex); + this->writePosition = (index) & ringBufferMask; + } + if (SEMAPHORE_READER) + { + cvRead.notify_all(); + } + return true; + } + } + bool write(size_t bytes, uint8_t *data) { if (MULTI_WRITER) @@ -273,7 +330,7 @@ namespace pipedal if (MULTI_WRITER) { std::lock_guard guard(writeMutex); - if (writeSpace() <= sizeof(bytes) + bytes +bytes2) + if (writeSpace() <= sizeof(bytes) + bytes + bytes2) { return false; } @@ -340,6 +397,23 @@ namespace pipedal } } + size_t read_packet(size_t maxSize, void*data) { + size_t packet_size; + if (!read(sizeof(packet_size), (uint8_t*)&packet_size)) + { + throw std::runtime_error("RingBuffer::read_packet: failed to read packet size."); + } + if (packet_size > maxSize) + { + throw std::runtime_error("RingBuffer::read_packet: packet size too large."); + } + if (!read(packet_size, (uint8_t*)data)) + { + throw std::runtime_error("RingBuffer::read_packet: failed to read packet data."); + } + return packet_size; + } + bool read(size_t bytes, uint8_t *data) { if (readSpace() < bytes) @@ -366,18 +440,20 @@ namespace pipedal delete[] buffer; } - bool isReadReady() { + bool isReadReady() + { std::lock_guard lock(mutex); - if (isReadReady_()) return true; + if (isReadReady_()) + return true; return !this->is_open; } - bool isReadReady(size_t size) { + bool isReadReady(size_t size) + { size_t available = readSpace(); return available >= size; } private: - size_t readSpace_() { int64_t size = writePosition - readPosition; @@ -389,7 +465,7 @@ namespace pipedal uint32_t peekSize() { volatile uint32_t result; - uint8_t *p = (uint8_t*)&result; + uint8_t *p = (uint8_t *)&result; size_t ix = this->readPosition; for (size_t i = 0; i < sizeof(result); ++i) { @@ -400,14 +476,12 @@ namespace pipedal bool isReadReady_() { size_t available = readSpace_(); - if (available < sizeof(uint32_t)) return false; + if (available < sizeof(uint32_t)) + return false; // peak to get the size! uint32_t packetSize = peekSize(); - return packetSize+sizeof(uint32_t) <= available; + return packetSize + sizeof(uint32_t) <= available; } - - }; - }; diff --git a/src/RingBufferReader.hpp b/src/RingBufferReader.hpp index 25e6dd0..634d354 100644 --- a/src/RingBufferReader.hpp +++ b/src/RingBufferReader.hpp @@ -327,6 +327,7 @@ namespace pipedal return; } } + template void write(RingBufferCommand command, const T &value, size_t dataLength, uint8_t *variableData) { diff --git a/src/WebServerConfig.cpp b/src/WebServerConfig.cpp index c42488c..1405f2b 100644 --- a/src/WebServerConfig.cpp +++ b/src/WebServerConfig.cpp @@ -34,6 +34,7 @@ #include "PresetBundle.hpp" #include "json.hpp" #include "HotspotManager.hpp" +#include "MimeTypes.hpp" #define OLD_PRESET_EXTENSION ".piPreset" #define PRESET_EXTENSION ".piPreset" @@ -51,6 +52,22 @@ using namespace pipedal; using namespace boost::system; namespace fs = std::filesystem; + + +static bool HasDotDot(const std::filesystem::path &path) { + for (auto &part : path) + { + if (part == "..") { + return true; + } + if (part == ".") + { + return true; + } + } + return false; +} + class UserUploadResponse { public: @@ -63,6 +80,13 @@ JSON_MAP_REFERENCE(UserUploadResponse, errorMessage) JSON_MAP_REFERENCE(UserUploadResponse, path) JSON_MAP_END() +static std::string GetMimeType(const std::filesystem::path&path) { + std::string extension = path.extension(); + const MimeTypes&mimeTypes = MimeTypes::instance(); + auto result = mimeTypes.MimeTypeFromExtension(extension); + return result; + +} static bool IsZipFile(const std::filesystem::path &path) { std::ifstream f(path); @@ -119,6 +143,10 @@ public: return false; } std::string segment = request_uri.segment(1); + if (segment == "downloadMediaFile") + { + return true; + } if (segment == "uploadPluginPresets") { return true; @@ -154,7 +182,7 @@ public: return false; } - std::string GetContentDispositionHeader(const std::string &name, const std::string &extension) + static std::string GetContentDispositionHeader(const std::string &name, const std::string &extension) { std::string fileName = name.substr(0, 64) + extension; std::stringstream s; @@ -423,6 +451,23 @@ public: { std::string segment = request_uri.segment(1); + if (segment == "downloadMediaFile") { + fs::path path = request_uri.query("path"); + + if (!fs::exists(path) || !this->model->IsInUploadsDirectory(path) || HasDotDot(path)) + { + throw PiPedalException("File not found."); + } + auto mimeType = GetMimeType(path); + if (mimeType.empty()) { + throw PiPedalException("Can't download files of this type."); + } + res.set(HttpField::content_type, mimeType); + res.set(HttpField::cache_control, "no-cache"); + std::string disposition = GetContentDispositionHeader(path.stem().string(), path.extension().string()); + res.set(HttpField::content_disposition, disposition); + res.setBodyFile(path,false); + } if (segment == "uploadPluginPresets") { PluginPresets presets; diff --git a/vite/package-lock.json b/vite/package-lock.json index 58d5300..3b725a3 100644 --- a/vite/package-lock.json +++ b/vite/package-lock.json @@ -29,7 +29,7 @@ "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.19.0", "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.18", + "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.14.0", "typescript": "~5.7.2", "typescript-eslint": "^8.22.0", diff --git a/vite/package.json b/vite/package.json index bcf96f2..45b7618 100644 --- a/vite/package.json +++ b/vite/package.json @@ -31,7 +31,7 @@ "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.19.0", "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.18", + "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.14.0", "typescript": "~5.7.2", "typescript-eslint": "^8.22.0", diff --git a/vite/src/pipedal/FilePropertyDialog.tsx b/vite/src/pipedal/FilePropertyDialog.tsx index 4a02bd7..65d1cd6 100644 --- a/vite/src/pipedal/FilePropertyDialog.tsx +++ b/vite/src/pipedal/FilePropertyDialog.tsx @@ -19,7 +19,7 @@ import React from 'react'; -import {createStyles} from './WithStyles'; +import { createStyles } from './WithStyles'; import { Theme } from '@mui/material/styles'; @@ -33,6 +33,7 @@ import { PiPedalModel, PiPedalModelFactory, FileEntry, BreadcrumbEntry, FileRequ import { isDarkMode } from './DarkMode'; import Button from '@mui/material/Button'; import FileUploadIcon from '@mui/icons-material/FileUpload'; +import FileDownloadIcon from '@mui/icons-material/FileDownload'; import AudioFileIcon from '@mui/icons-material/AudioFile'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import FolderIcon from '@mui/icons-material/Folder'; @@ -108,9 +109,11 @@ export interface FilePropertyDialogState { selectedFileIsDirectory: boolean; selectedFileProtected: boolean; hasSelection: boolean; + hasFileSelection: boolean; canDelete: boolean; fileResult: FileRequestResult; navDirectory: string; + windowWidth: number; uploadDirectory: string; isProtectedDirectory: boolean; columns: number; @@ -199,10 +202,12 @@ export default withStyles( fullScreen: this.getFullScreen(), selectedFile: selectedFile, selectedFileProtected: true, + windowWidth: this.windowSize.width, selectedFileIsDirectory: false, navDirectory: this.getNavDirectoryFromFile(selectedFile, props.fileProperty), uploadDirectory: "", hasSelection: false, + hasFileSelection: false, canDelete: false, columns: 0, columnWidth: 1, @@ -259,12 +264,13 @@ export default withStyles( // } filesResult.files.splice(0, 0, { pathname: "", displayName: "", isDirectory: false, isProtected: true }); - let fileEntry = this.getFileEntry(filesResult.files, this.state.selectedFile); + let fileEntry = this.getFileEntry(filesResult.files, this.state.selectedFile); this.setState({ isProtectedDirectory: filesResult.isProtected, fileResult: filesResult, hasSelection: !!fileEntry, - selectedFileProtected: fileEntry ? fileEntry.isProtected: true, + hasFileSelection: !!fileEntry && (!fileEntry.isDirectory) && (!fileEntry.isProtected), + selectedFileProtected: fileEntry ? fileEntry.isProtected : true, navDirectory: navPath, uploadDirectory: filesResult.currentDirectory }); @@ -297,7 +303,8 @@ export default withStyles( onWindowSizeChanged(width: number, height: number): void { this.setState({ - fullScreen: this.getFullScreen() + fullScreen: this.getFullScreen(), + windowWidth: width }) if (this.lastDivRef !== null) { this.onMeasureRef(this.lastDivRef); @@ -377,7 +384,8 @@ export default withStyles( selectedFile: fileEntry.pathname, selectedFileIsDirectory: fileEntry.isDirectory, selectedFileProtected: fileEntry.isProtected, - hasSelection: this.isFileInList(this.state.fileResult.files, fileEntry.pathname) + hasSelection: this.isFileInList(this.state.fileResult.files, fileEntry.pathname), + hasFileSelection: !fileEntry.isDirectory && !fileEntry.isDirectory && !fileEntry.isProtected }) } onDoubleClickValue(selectedFile: string) { @@ -392,14 +400,21 @@ export default withStyles( this.setState({ menuAnchorEl: null }); } handleDelete() { - if (this.state.selectedFileProtected) { - return; + if (this.state.selectedFileProtected) { + return; } this.setState({ openConfirmDeleteDialog: true }); } + handleDownloadFile() { + if (this.state.selectedFileProtected || !this.state.hasFileSelection) { + return; + } + let file = this.state.selectedFile; + this.model.downloadAudioFile(file); + } handleConfirmDelete() { - if (this.state.selectedFileProtected) { - return; + if (this.state.selectedFileProtected) { + return; } this.setState({ openConfirmDeleteDialog: false }); if (this.state.hasSelection) { @@ -424,20 +439,21 @@ export default withStyles( this.model.deleteUserFile(this.state.selectedFile) .then( () => { - if (newSelection) - { - this.setState({ + if (newSelection) { + this.setState({ selectedFile: newSelection.pathname, selectedFileIsDirectory: newSelection.isDirectory, selectedFileProtected: newSelection.isProtected, - hasSelection: true + hasSelection: true, + hasFileSelection: !newSelection.isDirectory && !newSelection.isProtected }); } else { this.setState({ selectedFile: "", selectedFileIsDirectory: false, selectedFileProtected: true, - hasSelection: false + hasSelection: false, + hasFileSelection: false }); } @@ -580,8 +596,7 @@ export default withStyles( } let protectedDirectory = this.state.fileResult.isProtected; let protectedItem = true; - if (this.state.hasSelection) - { + if (this.state.hasSelection) { protectedItem = this.state.selectedFileProtected; } let canMoveOrRename = this.hasSelectedFileOrFolder() && !protectedItem; @@ -719,31 +734,79 @@ export default withStyles( -
- this.handleDelete()} > - - + {this.state.windowWidth > 500 ? ( +
+ this.handleDelete()} > + + - -
 
+ - - - disabled={(!this.state.hasSelection) && this.state.selectedFile !== ""} aria-label="select" - > - {okButtonText} - -
+
 
+ + + +
+ ) : ( +
+
+ this.handleDelete()} > + + + + + + + +
 
+
+
+
 
+ + + +
+
+ )}
{ - this.setState({ + this.setState({ selectedFile: newPath, - selectedFileIsDirectory: - true,selectedFileProtected: false + selectedFileIsDirectory: + true, selectedFileProtected: false }); this.requestFiles(this.state.navDirectory); this.requestScroll = true diff --git a/vite/src/pipedal/PiPedalModel.tsx b/vite/src/pipedal/PiPedalModel.tsx index b791956..a236ecc 100644 --- a/vite/src/pipedal/PiPedalModel.tsx +++ b/vite/src/pipedal/PiPedalModel.tsx @@ -2388,6 +2388,16 @@ export class PiPedalModel //implements PiPedalModel this.webSocket?.send("cancelListenForMidiEvent", listenHandle._handle); } + downloadAudioFile(filePath: string) { + let downloadUrl = this.varServerUrl + "downloadMediaFile?path=" + encodeURIComponent(filePath); + + // download with no flashing temporary tab. + let link = window.document.createElement("A") as HTMLLinkElement; + link.href = downloadUrl; + link.setAttribute("download", ""); + link.click(); + } + download(targetType: string, instanceId: number | string): void { if (instanceId === -1) return; let url = this.varServerUrl + targetType + "?id=" + instanceId; diff --git a/vite/src/pipedal/PluginControlView.tsx b/vite/src/pipedal/PluginControlView.tsx index 7d07a58..2dc6b00 100644 --- a/vite/src/pipedal/PluginControlView.tsx +++ b/vite/src/pipedal/PluginControlView.tsx @@ -790,7 +790,7 @@ const PluginControlView = }) .catch((error) => { - this.model.showAlert("Unable to complete the operation. Audio is not running." + error); + this.model.showAlert("Unable to complete the operation. " + error); }); this.setState({ showFileDialog: false }); }