<?php
namespace Garradin\Files;
use Garradin\Static_Cache;
use Garradin\Config;
use Garradin\DB;
use Garradin\Plugins;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Users\Session;
use Garradin\Entities\Files\File;
use Garradin\Entities\Web\Page;
use KD2\DB\EntityManager as EM;
use KD2\ZipWriter;
use const Garradin\{FILE_STORAGE_BACKEND, FILE_STORAGE_QUOTA, FILE_STORAGE_CONFIG};
class Files
{
/**
* To enable or disable quota check
*/
static protected $quota = true;
static public function enableQuota(): void
{
self::$quota = true;
}
static public function disableQuota(): void
{
self::$quota = false;
}
static public function listContextsPermissions(Session $s): array
{
$perm = self::buildUserPermissions($s);
$contexts = [
'Fichiers de votre fiche de membre personnelle' => File::CONTEXT_USER . '/' . $s::getUserId() . '/',
'Documents de l\'association' => File::CONTEXT_DOCUMENTS,
'Fichiers des membres' => File::CONTEXT_USER . '//',
'Fichiers des écritures comptables' => File::CONTEXT_TRANSACTION . '//',
'Fichiers du site web (contenu des pages, images, etc.)' => File::CONTEXT_WEB . '//',
'Fichiers de la configuration (logo, etc.)' => File::CONTEXT_CONFIG,
'Code des modules' => File::CONTEXT_MODULES,
];
$out = [];
foreach ($contexts as $name => $path) {
$out[$name] = $perm[$path] ?? null;
}
return $out;
}
/**
* Returns an array of all file permissions for a given user
*/
static public function buildUserPermissions(Session $s): array
{
$is_admin = $s->canAccess($s::SECTION_CONFIG, $s::ACCESS_ADMIN);
$p = [];
if ($s->isLogged() && $id = $s::getUserId()) {
// The user can always access his own profile files
$p[File::CONTEXT_USER . '/' . $s::getUserId() . '/'] = [
'mkdir' => false,
'move' => false,
'create' => false,
'read' => true,
'write' => false,
'delete' => false,
'share' => false,
];
}
// Subdirectories can be managed by member managemnt
$p[File::CONTEXT_USER . '//'] = [
'mkdir' => false,
'move' => false,
'create' => $s->canAccess($s::SECTION_USERS, $s::ACCESS_WRITE),
'read' => $s->canAccess($s::SECTION_USERS, $s::ACCESS_READ),
'write' => $s->canAccess($s::SECTION_USERS, $s::ACCESS_WRITE),
'delete' => $s->canAccess($s::SECTION_USERS, $s::ACCESS_WRITE),
'share' => false,
];
// Users can't do anything on the root though
$p[File::CONTEXT_USER] = [
'mkdir' => false,
'move' => false,
'create' => false,
'write' => false,
'delete' => false,
'read' => $s->canAccess($s::SECTION_USERS, $s::ACCESS_READ),
'share' => false,
];
$p[File::CONTEXT_CONFIG] = [
'mkdir' => false,
'move' => false,
'create' => false,
'read' => $s->isLogged(), // All config files can be accessed by all logged-in users
'write' => $is_admin,
'delete' => false,
'share' => false,
];
// Modules source code
$p[File::CONTEXT_MODULES] = [
'mkdir' => $is_admin,
'move' => $is_admin,
'create' => $is_admin,
'read' => $s->isLogged(),
'write' => $is_admin,
'delete' => $is_admin,
'share' => false,
];
// Trash
$p[File::CONTEXT_TRASH] = [
'mkdir' => false,
'move' => $is_admin,
'create' => false,
'read' => $is_admin,
'write' => false,
'delete' => $is_admin,
'share' => false,
];
$p[File::CONTEXT_WEB . '//'] = [
'mkdir' => false,
'move' => false,
'create' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE),
'read' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_READ),
'write' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE),
'delete' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE),
'share' => false,
];
// At root level of web you can only create new articles
$p[File::CONTEXT_WEB] = [
'mkdir' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE),
'move' => false,
'create' => false,
'read' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_READ),
'write' => false,
'delete' => false,
'share' => false,
];
$p[File::CONTEXT_DOCUMENTS] = [
'mkdir' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
'move' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
'create' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
'read' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_READ),
'write' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
'delete' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_ADMIN),
'share' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE),
];
// You can write in transaction subdirectories
$p[File::CONTEXT_TRANSACTION . '//'] = [
'mkdir' => false,
'move' => false,
'create' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE),
'read' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_READ),
'write' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE),
'delete' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_ADMIN),
'share' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE),
];
// But not in root
$p[File::CONTEXT_TRANSACTION] = [
'mkdir' => false,
'move' => false,
'write' => false,
'create' => false,
'delete' => false,
'read' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_READ),
'share' => false,
];
$p[''] = [
'mkdir' => false,
'move' => false,
'write' => false,
'create' => false,
'delete' => false,
'read' => true,
'share' => false,
];
return $p;
}
static public function search(string $search, string $path = null): array
{
if (strlen($search) > 100) {
throw new ValidationException('Recherche trop longue : maximum 100 caractères');
}
$where = '';
$params = [trim($search)];
if (null !== $path) {
$where = ' AND path LIKE ?';
$params[] = $path;
}
$query = sprintf('SELECT
*,
dirname(path) AS parent,
snippet(files_search, \'<b>\', \'</b>\', \'…\', 2, -30) AS snippet,
rank(matchinfo(files_search), 0, 1.0, 1.0) AS points
FROM files_search
WHERE files_search MATCH ? %s
ORDER BY points DESC
LIMIT 0,50;', $where);
$out = [];
$db = DB::getInstance();
$db->begin();
foreach ($db->iterate($query, ...$params) as $row) {
// Remove deleted/moved files
if (FILE_STORAGE_BACKEND != 'SQLite' && !Files::callStorage('exists', $row->path)) {
$db->delete('files_search', 'path = ?', $row->path);
continue;
}
$out[] = $row;
}
$db->commit();
return $out;
}
/**
* Returns a list of files and directories inside a parent path
* This is not recursive and will only return files and directories
* directly in the specified $parent path.
*/
static public function list(string $parent = ''): array
{
if ($parent !== '') {
File::validatePath($parent);
}
$dir = self::get($parent);
if ($dir && $dir->type != File::TYPE_DIRECTORY) {
return [$dir];
}
// Update this path
return self::callStorage('list', $parent);
}
/**
* Returns a list of files or directories matching a glob pattern
* only * and ? characters are supported in pattern
*/
static public function glob(string $pattern): array
{
return self::callStorage('glob', $pattern);
}
/**
* Creates a ZIP file archive from multiple paths
* @param null|string $target Target file name, if left NULL, then will be sent to browser
* @param array $paths List of paths to append to ZIP file
* @param Session $session Logged-in user session, if set access rights to the path will be checked,
* if left NULL, then no check will be made (!).
*/
static public function zip(?string $target, array $paths, ?Session $session, ?string $download_name = null): void
{
if (!$target) {
$download_name ??= Config::getInstance()->org_name . ' - Documents';
header('Content-type: application/zip');
header(sprintf('Content-Disposition: attachment; filename="%s"', $download_name. '.zip'));
$target = 'php://output';
}
$zip = new ZipWriter($target);
$zip->setCompression(0);
foreach ($paths as $path) {
foreach (Files::listRecursive($path, $session, false) as $file) {
$zip->add($file->path, null, $file->fullpath());
}
}
$zip->close();
}
static public function listRecursive(string $path, ?Session $session, bool $include_directories = true): \Generator
{
foreach (self::list($path) as $file) {
if ($session && !$file->canRead($session)) {
continue;
}
if ($file->isDir()) {
yield from self::listRecursive($file->path, $session, $include_directories);
if ($include_directories) {
yield $file;
}
}
else {
yield $file;
}
}
}
/**
* List files and directories inside a context (first-level directory)
*/
static public function listForContext(string $context, ?string $ref = null): array
{
$path = $context;
if ($ref) {
$path .= '/' . $ref;
}
return self::list($path);
}
/**
* Delete a specified file/directory path
*/
static public function delete(string $path): void
{
$file = self::get($path);
if (!$file) {
return;
}
$file->delete();
}
static public function callStorage(string $function, ...$args)
{
$class_name = __NAMESPACE__ . '\\Storage\\' . FILE_STORAGE_BACKEND;
call_user_func([$class_name, 'configure'], FILE_STORAGE_CONFIG);
return call_user_func_array([$class_name, $function], $args);
}
/**
* Copy all files from a storage backend to another one
* This can be used to move from SQLite to FileSystem for example
* Note that this only copies files, and is not removing them from the source storage backend.
*/
static public function migrateStorage(string $from, string $to, $from_config = null, $to_config = null, ?callable $callback = null): void
{
$from = __NAMESPACE__ . '\\Storage\\' . $from;
$to = __NAMESPACE__ . '\\Storage\\' . $to;
if (!class_exists($from)) {
throw new \InvalidArgumentException('Invalid storage: ' . $from);
}
if (!class_exists($to)) {
throw new \InvalidArgumentException('Invalid storage: ' . $to);
}
call_user_func([$from, 'configure'], $from_config);
call_user_func([$to, 'configure'], $to_config);
try {
call_user_func([$from, 'checkLock']);
call_user_func([$to, 'checkLock']);
call_user_func([$from, 'lock']);
call_user_func([$to, 'lock']);
$db = DB::getInstance();
$db->begin();
$i = 0;
self::migrateDirectory($from, $to, '', $i, $callback);
}
catch (UserException $e) {
throw new \RuntimeException('Migration failed', 0, $e);
}
finally {
$db->commit();
call_user_func([$from, 'unlock']);
call_user_func([$to, 'unlock']);
}
}
static protected function migrateDirectory(string $from, string $to, string $path, int &$i, ?callable $callback)
{
$db = DB::getInstance();
foreach (call_user_func([$from, 'list'], $path) as $file) {
if (!$file->parent && $file->name == '.lock') {
// Ignore lock file
continue;
}
if (++$i >= 100) {
$db->commit();
$db->begin();
$i = 0;
}
if ($file->type == File::TYPE_DIRECTORY) {
call_user_func([$to, 'mkdir'], $file);
self::migrateDirectory($from, $to, $file->path, $i, $callback);
}
else {
$from_path = call_user_func([$from, 'getFullPath'], $file);
call_user_func([$to, 'storePath'], $file, $from_path);
}
if (null !== $callback) {
$callback($file);
}
unset($file);
}
}
/**
* Delete all files from a storage backend
*/
static public function truncateStorage(string $backend, $config = null): void
{
$backend = __NAMESPACE__ . '\\Storage\\' . $backend;
call_user_func([$backend, 'configure'], $config);
if (!class_exists($backend)) {
throw new \InvalidArgumentException('Invalid storage: ' . $backend);
}
call_user_func([$backend, 'truncate']);
}
static public function get(string $path, int $type = null): ?File
{
// Root contexts always exist, same with root itself
if ($path == '' || array_key_exists($path, File::CONTEXTS_NAMES)) {
$file = new File;
$file->parent = '';
$file->name = $path;
$file->path = $path;
$file->type = $file::TYPE_DIRECTORY;
return $file;
}
try {
File::validatePath($path);
}
catch (ValidationException $e) {
return null;
}
$file = self::callStorage('get', $path);
if (!$file || ($type && $file->type != $type)) {
return null;
}
return $file;
}
static public function exists(string $path): bool
{
if (array_key_exists($path, File::CONTEXTS_NAMES)) {
return true;
}
return self::callStorage('exists', $path);
}
static public function getFromURI(string $uri): ?File
{
$uri = trim($uri, '/');
$uri = rawurldecode($uri);
return self::get($uri, File::TYPE_FILE);
}
static public function getContext(string $path): ?string
{
$pos = strpos($path, '/');
if (false === $pos) {
return $path;
}
$context = substr($path, 0, $pos);
if (!$context) {
return null;
}
if (!array_key_exists($context, File::CONTEXTS_NAMES)) {
return null;
}
return $context;
}
static public function isContextRoutable(string $path): bool
{
$context = self::getContext($path);
if (!$context) {
return false;
}
// Modules and trash files can never be served directly
if ($context == File::CONTEXT_MODULES
|| $context == File::CONTEXT_TRASH) {
return false;
}
return true;
}
static public function getContextRef(string $path): ?string
{
$context = strtok($path, '/');
return strtok('/') ?: null;
}
static public function getBreadcrumbs(string $path): array
{
$parts = explode('/', $path);
$breadcrumbs = [];
$path = '';
foreach ($parts as $part) {
$path = trim($path . '/' . $part, '/');
$breadcrumbs[$path] = $part;
}
return $breadcrumbs;
}
static public function getQuota(): float
{
return FILE_STORAGE_QUOTA ?? self::callStorage('getQuota');
}
static public function getUsedQuota(bool $force_refresh = false): float
{
if ($force_refresh || Static_Cache::expired('used_quota', 3600)) {
$quota = self::callStorage('getTotalSize');
Static_Cache::store('used_quota', $quota);
}
else {
$quota = (float) Static_Cache::get('used_quota');
}
return $quota;
}
static public function getRemainingQuota(bool $force_refresh = false): float
{
if (FILE_STORAGE_QUOTA !== null) {
$quota = FILE_STORAGE_QUOTA - self::getUsedQuota($force_refresh);
}
else {
$quota = self::callStorage('getRemainingQuota');
}
return max(0, $quota);
}
static public function checkQuota(int $size = 0): void
{
if (!self::$quota) {
return;
}
$remaining = self::getRemainingQuota(true);
if (($remaining - (float) $size) < 0) {
throw new ValidationException('L\'espace disque est insuffisant pour réaliser cette opération');
}
}
static public function getVirtualTableName(): string
{
if (FILE_STORAGE_BACKEND == 'SQLite') {
return 'files';
}
return 'tmp_files';
}
static public function syncVirtualTable(string $parent = '', bool $recursive = false)
{
if (FILE_STORAGE_BACKEND == 'SQLite') {
// No need to create a virtual table, use the real one
return;
}
$db = DB::getInstance();
$db->begin();
$db->exec('CREATE TEMP TABLE IF NOT EXISTS tmp_files AS SELECT * FROM files WHERE 0;');
foreach (Files::list($parent) as $file) {
// Ignore additional directories
if ($parent == '' && !array_key_exists($file->name, File::CONTEXTS_NAMES)) {
continue;
}
$db->insert('tmp_files', $file->asArray(true));
if ($recursive && $file->type === $file::TYPE_DIRECTORY) {
self::syncVirtualTable($file->path, $recursive);
}
}
$db->commit();
}
static protected function create(string $parent, string $name, array $source): File
{
if (!isset($source['path']) && !isset($source['content']) && !isset($source['pointer'])) {
throw new \InvalidArgumentException('Unknown source type');
}
elseif (count($source) != 1) {
throw new \InvalidArgumentException('Invalid source type');
}
$pointer = $path = $content = null;
extract($source);
File::validateFileName($name);
File::validatePath($parent);
File::validateCanHTML($name, $parent);
self::ensureDirectoryExists($parent);
$name = File::filterName($name);
$finfo = \finfo_open(\FILEINFO_MIME_TYPE);
$target = $parent . '/' . $name;
$file = Files::callStorage('get', $target) ?? new File;
$file->path = $target;
$file->parent = $parent;
$file->name = $name;
if ($pointer) {
if (0 !== fseek($pointer, 0, SEEK_END)) {
throw new \RuntimeException('Stream is not seekable');
}
$file->set('size', ftell($pointer));
fseek($pointer, 0, SEEK_SET);
$file->set('mime', mime_content_type($pointer));
}
elseif ($path) {
$file->set('mime', finfo_file($finfo, $path));
$file->set('size', filesize($path));
$file->set('modified', new \DateTime('@' . filemtime($path)));
}
else {
$file->set('size', strlen($content));
$file->set('mime', finfo_buffer($finfo, $content));
}
$file->set('image', in_array($file->mime, $file::IMAGE_TYPES));
// Force empty files as text/plain
if ($file->mime == 'application/x-empty' && !$file->size) {
$file->set('mime', 'text/plain');
}
return $file;
}
static public function createDocument(string $parent, string $name, string $extension): File
{
// From https://github.com/nextcloud/richdocuments/tree/2338e2ff7078040d54fc0c70a96c8a1b860f43a0/emptyTemplates
// We need to copy an empty template, or Collabora will create flat-XML file
if ($extension == 'ods') {
$tpl = 'UEsDBBQAAAAAAOw6wVCFbDmKLgAAAC4AAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnNwcmVhZHNoZWV0UEsDBBQAAAAIABxZFFFL43PrmgAAAEABAAAVAAAATUVUQS1JTkYvbWFuaWZlc3QueG1slVDRDoMgDHz3KwjvwvZK1H9poEYSKETqon8vLpluWfawPrXXy921XQTyIxY2r0asMVA5x14uM5kExRdDELEYtiZlJJfsEpHYfPLNXd2kGBpRqzvB0QdsK3nexIUtIbQZeOqllhcc0XloecvYS8g5eAvsE+kHOfWMod7dVckzgisTIkv9p61NxIdGveBHAMaV9bGu0p3++tXQ7FBLAwQUAAAACAAAWRRRA4GGVIkAAAD/AAAACwAAAGNvbnRlbnQueG1sXY/RCsIwDEWf9SvG3uv0Ncz9S01TLLTNWFJwf29xbljzEu49N1wysvcBCRxjSZTVIGetu3ulmAU2eu/LkoGtBIFsEwkoAs+U9yv4TcPtcu2nc1dn/DqCS5hVuqG1fe0y3iIZRxg/+LQzW5ST1YBGdI3Uwge7tcpDy7yQdfIk0i03NMFD/n85vQFQSwECFAMUAAAAAADsOsFQhWw5ii4AAAAuAAAACAAAAAAAAAAAAAAAtIEAAAAAbWltZXR5cGVQSwECFAMUAAAACAAcWRRRS+Nz65oAAABAAQAAFQAAAAAAAAAAAAAAtIFUAAAATUVUQS1JTkYvbWFuaWZlc3QueG1sUEsBAhQDFAAAAAgAAFkUUQOBhlSJAAAA/wAAAAsAAAAAAAAAAAAAALSBIQEAAGNvbnRlbnQueG1sUEsFBgAAAAADAAMAsgAAANMBAAAAAA==';
}
elseif ($extension == 'odp') {
$tpl = 'UEsDBBQAAAAAAC6dVEszJqyoLwAAAC8AAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnByZXNlbnRhdGlvblBLAwQUAAAACAAsYRRRP7fJFJoAAABBAQAAFQAAAE1FVEEtSU5GL21hbmlmZXN0LnhtbJVQwQqDMAy97ytK77bbNaj/EmpkhTYtNg79+1VhujF2WC5JXh7vJWkjsh+pCLwKtcTA5Wg7PU8MCYsvwBipgDhImXhIbo7EAp98uJmrVv1F1WgPcPSBmkqeVnVicwhNRrl32uoTjjR4bGTN1GnMOXiH4hPbBw9mX8O8u5s8Ual552j7p69LLJtIPeHHBkKL2G1cpVv79az+8gRQSwMEFAAAAAgAMl4UUXz4vRWJAAAA/gAAAAsAAABjb250ZW50LnhtbF2P0QqDMAxFn+dXiO+d22tw/ksXUyjYpJgI8+8tOGVdXsK994Qkg4QQkWASXBOxORS20ttPmlnhSF/dujCI16jAPpGCIUgmPqfgl4bn/dGNTVtq+DqKS8ymbT82t9MLZZELHslNhHOd+dUkeYvo1LaZ6vAt01bkpfNCWm4ouPAB9hV5yf8fx2YHUEsBAhQDFAAAAAAALp1USzMmrKgvAAAALwAAAAgAAAAAAAAAAAAAALSBAAAAAG1pbWV0eXBlUEsBAhQDFAAAAAgALGEUUT+3yRSaAAAAQQEAABUAAAAAAAAAAAAAALSBVQAAAE1FVEEtSU5GL21hbmlmZXN0LnhtbFBLAQIUAxQAAAAIADJeFFF8+L0ViQAAAP4AAAALAAAAAAAAAAAAAAC0gSIBAABjb250ZW50LnhtbFBLBQYAAAAAAwADALIAAADUAQAAAAA=';
}
else {
$extension = 'odt';
$tpl = 'UEsDBBQAAAAAAPMbH0texjIMJwAAACcAAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnRleHRQSwMEFAAAAAgA3U0SUeqX5meSAAAAMQEAABUAAABNRVRBLUlORi9tYW5pZmVzdC54bWyVUEEOgzAMu+8VqHfa7Rq1/CUqQavUphUNE/wemDTYNO2wW2I7thWbkMNAVeA1NHOKXI/VqWlkyFhDBcZEFcRDLsR99lMiFvjUw01fVXdp7AEMIVK7CcelObEpxrag3J0y6oQT9QFbWQo5haXE4FFCZvPgXj8r6PdkLTSLMv+E+cyyX26df8TunmanN19rvr7TrVBLAwQUAAAACACQThJRWmJBaH8AAADjAAAACwAAAGNvbnRlbnQueG1sXY/RCsMgDEXf+xWj767ba+j8FxcjCGpKE6H9+wlbRfYUbs69uWTlECISeMaaqahBLtrm7cipCHzpa657AXYSBYrLJKAIvFG5UjC64Xl/zHZaf0pwj5vKYq9FaA0mOCTjCdMAXFXOTiMa0TNRI/3Im/3ZfUqHttQysqnL/0/sB1BLAQIUAxQAAAAAAPMbH0texjIMJwAAACcAAAAIAAAAAAAAAAAAAACkgQAAAABtaW1ldHlwZVBLAQIUAxQAAAAIAN1NElHql+ZnkgAAADEBAAAVAAAAAAAAAAAAAACkgU0AAABNRVRBLUlORi9tYW5pZmVzdC54bWxQSwECFAMUAAAACACQThJRWmJBaH8AAADjAAAACwAAAAAAAAAAAAAApIESAQAAY29udGVudC54bWxQSwUGAAAAAAMAAwCyAAAAugEAAAAA';
}
return Files::createFromString($parent . '/' . $name . '.' . $extension, base64_decode($tpl));
}
static protected function createFrom(string $target, array $source): File
{
$parent = Utils::dirname($target);
$name = Utils::basename($target);
$file = self::create($parent, $name, $source);
$file->store($source);
return $file;
}
/**
* Create and store a file from a local path
* @param string $target Target parent path + name
* @param string $path Source file path
* @return File
*/
static public function createFromPath(string $target, string $path): File
{
return self::createFrom($target, compact('path'));
}
/**
* Create and store a file from a string
* @param string $target Target parent path + name
* @param string $content Source file contents
* @return File
*/
static public function createFromString(string $target, string $content): File
{
return self::createFrom($target, compact('content'));
}
static public function createFromPointer(string $target, $pointer): File
{
return self::createFrom($target, compact('pointer'));
}
/**
* Upload multiple files
* @param string $parent Target parent directory (eg. 'documents/Logos')
* @param string $key The name of the file input in the HTML form (this MUST have a '[]' at the end of the name)
* @return array list of File objects created
*/
static public function uploadMultiple(string $parent, string $key): array
{
if (!isset($_FILES[$key]['name'][0])) {
throw new UserException('Aucun fichier reçu');
}
// Transpose array
// see https://www.php.net/manual/en/features.file-upload.multiple.php#53240
$files = Utils::array_transpose($_FILES[$key]);
$out = [];
// First check all files
foreach ($files as $file) {
if (!empty($file['error'])) {
throw new UserException(self::getUploadErrorMessage($file['error']));
}
if (empty($file['size']) || empty($file['name'])) {
throw new UserException('Fichier reçu invalide : vide ou sans nom de fichier.');
}
if (!is_uploaded_file($file['tmp_name'])) {
throw new \RuntimeException('Le fichier n\'a pas été envoyé de manière conventionnelle.');
}
}
// Then create files
foreach ($files as $file) {
$name = File::filterName($file['name']);
$out[] = self::createFromPath($parent . '/' . $name, $file['tmp_name']);
}
return $out;
}
/**
* Upload a file using POST from a HTML form
* @param string $parent Target parent directory (eg. 'documents/Logos')
* @param string $key The name of the file input in the HTML form
* @return self Created file object
*/
static public function upload(string $parent, string $key, ?string $name = null): File
{
if (!isset($_FILES[$key]) || !is_array($_FILES[$key])) {
throw new UserException('Aucun fichier reçu');
}
$file = $_FILES[$key];
if (!empty($file['error'])) {
throw new UserException(self::getUploadErrorMessage($file['error']));
}
if (empty($file['size']) || empty($file['name'])) {
throw new UserException('Fichier reçu invalide : vide ou sans nom de fichier.');
}
if (!is_uploaded_file($file['tmp_name'])) {
throw new \RuntimeException('Le fichier n\'a pas été envoyé de manière conventionnelle.');
}
$name = File::filterName($name ?? $file['name']);
return self::createFromPath($parent . '/' . $name, $file['tmp_name']);
}
/**
* Récupération du message d'erreur
* @param integer $error Code erreur du $_FILE
* @return string Message d'erreur
*/
static public function getUploadErrorMessage($error)
{
switch ($error)
{
case UPLOAD_ERR_INI_SIZE:
return 'Le fichier excède la taille permise par la configuration.';
case UPLOAD_ERR_FORM_SIZE:
return 'Le fichier excède la taille permise par le formulaire.';
case UPLOAD_ERR_PARTIAL:
return 'L\'envoi du fichier a été interrompu.';
case UPLOAD_ERR_NO_FILE:
return 'Aucun fichier n\'a été reçu.';
case UPLOAD_ERR_NO_TMP_DIR:
return 'Pas de répertoire temporaire pour stocker le fichier.';
case UPLOAD_ERR_CANT_WRITE:
return 'Impossible d\'écrire le fichier sur le disque du serveur.';
case UPLOAD_ERR_EXTENSION:
return 'Une extension du serveur a interrompu l\'envoi du fichier.';
default:
return 'Erreur inconnue: ' . $error;
}
}
/**
* Create a new directory
* @param string $parent Target parent path
* @param string $name Target name
* @param bool $create_parent Create parent directories if they don't exist
* @return self
*/
static public function mkdir(string $path, bool $create_parent = true): File
{
$path = trim($path, '/');
$parent = Utils::dirname($path);
$name = Utils::basename($path);
$name = File::filterName($name);
$path = $parent . '/' . $name;
File::validatePath($path);
Files::checkQuota();
if (self::exists($path)) {
throw new ValidationException('Le nom de répertoire choisi existe déjà: ' . $path);
}
if ($parent !== '' && $create_parent) {
self::ensureDirectoryExists($parent);
}
$file = new File;
$type = $file::TYPE_DIRECTORY;
$file->import(compact('path', 'name', 'parent') + [
'type' => file::TYPE_DIRECTORY,
'image' => false,
]);
$file->modified = new \DateTime;
Files::callStorage('mkdir', $file);
Plugins::fireSignal('files.mkdir', compact('file'));
return $file;
}
static public function ensureDirectoryExists(string $path): void
{
$db = DB::getInstance();
$parts = explode('/', $path);
$tree = '';
foreach ($parts as $part) {
$tree = trim($tree . '/' . $part, '/');
$exists = $db->test(File::TABLE, 'type = ? AND path = ?', File::TYPE_DIRECTORY, $tree);
if (!$exists) {
try {
self::mkdir($tree, false);
}
catch (ValidationException $e) {
// Ignore when directory already exists
}
}
}
}
/**
* Return list of context that can be read by currently logged user
*/
static public function listReadAccessContexts(?Session $session): array
{
if (!$session->isLogged()) {
return [];
}
$list = [];
if ($session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) {
$access[] = File::CONTEXT_CONFIG;
$access[] = File::CONTEXT_MODULES;
}
if ($session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)) {
$access[] = File::CONTEXT_TRANSACTION;
}
if ($session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)) {
$access[] = File::CONTEXT_USER;
}
if ($session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)) {
$access[] = File::CONTEXT_DOCUMENTS;
}
if ($session->canAccess($session::SECTION_WEB, $session::ACCESS_READ)) {
$access[] = File::CONTEXT_WEB;
}
return array_intersect_key(File::CONTEXTS_NAMES, array_flip($access));
}
}