diff --git a/SQL/0000-00-03-ConfigTables.sql b/SQL/0000-00-03-ConfigTables.sql index 3de025fcc3..1fc0134c33 100644 --- a/SQL/0000-00-03-ConfigTables.sql +++ b/SQL/0000-00-03-ConfigTables.sql @@ -8,7 +8,7 @@ CREATE TABLE `ConfigSettings` ( `Description` varchar(255) DEFAULT NULL, `Visible` tinyint(1) DEFAULT '0', `AllowMultiple` tinyint(1) DEFAULT '0', - `DataType` ENUM('text','boolean','email','instrument','textarea','scan_type','date_format','lookup_center','path','web_path', 'log_level') DEFAULT NULL, + `DataType` ENUM('text','boolean','email','instrument','textarea','scan_type','date_format','lookup_center','path','web_path', 'log_level','image') DEFAULT NULL, `Parent` int(11) DEFAULT NULL, `Label` varchar(255) DEFAULT NULL, `OrderNumber` int(11) DEFAULT NULL, @@ -47,12 +47,12 @@ CREATE TABLE `Config` ( INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, Label, OrderNumber) VALUES ('study', 'Settings related to details of the study', 1, 0, 'Study', 1); INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber, Multilingual) SELECT 'title', 'Full descriptive title of the study', 1, 0, 'text', ID, 'Study title', 1, true FROM ConfigSettings WHERE Name="study"; -INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'studylogo', 'Filename containing logo of the study. File should be located under the htdocs/images/ folder', 1, 0, 'text', ID, 'Study logo', 2 FROM ConfigSettings WHERE Name="study"; -INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'login_logo_left', 'Path for top left logo on the login page.', 1, 0, 'text', ID, 'Login Top Left Logo', 3 FROM ConfigSettings WHERE Name="study"; -INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'login_logo_right', 'Path for top right logo on the login page.', 1, 0, 'text', ID, 'Login Top Right Logo', 3 FROM ConfigSettings WHERE Name="study"; +INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'studylogo', 'Image displayed as the study logo', 1, 0, 'image', ID, 'Study logo', 2 FROM ConfigSettings WHERE Name="study"; +INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'login_logo_left', 'Image displayed at the top left of the login page', 1, 0, 'image', ID, 'Login Top Left Logo', 3 FROM ConfigSettings WHERE Name="study"; +INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'login_logo_right', 'Image displayed at the top right of the login page', 1, 0, 'image', ID, 'Login Top Right Logo', 3 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'login_logo_left_link', 'Optional link to redirect when clicking on top left logo', 1, 0, 'text', ID, 'Login Top Left Logo Link', 4 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'login_logo_right_link', 'Optional link to redirect when clicking on top right logo', 1, 0, 'text', ID, 'Login Top Right Logo Link', 4 FROM ConfigSettings WHERE Name="study"; -INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'partner_logos', 'Logos for partners to be displayed in the homepage', 1, 1, 'text', ID, 'Partner Logos', 4 FROM ConfigSettings WHERE Name="study"; +INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'partner_logos', 'Partner images displayed on the homepage', 1, 1, 'image', ID, 'Partner Logos', 4 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'startYear', "Start year for study recruitment or data collection", 1, 0, 'text', ID, 'Start year', 5 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'endYear', "End year for study recruitment or data collection", 1, 0, 'text', ID, 'End year', 6 FROM ConfigSettings WHERE Name="study"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'ageMin', 'Minimum candidate age in years (0+)', 1, 0, 'text', ID, 'Minimum candidate age', 7 FROM ConfigSettings WHERE Name="study"; @@ -90,6 +90,7 @@ INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'publication_deletions', 'Path to deleted publications', 1, 0, 'web_path', ID, 'Deleted Publications', 11 FROM ConfigSettings WHERE Name="paths"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'documentRepositoryPath', 'Path to uploaded document repository files', 1, 0, 'web_path', ID, 'Document Repository Upload Path', 13 FROM ConfigSettings WHERE Name="paths"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'dataReleasePath', 'Path to uploaded data release files', 1, 0, 'web_path', ID, 'Data release Upload Path', 14 FROM ConfigSettings WHERE Name="paths"; +INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, DataType, Parent, Label, OrderNumber) SELECT 'config_upload_path', 'Path to images uploaded through the Configuration module', 1, 0, 'web_path', ID, 'Configuration Upload Path', 15 FROM ConfigSettings WHERE Name="paths"; INSERT INTO ConfigSettings (Name, Description, Visible, AllowMultiple, Label, OrderNumber) VALUES ('gui', 'Settings related to the overall display of LORIS', 1, 0, 'GUI', 3); @@ -242,6 +243,12 @@ INSERT INTO Config (ConfigID, Value) SELECT ID, "none" FROM ConfigSettings WHERE INSERT INTO Config (ConfigID, Value) SELECT ID, "/data/%PROJECTNAME%/data/" FROM ConfigSettings WHERE Name="imagePath"; INSERT INTO Config (ConfigID, Value) SELECT ID, "%LORISROOT%" FROM ConfigSettings WHERE Name="base"; +INSERT INTO Config (ConfigID, Value) +SELECT upload.ID, CONCAT(TRIM(TRAILING '/' FROM base.Value), '/htdocs/images/configuration/') +FROM ConfigSettings upload +JOIN ConfigSettings baseSetting ON baseSetting.Name="base" +JOIN Config base ON base.ConfigID=baseSetting.ID +WHERE upload.Name="config_upload_path"; INSERT INTO Config (ConfigID, Value) SELECT ID, "tools/logs/" FROM ConfigSettings WHERE Name="log"; INSERT INTO Config (ConfigID, Value) SELECT ID, "/data/%PROJECTNAME%/bin/mri/" FROM ConfigSettings WHERE Name="MRICodePath"; INSERT INTO Config (ConfigID, Value) SELECT ID, "/data/incoming/" FROM ConfigSettings WHERE Name="MRIUploadIncomingPath"; diff --git a/SQL/New_patches/2026-07-24-configuration-uploader.sql b/SQL/New_patches/2026-07-24-configuration-uploader.sql new file mode 100644 index 0000000000..a421a68dd5 --- /dev/null +++ b/SQL/New_patches/2026-07-24-configuration-uploader.sql @@ -0,0 +1,66 @@ +ALTER TABLE ConfigSettings +MODIFY COLUMN DataType ENUM( + 'text', + 'boolean', + 'email', + 'instrument', + 'textarea', + 'scan_type', + 'date_format', + 'lookup_center', + 'path', + 'web_path', + 'log_level', + 'image' +); + +UPDATE ConfigSettings +SET DataType='image', + Description='Image displayed as the study logo' +WHERE Name='studylogo'; + +UPDATE ConfigSettings +SET DataType='image', + Description='Image displayed at the top left of the login page' +WHERE Name='login_logo_left'; + +UPDATE ConfigSettings +SET DataType='image', + Description='Image displayed at the top right of the login page' +WHERE Name='login_logo_right'; + +UPDATE ConfigSettings +SET DataType='image', + Description='Partner images displayed on the homepage' +WHERE Name='partner_logos'; + +INSERT INTO ConfigSettings ( + Name, + Description, + Visible, + AllowMultiple, + DataType, + Parent, + Label, + OrderNumber +) +SELECT + 'config_upload_path', + 'Path to images uploaded through the Configuration module', + 1, + 0, + 'web_path', + ID, + 'Configuration Upload Path', + 15 +FROM ConfigSettings +WHERE Name='paths'; + +INSERT INTO Config (ConfigID, Value) +SELECT + upload.ID, + CONCAT(TRIM(TRAILING '/' FROM base.Value), '/htdocs/images/configuration/') +FROM ConfigSettings upload +JOIN ConfigSettings baseSetting ON baseSetting.Name='base' +JOIN Config base ON base.ConfigID=baseSetting.ID +WHERE upload.Name='config_upload_path'; diff --git a/htdocs/images/configuration/.gitignore b/htdocs/images/configuration/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/htdocs/images/configuration/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/modules/configuration/css/configuration.css b/modules/configuration/css/configuration.css index 6d99b3ea75..0fefe2d22d 100644 --- a/modules/configuration/css/configuration.css +++ b/modules/configuration/css/configuration.css @@ -16,4 +16,8 @@ .btn-container { display: flex; justify-content: center; -} \ No newline at end of file +} + +.configuration-image-upload { + margin-top: 8px; +} diff --git a/modules/configuration/jsx/configuration_helper.js b/modules/configuration/jsx/configuration_helper.js index 010d406bea..e147aeee52 100644 --- a/modules/configuration/jsx/configuration_helper.js +++ b/modules/configuration/jsx/configuration_helper.js @@ -93,6 +93,62 @@ $(function() { }); }); + $('body').on('click', '.configuration-image-upload-button', function() { + let button = $(this); + let upload = button.closest('.configuration-image-upload'); + let input = upload.find('.configuration-image-file')[0]; + let file = input.files[0]; + + if (!file) { + swal.fire({ + text: 'Select an image to upload.', + type: 'warning', + }); + return; + } + + let data = new FormData(); + data.append('file', file); + button.prop('disabled', true); + + $.ajax({ + type: 'post', + url: loris.BaseURL + '/configuration/upload', + data: data, + processData: false, + contentType: false, + success: function(response) { + $('.configuration-image-select').each(function() { + let select = $(this); + let exists = select.find('option').filter(function() { + return $(this).val() === response.path; + }).length > 0; + if (!exists) { + select.append(new Option(response.path, response.path)); + } + }); + upload.prev('.configuration-image-select').val(response.path); + input.value = ''; + swal.fire({ + text: 'Image uploaded. Submit the form to save this selection.', + type: 'success', + }); + }, + error: function(xhr) { + let message = xhr.responseJSON && xhr.responseJSON.error + ? xhr.responseJSON.error + : 'The image could not be uploaded.'; + swal.fire({ + text: message, + type: 'error', + }); + }, + complete: function() { + button.prop('disabled', false); + }, + }); + }); + // On form submit, process the changes through an AJAX call $('form').on('submit', function(e) { e.preventDefault(); diff --git a/modules/configuration/php/configuration.class.inc b/modules/configuration/php/configuration.class.inc index d2de66893d..a8ff0498b5 100644 --- a/modules/configuration/php/configuration.class.inc +++ b/modules/configuration/php/configuration.class.inc @@ -94,6 +94,27 @@ class Configuration extends \NDB_Form 'PatientID' => 'PatientID', 'PatientName' => 'PatientName', ]; + + try { + $lorisRoot = $config->getSetting('base'); + $uploadDirectory = $config->getSetting('config_upload_path'); + if (!is_string($lorisRoot) || !is_string($uploadDirectory)) { + throw new \ConfigurationException( + 'The configuration image paths are invalid' + ); + } + $images = new ImageFiles( + $lorisRoot, + $uploadDirectory + ); + $this->tpl_data['image_options'] = $images->getOptions(); + $this->tpl_data['image_upload_enabled'] + = $images->getUploadDirectory()->isWritable(); + } catch (\ConfigurationException $e) { + $this->tpl_data['image_options'] = []; + $this->tpl_data['image_upload_enabled'] = false; + $this->tpl_data['image_upload_error'] = $e->getMessage(); + } } /** diff --git a/modules/configuration/php/imagefiles.class.inc b/modules/configuration/php/imagefiles.class.inc new file mode 100644 index 0000000000..c076188c45 --- /dev/null +++ b/modules/configuration/php/imagefiles.class.inc @@ -0,0 +1,191 @@ + ['gif'], + 'image/jpeg' => ['jpeg', 'jpg'], + 'image/png' => ['png'], + 'image/webp' => ['webp'], + ]; + + private string $webRoot; + private string $imagesRoot; + private string $uploadDirectory; + + /** + * Create an image file service. + * + * @param string $lorisRoot LORIS installation directory + * @param string $uploadDirectory Configuration image upload directory + */ + public function __construct(string $lorisRoot, string $uploadDirectory) + { + $webRoot = realpath(\Utility::pathJoin($lorisRoot, 'htdocs')); + $imagesRoot = realpath( + \Utility::pathJoin($lorisRoot, 'htdocs', 'images') + ); + $uploadDirectory = realpath($uploadDirectory); + + if ($webRoot === false || $imagesRoot === false) { + throw new \ConfigurationException( + 'The public images directory is not available' + ); + } + if ($uploadDirectory === false || !is_dir($uploadDirectory)) { + throw new \ConfigurationException( + 'The configuration upload directory is not available' + ); + } + if (!str_starts_with($uploadDirectory . '/', $imagesRoot . '/')) { + throw new \ConfigurationException( + 'The configuration upload directory must be under htdocs/images' + ); + } + + $this->webRoot = $webRoot; + $this->imagesRoot = $imagesRoot; + $this->uploadDirectory = $uploadDirectory; + } + + /** + * Return public paths for image files beneath htdocs/images. + * + * @return string[] + */ + public function getOptions(): array + { + $options = []; + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $this->imagesRoot, + \FilesystemIterator::SKIP_DOTS + ) + ); + + foreach ($files as $file) { + if (!$file instanceof \SplFileInfo + || !$file->isFile() + || !$this->_isDisplayableImage($file->getPathname()) + ) { + continue; + } + $options[] = $this->_getPublicPath($file->getPathname()); + } + + sort($options); + return $options; + } + + /** + * Return the upload directory for FilesUploadHandler. + * + * @return \SplFileInfo + */ + public function getUploadDirectory(): \SplFileInfo + { + return new \SplFileInfo($this->uploadDirectory); + } + + /** + * Check that an upload contains a supported image matching its extension. + * + * @param string $filename Client-provided filename + * @param string $filePath Temporary uploaded file path + * + * @return bool + */ + public function validateUpload(string $filename, string $filePath): bool + { + if (!$this->_validFilename($filename) || !is_file($filePath)) { + return false; + } + + $mime = mime_content_type($filePath); + if ($mime === false) { + return false; + } + + $extensions = self::UPLOAD_TYPES[$mime] ?? []; + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + return in_array($extension, $extensions, true); + } + + /** + * Return the public path for a file in the upload directory. + * + * @param string $filename Uploaded filename + * + * @return string + */ + public function getUploadedPath(string $filename): string + { + if (!$this->_validFilename($filename)) { + throw new \InvalidArgumentException('Invalid upload filename'); + } + return $this->_getPublicPath( + \Utility::pathJoin($this->uploadDirectory, $filename) + ); + } + + /** + * Check whether a client filename is safe and URL-friendly. + * + * @param string $filename Client-provided filename + * + * @return bool + */ + private function _validFilename(string $filename): bool + { + return !str_contains($filename, '..') + && preg_match('/^[[:alnum:]][[:alnum:]_.-]*$/', $filename) === 1; + } + + /** + * Check whether a file can be displayed as an image option. + * + * @param string $path Image path + * + * @return bool + */ + private function _isDisplayableImage(string $path): bool + { + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if ($extension === 'svg') { + $start = file_get_contents($path, false, null, 0, 8192); + return $start !== false + && preg_match('/)/i', $start) === 1; + } + + $mime = mime_content_type($path); + if ($mime === false) { + return false; + } + return in_array( + $extension, + self::UPLOAD_TYPES[$mime] ?? [], + true + ); + } + + /** + * Convert a file beneath htdocs to a URL path. + * + * @param string $path File path + * + * @return string + */ + private function _getPublicPath(string $path): string + { + $relative = ltrim(substr($path, strlen($this->webRoot)), '/'); + $parts = array_map('rawurlencode', explode('/', $relative)); + return '/' . implode('/', $parts); + } +} diff --git a/modules/configuration/php/upload.class.inc b/modules/configuration/php/upload.class.inc new file mode 100644 index 0000000000..29300feda7 --- /dev/null +++ b/modules/configuration/php/upload.class.inc @@ -0,0 +1,108 @@ +hasPermission('config'); + } + + /** + * Validate and store a configuration image. + * + * @param ServerRequestInterface $request The incoming request + * + * @return ResponseInterface + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + if ($request->getMethod() !== 'POST') { + return new \LORIS\Http\Response\JSON\MethodNotAllowed(['POST']); + } + + $file = $request->getUploadedFiles()['file'] ?? null; + if ($file === null || is_array($file)) { + return new \LORIS\Http\Response\JSON\BadRequest( + 'Select one image to upload' + ); + } + if ($file->getError() !== UPLOAD_ERR_OK) { + $message = (new \UploadException($file->getError()))->getMessage(); + return new \LORIS\Http\Response\JSON\InternalServerError( + "Upload error: $message" + ); + } + + $filename = $file->getClientFilename(); + $tempPath = $file->getStream()->getMetadata('uri'); + if (!is_string($filename) || !is_string($tempPath)) { + return new \LORIS\Http\Response\JSON\BadRequest( + 'The uploaded image is invalid' + ); + } + + $config = $this->loris->getConfiguration(); + try { + $lorisRoot = $config->getSetting('base'); + $uploadDirectory = $config->getSetting('config_upload_path'); + if (!is_string($lorisRoot) || !is_string($uploadDirectory)) { + throw new \ConfigurationException( + 'The configuration image paths are invalid' + ); + } + $images = new ImageFiles($lorisRoot, $uploadDirectory); + } catch (\ConfigurationException $e) { + return new \LORIS\Http\Response\JSON\InternalServerError( + $e->getMessage() + ); + } + + if (!$images->validateUpload($filename, $tempPath)) { + return new \LORIS\Http\Response\JSON\UnsupportedMediaType( + 'Upload a PNG, JPEG, GIF, or WebP image with a safe filename' + ); + } + + try { + $uploader = new \LORIS\FilesUploadHandler( + $images->getUploadDirectory() + ); + } catch (\ConfigurationException $e) { + return new \LORIS\Http\Response\JSON\InternalServerError( + $e->getMessage() + ); + } + + $response = $uploader->handle( + $request->withUploadedFiles(['file' => $file]) + ); + if ($response->getStatusCode() !== 201) { + return $response; + } + + return new \LORIS\Http\Response\JSON\Created( + [ + 'message' => 'Image uploaded successfully', + 'path' => $images->getUploadedPath($filename), + ] + ); + } +} diff --git a/modules/configuration/templates/form_configuration.tpl b/modules/configuration/templates/form_configuration.tpl index 2ed16e8a29..420f2330da 100644 --- a/modules/configuration/templates/form_configuration.tpl +++ b/modules/configuration/templates/form_configuration.tpl @@ -60,6 +60,48 @@ {/function} +{function name=createImage} + {$currentValue=$v|default:""} + {$currentOptionFound=false} + {$legacyPath="/"|cat:$currentValue} + +
+ + + + +
+ {if !$image_upload_enabled} +

+ {$image_upload_error|default:"The configuration upload directory is not writable."|escape:html} +

+ {/if} +{/function} + {function name=createLogDropdown}