$context[$matches[1]] ?? $matches[0], $message ); return $this->log($log); } /** * Get a command path. * * Returns the path to the provided command or boolean false if the command * is not found. * * @param string $command * @return string|false */ public function getCommandPath($command) { $command = sprintf('command -v %s', escapeshellarg($command)); return $this->execute($command); } /** * Execute a command. * * Expects arguments to be properly escaped. * * @param string $command An executable command * @return string|false The command's standard output or false on error */ public function execute($command) { if (function_exists('proc_open')) { return $this->procOpen($command); } elseif (function_exists('exec')) { return $this->exec($command); } else { return false; } } /** * Execute command using PHP's exec function. * * @link http://php.net/manual/en/function.exec.php * @param string $command * @return string|false */ public function exec($command) { $output = null; $exitCode = null; exec($command, $output, $exitCode); if (0 !== $exitCode) { $this->log(sprintf('Command "%s" failed with status code %s.', $command, $exitCode)); return false; } return implode(PHP_EOL, $output); } /** * Execute command using PHP's proc_open function. * * For servers that allow proc_open. Logs standard error. * * @link http://php.net/manual/en/function.proc-open.php * @param string $command * @return string|false */ public function procOpen($command) { $descriptorSpec = [ 0 => ['pipe', 'r'], // STDIN 1 => ['pipe', 'w'], // STDOUT 2 => ['pipe', 'w'], // STDERR ]; $pipes = []; $proc = proc_open($command, $descriptorSpec, $pipes, getcwd()); if (!is_resource($proc)) { return false; } // Set non-blocking mode on STDOUT and STDERR. stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); // Poll STDOUT and STDIN in a loop, waiting for EOF. We do this to avoid // issues with stream_get_contents() where either stream could hang. $output = ''; $errors = ''; while (!feof($pipes[1]) || !feof($pipes[2])) { // Sleep to avoid tight busy-looping on the streams usleep(25000); if (!feof($pipes[1])) { $output .= stream_get_contents($pipes[1]); } if (!feof($pipes[2])) { $errors .= stream_get_contents($pipes[2]); } } foreach ($pipes as $pipe) { fclose($pipe); } $exitCode = proc_close($proc); if (0 !== $exitCode) { // Log standard error if any. if (strlen($errors)) { $this->log($errors); } $this->log(sprintf('Command "%s" failed with status code %s.', $command, $exitCode)); return false; } return trim($output); } /** * Helper to download a file. * * @param string $source * @param string $destination * @return bool */ public function downloadFile($source, $destination): bool { // Only allow http/https to prevent local file reads via file://, // php://, etc. $scheme = parse_url($source, PHP_URL_SCHEME); if (!in_array($scheme, ['http', 'https'])) { return false; } // Restrict to trusted hosts to prevent SSRF. The suffix match covers // subdomains such as raw/objects/codeload.githubusercontent.com and // api.github.com. $host = strtolower((string) parse_url($source, PHP_URL_HOST)); $trustedHosts = [ 'github.com', 'githubusercontent.com', 'gitlab.com', 'omeka.org', ]; $isTrusted = false; foreach ($trustedHosts as $trusted) { if ($host === $trusted || str_ends_with($host, '.' . $trusted)) { $isTrusted = true; break; } } if (!$isTrusted) { return false; } // Limit download size to 200 MB to prevent disk exhaustion. $maxSize = 200 * 1024 * 1024; $handle = @fopen($source, 'rb'); if (empty($handle)) { return false; } $destHandle = @fopen($destination, 'wb'); if (!$destHandle) { @fclose($handle); return false; } $written = 0; while (!feof($handle)) { $chunk = @fread($handle, 8192); if ($chunk === false) { break; } $written += strlen($chunk); if ($written > $maxSize) { @fclose($handle); @fclose($destHandle); @unlink($destination); return false; } fwrite($destHandle, $chunk); } @fclose($handle); @fclose($destHandle); return $written > 0; } /** * Helper to unzip a file. * * @param string $source A local file. * @param string $destination A writeable dir. * @return bool */ public function unzipFile($source, $destination): bool { // Unzip via php-zip. if (class_exists('ZipArchive', false)) { $zip = new ZipArchive; $result = $zip->open($source); if ($result === true) { // Validate entries to prevent zip-slip (path traversal). for ($i = 0; $i < $zip->numFiles; $i++) { $entryName = $zip->getNameIndex($i); if ($entryName === false || strpos($entryName, '..') !== false || strpos($entryName, '/') === 0 || strpos($entryName, '\\') === 0 || preg_match('/^[a-zA-Z]:/', $entryName) ) { $zip->close(); $this->log('Archive zip invalide (chemin non conforme).'); return false; } } // Suppress harmless "File exists" warnings emitted by some // archives (duplicate directory entries) without losing the // boolean return value. $result = @$zip->extractTo($destination); $zip->close(); } else { $zipErrors = [ ZipArchive::ER_EXISTS => 'File already exists', ZipArchive::ER_INCONS => 'Zip archive inconsistent', ZipArchive::ER_INVAL => 'Invalid argument', ZipArchive::ER_MEMORY => 'Malloc failure', ZipArchive::ER_NOENT => 'No such file', ZipArchive::ER_NOZIP => 'Not a zip archive', ZipArchive::ER_OPEN => 'Can’t open file', ZipArchive::ER_READ => 'Read error', ZipArchive::ER_SEEK => 'Seek error', ]; $this->log(sprintf('Erreur lors de la décompression : %s', $zipErrors[$result] ?? 'Other zip error')); $result = false; } } // Unzip via command line else { // Check if the unzip command exists. $unzipPath = $this->getCommandPath('unzip'); if ($unzipPath === false) { $this->log('La commande unzip n’est pas disponible.'); return false; } $command = 'unzip ' . escapeshellarg($source) . ' -d ' . escapeshellarg($destination); $result = $this->execute($command); if ($result === false) { return false; } } return $result; } /** * Get the root directory name from a zip file. * * @param string $zipPath Path to the zip file. * @return string|null The root directory name, or null if not found. */ public function getZipRootDir(string $zipPath): ?string { if (!class_exists('ZipArchive', false)) { // Fallback: use unzip -l command $unzipPath = $this->getCommandPath('unzip'); if ($unzipPath === false) { return null; } $command = 'unzip -l ' . escapeshellarg($zipPath) . ' 2>/dev/null | head -n 5'; $output = $this->execute($command); if ($output === false) { return null; } // Parse the output to find the first directory foreach (explode("\n", $output) as $line) { if (preg_match('/\s+(\S+)\/$/', $line, $matches)) { return rtrim($matches[1], '/'); } } return null; } $zip = new ZipArchive(); if ($zip->open($zipPath) !== true) { return null; } // Get the first entry which is typically the root directory $firstEntry = $zip->getNameIndex(0); $zip->close(); if ($firstEntry === false) { return null; } // Extract the root directory name (before the first /) $parts = explode('/', $firstEntry); return $parts[0] ?: null; } /** * Move all files and dirs from a directory to another one. */ public function moveFilesFromDirToDir($source, $destination): bool { if ($source === $destination) { return true; } if (!file_exists($source) || !is_dir($source) || !is_readable($source) || !is_writeable($source)) { return false; } if (!file_exists($destination)) { mkdir($destination, 0775, true); } if (!file_exists($destination) || !is_dir($destination) || !is_writeable($destination)) { return false; } // Since rename() moves all the contents of a directory, only the root // files and dirs needs to be processed. $filesOrDirs = array_diff(scandir($source) ?: [], ['.', '..']); $result = true; foreach ($filesOrDirs as $fileOrDir) { $sourceFileOrDir = $source . DIRECTORY_SEPARATOR . $fileOrDir; $destinationFileOrDir = $destination . DIRECTORY_SEPARATOR . $fileOrDir; $result = rename($sourceFileOrDir, $destinationFileOrDir); if (!$result) { $this->log(sprintf('Impossible de déplacer %1$s', $fileOrDir)); break; } } return $result; } /** * Remove all files and dirs of a dir from the filesystem. * * It does not remove the passed dir. * * @param string $dirpath Absolute path. * @param array $except Absolute paths. Only root paths can be skipped. */ public function removeFilesAndDirsInDir(string $dirPath, array $except = []): bool { if (!file_exists($dirPath)) { return true; } if ($dirPath === '/' || strpos($dirPath, '/..') !== false || substr($dirPath, 0, 1) !== '/' ) { return false; } // Process the first level here and use rmDir for other ones. $filesOrDirs = array_diff(scandir($dirPath) ?: [], ['.', '..']); foreach ($filesOrDirs as $fileOrDir) { $path = $dirPath . '/' . $fileOrDir; if (in_array($path, $except)) { continue; } if (is_dir($path)) { $this->rmDir($path); } else { unlink($path); } } return true; } /** * Remove a dir from filesystem. * * @param string $dirpath Absolute path. */ public function rmDir(string $dirPath): bool { if (!file_exists($dirPath)) { return true; } if ($dirPath === '/' || strpos($dirPath, '/..') !== false || substr($dirPath, 0, 1) !== '/' ) { return false; } $files = array_diff(scandir($dirPath) ?: [], ['.', '..']); foreach ($files as $file) { $path = $dirPath . '/' . $file; if (is_dir($path)) { $this->rmDir($path); } else { unlink($path); } } return rmdir($dirPath); } } /** * Version simplifiée de Easy Admin Addons. * * @see \EasyAdmin\Mvc\Controller\Plugin\Addons */ class Addons { /** * Repository owner and name for addon lists. * Can be overridden to use a fork. */ public const ADDON_LIST_REPO = 'Daniel-KM/UpgradeToOmekaS'; /** * Branch to use for addon lists. * Can be 'master', 'main', 'develop', etc. */ public const ADDON_LIST_BRANCH = 'master'; /** * @var Utils */ protected $utils; /** * Source of data and destination of addons. * URLs are built dynamically using ADDON_LIST_REPO and ADDON_LIST_BRANCH. * * @var array */ protected $data = []; /** * Default data sources. */ protected function getDefaultData(): array { $baseUrl = 'https://raw.githubusercontent.com/' . self::ADDON_LIST_REPO . '/' . self::ADDON_LIST_BRANCH . '/_data/'; return [ 'omekamodule' => [ 'source' => 'https://omeka.org/add-ons/json/s_module.json', 'destination' => '/modules', ], 'omekatheme' => [ 'source' => 'https://omeka.org/add-ons/json/s_theme.json', 'destination' => '/themes', ], 'module' => [ 'source' => $baseUrl . 'omeka_s_modules.json', 'destination' => '/modules', ], 'theme' => [ 'source' => $baseUrl . 'omeka_s_themes.json', 'destination' => '/themes', ], ]; } /** * Cache for the list of addons. * * @var array */ protected $addons = []; /** * Cache for the list of selections. * * @var array */ protected $selections = []; public function __construct(?Utils $utils = null) { $this->utils = $utils ?? new Utils(); $this->data = $this->getDefaultData(); } public function __invoke(): self { return $this; } public function getAddons(bool $refresh = false): array { $this->initAddons($refresh); return $this->addons; } /** * Get curated selections of modules from the web. */ public function getSelections(): array { static $list = []; if ($list) { $this->selections = $list; return $list; } $this->selections = []; $selectionsUrl = 'https://raw.githubusercontent.com/' . self::ADDON_LIST_REPO . '/' . self::ADDON_LIST_BRANCH . '/_data/omeka_s_selections.csv'; $csv = @file_get_contents($selectionsUrl); if ($csv) { // Get the column for name and modules. $headers = []; $isFirst = true; foreach (explode("\n", $csv) as $row) { $row = str_getcsv($row) ?: []; if ($isFirst) { $headers = array_flip($row); $isFirst = false; } elseif ($row) { $name = $row[$headers['Name']] ?? ''; if ($name) { $dirs = explode(',', $row[$headers['Modules and themes']] ?? ''); $this->selections[$name] = array_values(array_filter(array_map( fn ($v) => str_replace(' ', '', trim($v)), $dirs ))); } } } } return $list = $this->selections; } /** * Get the list of default types. */ public function types(): array { return array_keys($this->data); } /** * Get addon data from the namespace of the module. */ public function dataFromNamespace(string $namespace, ?string $type = null): array { $listAddons = $this->getAddons(); $list = $type ? (isset($listAddons[$type]) ? [$type => $listAddons[$type]] : []) : $listAddons; foreach ($list as $type => $addonsForType) { $addonsUrl = array_column($addonsForType, 'url', 'dir'); if (isset($addonsUrl[$namespace]) && isset($addonsForType[$addonsUrl[$namespace]])) { return $addonsForType[$addonsUrl[$namespace]]; } } return []; } /** * Get addon data from the url of the repository. */ public function dataFromUrl(string $url, string $type): array { $listAddons = $this->getAddons(); return $listAddons && isset($listAddons[$type][$url]) ? $listAddons[$type][$url] : []; } /** * Check if an addon is installed. * * @param array $addon */ public function dirExists($addon): bool { $destination = OMEKA_PATH . $this->data[$addon['type']]['destination']; $existings = $this->listDirsInDir($destination); $existings = array_map('strtolower', $existings); return in_array(strtolower($addon['dir']), $existings) || in_array(strtolower($addon['basename']), $existings); } protected function initAddons(bool $refresh = false): self { static $lists; if ($lists) { $this->addons = $lists; return $this; } $this->addons = []; foreach ($this->types() as $addonType) { $this->addons[$addonType] = $this->listAddonsForType($addonType); } $lists = $this->addons; return $this; } /** * Helper to list the addons from a web page. * * @param string $type */ protected function listAddonsForType($type): array { if (!isset($this->data[$type]['source'])) { return []; } $source = $this->data[$type]['source']; $content = $this->fileGetContents($source); if (empty($content)) { return []; } switch ($type) { case 'module': case 'theme': case 'omekamodule': case 'omekatheme': return $this->extractAddonListFromOmeka($content, $type); } } /** * Helper to get content from an external url. * * @param string $url */ protected function fileGetContents($url): ?string { return file_get_contents($url) ?: null; } /** * Helper to parse json to get urls and names of addons. * * The omeka.org api and the UpgradeToOmekaS json lists share the same * schema (name => {dirname, owner, repo, latest_version, versions}). * * Note: the json lists don't include dependency information. Curated * selections must enumerate every needed addon (Common and Generic are * always added). * * @param string $json * @param string $type */ protected function extractAddonListFromOmeka($json, $type): array { $list = []; $addonsList = json_decode($json, true); if (!$addonsList) { return []; } foreach ($addonsList as $name => $data) { if (!$data) { continue; } $version = $data['latest_version']; $host = $data['host'] ?? 'github.com'; $url = 'https://' . $host . '/' . $data['owner'] . '/' . $data['repo']; // Fallback when no download_url: use a host-specific archive url. // The url with the default branch may not include dependencies. $zip = $data['versions'][$version]['download_url'] ?? $this->fallbackArchiveUrl($host, $url, $data['repo'], $version); // Build a sorted list of versions with their omeka compatibility // constraint (descending, latest first). $versions = []; foreach (($data['versions'] ?? []) as $v => $vData) { $versions[$v] = [ 'version' => $v, 'omeka_version_constraint' => $vData['omeka_version_constraint'] ?? '', 'download_url' => $vData['download_url'] ?? '', ]; } uksort($versions, 'version_compare'); $versions = array_reverse($versions, true); $addon = []; $addon['type'] = in_array($type, ['module', 'omekamodule'], true) ? 'module' : 'theme'; $addon['server'] = strpos($type, 'omeka') === 0 ? 'omeka.org' : $host; $addon['name'] = $name; $addon['basename'] = $data['dirname']; $addon['dir'] = $data['dirname']; $addon['version'] = $version; $addon['versions'] = $versions; $addon['url'] = $url; $addon['zip'] = $zip; $addon['dependencies'] = []; $list[$url] = $addon; } return $list; } /** * Pick the latest version compatible with the current Omeka S. Returns null * if no version is compatible, or if the addon has no versions metadata. * * @param array $addon */ protected function pickCompatibleVersion(array $addon): ?array { if (empty($addon['versions']) || !is_array($addon['versions'])) { return null; } $omekaVersion = Utils::OMEKA_VERSION; foreach ($addon['versions'] as $v => $vData) { $constraint = (string) ($vData['omeka_version_constraint'] ?? ''); if ($constraint === '' || $this->satisfiesConstraint($omekaVersion, $constraint) ) { return ['version' => $v] + $vData; } } return null; } /** * version_compare-based constraint check (no Composer dependency). Supports * comma/space AND groups, "||" OR groups, hyphen ranges, and the operators * >=, <=, !=, =, >, <, ^ and ~. Returns false when it cannot be parsed. * * @param string $version * @param string $constraint */ protected function satisfiesConstraint(string $version, string $constraint): bool { $constraint = trim($constraint); if ($constraint === '' || $constraint === '*') { return true; } foreach (preg_split('~\s*\|\|\s*~', $constraint) as $orPart) { $orPart = trim($orPart); if ($orPart === '') { continue; } if (preg_match('~^(?\d[\d.]*)\s*-\s*(?\d[\d.]*)$~', $orPart, $m)) { if (version_compare($version, $m['a'], '>=') && version_compare($version, $m['b'], '<=')) { return true; } continue; } $allOk = true; foreach (preg_split('~[\s,]+~', $orPart) as $clause) { if ($clause === '') { continue; } if (!preg_match('#^(?>=|<=|<>|!=|==|=|>|<|\^|~)?\s*(?\d[\d.A-Za-z\-+]*)$#', $clause, $m)) { $allOk = false; break; } $op = $m['op'] ?: '='; $v = $m['v']; if ($op === '^' || $op === '~') { $parts = explode('.', $v); $major = (int) ($parts[0] ?? 0); $minor = (int) ($parts[1] ?? 0); $upper = $op === '^' ? ($major + 1) . '.0.0' : $major . '.' . ($minor + 1) . '.0'; if (!version_compare($version, $v, '>=') || !version_compare($version, $upper, '<')) { $allOk = false; break; } continue; } $cmpOp = $op === '=' ? '==' : $op; if (!version_compare($version, $v, $cmpOp)) { $allOk = false; break; } } if ($allOk) { return true; } } return false; } /** * Build an archive url for the default branch when no release zip is * available. Handles github, gitlab, and a sensible default. * * @param string $host * @param string $url * @param string $repo * @param string $version */ protected function fallbackArchiveUrl(string $host, string $url, string $repo, string $version = 'master'): string { $branch = $version ?: 'master'; switch (strtolower($host)) { case 'github.com': return $url . '/archive/refs/heads/' . $branch . '.zip'; case 'gitlab.com': default: if (strpos($host, 'gitlab') !== false) { return $url . '/-/archive/' . $branch . '/' . $repo . '-' . $branch . '.zip'; } return $url . '/archive/' . $branch . '.zip'; } } /** * Helper to install an addon. * * @param array $addon The addon data. * @return bool */ public function installAddon(array $addon): bool { switch ($addon['type']) { case 'module': $destination = OMEKA_PATH . '/modules'; $type = 'module'; break; case 'theme': $destination = OMEKA_PATH . '/themes'; $type = 'theme'; break; default: return false; } if (file_exists($destination . DIRECTORY_SEPARATOR . $addon['dir'])) { return true; } // Pick the latest version compatible with the current Omeka S. $compatible = $this->pickCompatibleVersion($addon); if ($compatible) { $addon['version'] = $compatible['version']; $addon['zip'] = $compatible['download_url'] ?: $addon['zip']; } elseif (!empty($addon['versions'])) { $this->utils->psrLog( 'Aucune version du {type} "{name}" n’est compatible avec cette version d’Omeka S.', // @translate ['type' => $type, 'name' => $addon['name']] ); return false; } $zipFile = $destination . DIRECTORY_SEPARATOR . basename($addon['zip']); // Get the zip file from server. $result = $this->utils->downloadFile($addon['zip'], $zipFile); if (!$result) { $this->utils->psrLog( 'Impossible de télécharger le {type} "{name}".', // @translate ['type' => $type, 'name' => $addon['name']] ); return false; } // Get the root directory name from the zip before extracting. $extractedDir = $this->utils->getZipRootDir($zipFile); // Unzip downloaded file. $result = $this->utils->unzipFile($zipFile, $destination); unlink($zipFile); if (!$result) { $this->utils->psrLog( 'Une erreur s’est produite durant la décompression du {type} "{name}".', // @translate ['type' => $type, 'name' => $addon['name']] ); return false; } // Move the addon to its destination. $this->moveAddon($addon, $extractedDir); return true; } /** * Helper to rename the directory of an addon. * * @param array $addon The addon data. * @param string|null $extractedDir The directory name extracted from zip, if available. * @return bool */ protected function moveAddon(array $addon, ?string $extractedDir = null): bool { switch ($addon['type']) { case 'module': $destination = OMEKA_PATH . '/modules'; break; case 'theme': $destination = OMEKA_PATH . '/themes'; break; default: return false; } $path = $destination . DIRECTORY_SEPARATOR . $addon['dir']; // If we have the extracted directory name from the zip, use it directly. if ($extractedDir !== null) { $source = $destination . DIRECTORY_SEPARATOR . $extractedDir; if (file_exists($source)) { if ($source === $path) { return true; } return rename($source, $path); } } // Fallback: scan directory and find newly created folder matching addon name. // Use refresh=true to see newly extracted directories. $existingDirs = $this->listDirsInDir($destination, true); $addonNames = [$addon['dir']]; if ($addon['basename'] !== $addon['dir']) { $addonNames[] = $addon['basename']; } // Try to find a directory that contains the addon name (case-insensitive). $source = ''; foreach ($existingDirs as $dir) { $dirLower = strtolower($dir); foreach ($addonNames as $addonName) { $nameLower = strtolower($addonName); // Check if directory contains the addon name. if (strpos($dirLower, $nameLower) !== false) { $sourceCheck = $destination . DIRECTORY_SEPARATOR . $dir; if (file_exists($sourceCheck) && is_dir($sourceCheck)) { $source = $sourceCheck; break 2; } } } } if ($source === '') { return false; } if ($source === $path) { return true; } return rename($source, $path); } /** * List directories in a directory, not recursively. * * @param string $dir * @param bool $refresh Force a fresh scan, ignoring cache. */ protected function listDirsInDir($dir, bool $refresh = false): array { static $dirs; if (!$refresh && isset($dirs[$dir])) { return $dirs[$dir]; } if (empty($dir) || !file_exists($dir) || !is_dir($dir) || !is_readable($dir)) { return []; } $list = array_filter(array_diff(scandir($dir), ['.', '..']), fn ($file) => is_dir($dir . DIRECTORY_SEPARATOR . $file)); $dirs[$dir] = $list; return $dirs[$dir]; } } // La plupart des tests sont indépendants, sauf pour la base de données qui // nécessite un test sur php. // On peut donc afficher la plupart des problèmes en une seule fois. $utils = new Utils(); $addons = new Addons($utils); $isValid = true; $isSystemValid = true; $isPhpValid = true; $isDatabaseValid = true; // $currentMod = null; // $requireChmod = false; $failed = false; // Use $_SERVER instead of INPUT_SERVER for tests. $isPost = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST'; if ($isPost) { // TODO Mysqli permet aussi d’utiliser directement les paramètres définis par défaut, mais c’est rare, et de toute façon Omeka utilise pdo. $host = trim((string) filter_input(INPUT_POST, 'host')); $port = intval(trim((string) filter_input(INPUT_POST, 'port'))) ?: null; $socket = trim((string) filter_input(INPUT_POST, 'socket')) ?: null; $dbname = trim((string) filter_input(INPUT_POST, 'dbname')); $user = trim((string) filter_input(INPUT_POST, 'user')); $password = trim((string) filter_input(INPUT_POST, 'password')); $locale = trim((string) filter_input(INPUT_POST, 'locale')); $logLevel = filter_input(INPUT_POST, 'log_level'); $selection = trim((string) filter_input(INPUT_POST, 'selection')); } else { $host = 'localhost'; $port = ''; $socket = ''; $dbname = ''; $user = ''; $password = ''; // Omeka. $locale = $utils::OMEKA_LOCALE; $logLevel = $utils::OMEKA_LOG_LEVEL; $selection = ''; } // Test du système de fichiers. // Vérification des droits d'écriture par le serveur web. $isReadableAndWriteable = is_readable(__DIR__) && is_writeable(__DIR__); if (!$isReadableAndWriteable) { // $currentMod = substr(sprintf('%o', fileperms(__DIR__)), -4); try { $result = chmod(__DIR__, 0775); if (!$result || !is_readable(__DIR__) || !is_writeable(__DIR__)) { throw new Exception(); } // $requireChmod = true; $isReadableAndWriteable = true; } catch (Exception $e) { $isReadableAndWriteable = false; $isSystemValid = false; $utils->log('Le serveur web n’a pas les droits d’écriture dans le dossier en cours.'); } } // Test de l’écriture réelle du fichier, normalement inutile. if ($isReadableAndWriteable) { $randomFile = substr(strtr(base64_encode(random_bytes(128)), ['+' => '', '/' => '', '=' => '']), 0, 8); $randomPath = __DIR__ . '/' . $randomFile; try { $result = file_put_contents($randomPath, 'test'); if (!$result) { throw new Exception(); } unlink($randomPath); } catch (Exception $e) { $isSystemValid = false; $utils->log('Le serveur web ne peut pas écrire de fichier dans le dossier en cours.'); } } // Test si le dossier ne contient pas déjà une installation Omeka. $result = scandir(__DIR__); if ($result === false) { $isSystemValid = false; $utils->log('Le serveur web ne peut pas compter les fichiers existants dans le dossier en cours.'); } else { // Check for existing Omeka files/directories instead of requiring empty dir. $omekaFiles = ['application', 'config', 'modules', 'themes', 'files', '.htaccess', 'index.php']; $existingOmekaFiles = array_intersect($result, $omekaFiles); if (!empty($existingOmekaFiles)) { $isSystemValid = false; $utils->log('Le dossier contient des fichiers Omeka : ' . implode(', ', $existingOmekaFiles) . '.'); } } // Vérification complémentaire : unzip ou l’extension zip doivent être // installés pour décompresser le fichier zip. if (!extension_loaded('zip')) { try { $command = $utils->getCommandPath('unzip'); if (!$command) { throw new Exception; } } catch (Exception $e) { $isSystemValid = false; $utils->log('L’extension php « zip » ou la commande « unzip » doivent être disponibles pour décompresser Omeka.'); } } // Test si le serveur a accès à internet. // Use omeka-s repository to verify GitHub connectivity. $fileToDownload = 'https://raw.githubusercontent.com/omeka/omeka-s/develop/README.md'; try { $result = $utils->downloadFile($fileToDownload, __DIR__ . '/README.md'); if (!$result) { throw new Exception; } unlink(__DIR__ . '/README.md'); } catch (Exception $e) { $isSystemValid = false; $utils->log('Impossible de télécharger un fichier depuis le site github.com.'); } // Test de la version php. if (PHP_VERSION_ID < Utils::PHP_MINIMUM_VERSION_ID) { $isPhpValid = false; $utils->log(sprintf( 'La version de PHP %1$s ne permet pas d’installer la dernière version d’Omeka S, qui requiert %2$s. ', PHP_VERSION, Utils::PHP_MINIMUM_VERSION )); } // Vérifcation des extensions php. $result = []; foreach (Utils::PHP_REQUIRED_EXTENSIONS as $extension) { if (!extension_loaded($extension)) { $result[] = $extension; } } if (count($result)) { $isPhpValid = false; $utils->log(sprintf( 'La version de PHP %1$s permet d’installer la dernière version d’Omeka S, mais il manque %2$s sur le serveur : %3$s.', PHP_VERSION, count($result) === 1 ? 'l’extension php suivante' : 'les extensions php suivantes', implode(', ', $result) )); if (extension_loaded('intl')) { $utils->log('L’extension php « intl » est également recommandée pour une meilleure prise en charge des langues.'); } } $isValid = $isValid && $isSystemValid && $isPhpValid; // Vérification de la base de données (requiert php et les informations sur la base). // On peut tester la base même s'il y a des problèmes dans le système de fichiers. if ($isPhpValid && $isPost) { if (!preg_match('/^[\w.-]*$/', $host)) { $isDatabaseValid = false; $utils->log('Le nom d’hôte n’est pas conforme.'); } if ($socket && !preg_match('~^[\w./-]*$~', $socket)) { $isDatabaseValid = false; $utils->log('Le socket n’est pas conforme.'); } if ($socket && $host) { $isDatabaseValid = false; $utils->log('Il n’est pas possible de spécifier à la fois l’hôte et le socket.'); } if (!preg_match('/^[\w.-]*$/', $dbname)) { $isDatabaseValid = false; $utils->log('Le nom de la base n’est pas conforme.'); } if (!preg_match('/^[\w.-]*$/', $user)) { $isDatabaseValid = false; $utils->log('Le nom de l’utilisateur n’est pas conforme.'); } if (!$password) { $isDatabaseValid = false; $utils->log('Le mot de passe est vide.'); } // Vérifier complémentaire si l’utilisateur existe (via mysqli si disponible). if ($isDatabaseValid) { $dsn = $socket ? "mysql:unix_socket=$socket;charset=utf8mb4" : "mysql:host=$host" . ($port ? ";port=$port" : '') . ';charset=utf8mb4'; $dbOptions = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]; try { $pdo = new PDO($dsn, $user, $password, $dbOptions); if (!$pdo) { $isDatabaseValid = false; $utils->log('La configuration du serveur ou de l’utilisateur (nom/mot de passe) n’est pas correcte.'); } else { $pdo = null; } } catch (Exception $e) { $isDatabaseValid = false; $utils->log('La configuration du serveur ou de l’utilisateur (nom/mot de passe) n’est pas correcte.'); } } // Vérifier la version de mysql/mariadb. if ($isDatabaseValid) { $dsn = $socket ? "mysql:unix_socket=$socket;charset=utf8mb4" : "mysql:host=$host" . ($port ? ";port=$port" : '') . ';charset=utf8mb4'; $dbOptions = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]; try { $pdo = new PDO($dsn, $user, $password, $dbOptions); $sql = 'SELECT VERSION();'; $stmt = $pdo->query($sql); $dbVersion = $stmt->fetchColumn(); $stmt = null; $pdo = null; if (strpos($dbVersion, 'MariaDB') === false) { if (!version_compare($dbVersion, Utils::MYSQL_MINIMUM_VERSION, '>=')) { $isDatabaseValid = false; $utils->log(sprintf( 'La version de MySQL (%1$s) est inférieure à la version nécessaire pour Omeka (%2$s).', $dbVersion, Utils::MYSQL_MINIMUM_VERSION )); } } else { if (!version_compare($dbVersion, Utils::MARIADB_MINIMUM_VERSION, '>=')) { $isDatabaseValid = false; $utils->log(sprintf( 'La version de MariaDB (%1$s) est inférieure à la version nécessaire pour Omeka (%2$s).', $dbVersion, Utils::MARIADB_MINIMUM_VERSION )); } } } catch (Exception $e) { // La base de données n’est pas disponible pour l’utilisateur ou n’existe pas. $isDatabaseValid = false; $utils->log('La base de données est incorrecte.'); } } // Vérifier si la base est vide ou créer une base vide. if ($isDatabaseValid) { // Vérifie si la base existe. $dsn = $socket ? "mysql:unix_socket=$socket;charset=utf8mb4" : "mysql:host=$host" . ($port ? ";port=$port" : '') . ';charset=utf8mb4'; $dbOptions = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]; try { $pdo = new PDO($dsn, $user, $password, $dbOptions); $sql = sprintf( 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = %s;', $pdo->quote($dbname) ); $stmt = $pdo->query($sql); $hasDatabase = (bool) $stmt->fetchColumn(); $stmt = null; $pdo = null; } catch (Exception $e) { // La base de données n’est pas disponible pour l’utilisateur ou n’existe pas. $hasDatabase = false; } if ($hasDatabase) { // Vérifier si la base est bien vide. $dsnb = $socket ? "mysql:unix_socket=$socket;dbname=$dbname;charset=utf8mb4" : "mysql:host=$host" . ($port ? ";port=$port" : '') . ";dbname=$dbname;charset=utf8mb4"; try { $pdo = new PDO($dsnb, $user, $password, $dbOptions); $stmt = $pdo->query('SHOW TABLES;'); $hasTables = (bool) $stmt->fetchColumn(); $stmt = null; $pdo = null; if ($hasTables) { $isDatabaseValid = false; $utils->log('La base de données doit être vide.'); } else { $isDatabaseValid = true; } } catch (Exception $e) { $isDatabaseValid = false; $utils->log('Impossible de vérifier si la base de données est vide.'); } } else { // Sinon créer la base. try { $pdo = new PDO($dsn, $user, $password, $dbOptions); // Avec "create database", le nom de la base ne doit pas être quote(), mais « ` » si besoin. $sql = sprintf('CREATE DATABASE `%s`;', $dbname); $result = $pdo->exec($sql); $pdo = null; if ($result === false) { $isDatabaseValid = false; $utils->log('Impossible de créer la base de données.'); } else { $isDatabaseValid = true; } } catch (Exception $e) { $pdo = null; $isDatabaseValid = false; $utils->log('Impossible de créer la base de données : ' . $e->getMessage()); } } } } $isValid = $isValid && $isSystemValid && $isPhpValid && $isDatabaseValid; // Préparation des sélections si l’environnement est valide. $selections = $isSystemValid && $isPhpValid ? $addons->getSelections() : []; // Preparation de l’installation. if ($isValid && $isPost) { // Télécharger le zip. $fileToDownload = sprintf('https://github.com/omeka/omeka-s/releases/download/v%1$s/omeka-s-%1$s.zip', Utils::OMEKA_VERSION); $zipFile = __DIR__ . '/omeka-s.zip'; $result = $utils->downloadFile($fileToDownload, $zipFile); if (!$result) { $failed = true; if (file_exists($zipFile)) { unlink($zipFile); } $utils->log('Impossible de télécharger le fichier.'); } // Décompresser le zip dans le dossier en cours. if (!$failed) { $result = $utils->unzipFile($zipFile, __DIR__); if (!$result) { $failed = true; $utils->log('Impossible de décompresser le fichier.'); // On supprime tous les fichiers créés, notamment .htaccess. $utils->removeFilesAndDirsInDir(__DIR__, [__FILE__]); } // Dans tous les cas, on supprime le fichier téléchargé. unlink($zipFile); } // Déplacer les fichiers depuis le dossier vers la racine du dossier en cours. if (!$failed) { // Le sous-dossier dans le zip est toujours « omeka-s ». $sourceDir = __DIR__ . '/omeka-s'; $result = $utils->moveFilesFromDirToDir($sourceDir, __DIR__); if (!$result) { $failed = true; $utils->log('Impossible de déplacer les fichiers.'); $utils->removeFilesAndDirsInDir(__DIR__, [__FILE__]); } $utils->rmDir(__DIR__ . '/omeka-s'); } // Copier la configuration de la base de données dans database.ini. if (!$failed) { $dbIni = <<log('Impossible de créer le fichier config/database.ini.'); $utils->removeFilesAndDirsInDir(__DIR__, [__FILE__]); } } // Modifier les droits des dossiers et fichiers. // Normalement inutile. if (!$failed) { $chmodPaths = [ __DIR__ . '/files', __DIR__ . '/logs/application.log', __DIR__ . '/logs/sql.log', ]; foreach ($chmodPaths as $chmodPath) { if (file_exists($chmodPath) && !@chmod($chmodPath, 0775)) { $utils->log(sprintf('Impossible de modifier les droits de %s.', basename($chmodPath))); } } } // Modifier config. if (!$failed) { // var_export() ne peut pas être utilisé car il y a des constantes de // classes non disponibles et qu'en tout état de cause il est préférable // de conserver telles quelles. $configPath = __DIR__ . '/config/local.config.php'; $config = file_get_contents($configPath); if ($locale) { $config = str_replace("'locale' => 'en_US',", sprintf("'locale' => '%s',", $locale), $config); } if ($logLevel !== 'none') { $config = str_replace("'log' => false,", "'log' => true,", $config); $config = str_replace( "'priority' => \Laminas\Log\Logger::NOTICE,", sprintf("'priority' => \Laminas\Log\Logger::%s,", $logLevel), $config ); } file_put_contents($configPath, $config); } } $isFinalized = $isValid && $isPost && !$failed; if ($isFinalized) { $selectionAddons = $selection ? $selections[$selection] ?? [] : []; /** @see \EasyAdmin\Job\ManageAddons */ if ($selectionAddons) { // Initialisation des listes. $addons->getAddons(); $unknowns = []; $existings = []; $errors = []; $installeds = []; // Eviter les problèmes. $selectionAddons = array_unique(array_merge(['Common', 'Generic'], array_values($selectionAddons))); foreach ($selectionAddons as $addonName) { $addon = $addons->dataFromNamespace($addonName); if (!$addon) { $unknowns[] = $addonName; } elseif ($addons->dirExists($addon)) { $existings[] = $addonName; } else { $result = $addons->installAddon($addon); if ($result) { $installeds[] = $addonName; } else { $errors[] = $addonName; } } } if (count($unknowns)) { $failed = true; $isFinalized = false; $utils->psrLog( 'Les modules suivants de la sélection sont inconnus : {addons}.', // @translate ['addons' => implode(', ', $unknowns)] ); } if (count($existings)) { $utils->psrLog( 'Les modules suivants sont déjà installés : {addons}.', // @translate ['addons' => implode(', ', $existings)] ); } if (count($errors)) { $failed = true; $isFinalized = false; $utils->psrLog( 'Les modules suivants ne peuvent pas être installés : {addons}.', // @translate ['addons' => implode(', ', $errors)] ); } if (count($installeds)) { $utils->psrLog( 'Les modules suivants ont été installés : {addons}.', // @translate ['addons' => implode(', ', $installeds)] ); } } if ($failed) { $utils->removeFilesAndDirsInDir(__DIR__, [__FILE__]); } } if ($isFinalized) { // Supprimer le présent fichier. unlink(__FILE__); // Préparer la redirection. $scheme = ($_SERVER['REQUEST_SCHEME'] ?? '') ?: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http'); $serverPort = (string) ($_SERVER['SERVER_PORT'] ?? ''); $urlOmeka = $scheme . '://' . ($_SERVER['SERVER_NAME'] ?? '') . (in_array($serverPort, ['', '80', '443']) ? '' : ':' . $serverPort) // Ne pas ajouter « index.php ». . dirname((string) ($_SERVER['REQUEST_URI'] ?? '')); } $meta = [ 'title' => 'Installer Omeka S facilement', 'author' => 'Daniel Berthereau', 'description' => 'Installer Omeka S simplement avec un fichier unique à déposer sur le serveur.', ]; $locales = [ '' => 'Défaut', 'ca_ES' => 'Català (Espanya) [ca_ES]', 'cs' => 'Čeština [cs]', 'de_DE' => 'Deutsch (Deutschland) [de_DE]', 'et' => 'Eesti [et]', 'en_US' => 'English (United States) [en_US]', 'es_419' => 'Español (Latinoamérica) [es_419]', 'es' => 'Español [es]', 'eu' => 'Euskara [eu]', 'fr' => 'Français [fr]', 'hr' => 'Hrvatski [hr]', 'it' => 'Italiano [it]', 'lt' => 'Lietuvių [lt]', 'hu_HU' => 'Magyar (Magyarország) [hu_HU]', 'nl_NL' => 'Nederlands (Nederland) [nl_NL]', 'pl' => 'Polski [pl]', 'pt_BR' => 'Português (Brasil) [pt_BR]', 'pt_PT' => 'Português (Portugal) [pt_PT]', 'ro' => 'Română [ro]', 'fi_FI' => 'Suomi (Suomi) [fi_FI]', 'sv_SE' => 'Svenska (Sverige) [sv_SE]', 'tr_TR' => 'Türkçe (Türkiye) [tr_TR]', 'el_GR' => 'Ελληνικά (Ελλάδα) [el_GR]', 'bg_BG' => 'Български (България) [bg_BG]', 'mn' => 'Монгол [mn]', 'ru' => 'Русский [ru]', 'sr_RS' => 'Српски (Србија) [sr_RS]', 'uk' => 'Українська [uk]', 'ar' => 'العربية [ar]', 'ko_KR' => '한국어(대한민국) [ko_KR]', 'zh_CN' => '中文(中国) [zh_CN]', 'zh_TW' => '中文(台灣) [zh_TW]', 'ja' => '日本語 [ja]', ]; ?> <?= htmlspecialchars($meta['title']) ?>

log()): ?>

Omeka ne peut pas être installé en raison des erreurs suivantes :

Vérifiez la configuration chez votre hébergeur.

Aucun problème n’a été détecté sur le serveur.

Base de données

Omeka a besoin d’un accès à une base de données pour fonctionner. Merci d’en indiquer les paramètres ci-dessous. Si la base n’existe pas, l’utilisateur doit avoir les droits de création.





Configuration spécifique

Fichier de configuration

Configuration spécifique
/> /> />
/> /> />

Préinstallation de modules et thèmes

Les sélections sont des listes de modules et de thèmes permettant de disposer rapidement d'une installation adaptée à ses besoins.

Bravo, Omeka est préinstallé ! Vous pouvez désormais finaliser l’installation ou patientez dix secondes pour y aller automatiquement.