Browse Source

add file manager

develop
Simon Vieille 3 months ago
parent
commit
77e989de10
  1. 1
      assets/js/admin/admin.js
  2. 45
      assets/js/admin/components/file-manager/FileIcon.vue
  3. 23
      assets/js/admin/components/file-manager/FileManager.vue
  4. 284
      assets/js/admin/components/file-manager/Files.vue
  5. 2
      assets/js/admin/modules/editor.js
  6. 18
      assets/js/admin/modules/file-manager.js
  7. 2
      assets/js/admin/modules/form-confirm.js
  8. 2
      assets/js/admin/modules/form.js
  9. 49
      assets/js/admin/modules/modal.js
  10. 2
      composer.json
  11. 1
      config/bundles.php
  12. 23
      config/packages/app.yaml
  13. 1
      config/packages/security.yaml
  14. 232
      core/Controller/FileManager/FileManagerAdminController.php
  15. 89
      core/DependencyInjection/Configuration.php
  16. 230
      core/FileManager/FsFileManager.php
  17. 43
      core/Form/FileManager/DirectoryCreateType.php
  18. 43
      core/Form/FileManager/DirectoryRenameType.php
  19. 42
      core/Form/FileManager/FileUploadType.php
  20. 15
      core/Form/FileUploadHandler.php
  21. 20
      core/Resources/translations/messages.fr.yaml
  22. 10
      core/Resources/views/admin/module/menu.html.twig
  23. 1
      core/Resources/views/file_manager/_form.html.twig
  24. 33
      core/Resources/views/file_manager/directory_new.html.twig
  25. 33
      core/Resources/views/file_manager/directory_rename.html.twig
  26. 16
      core/Resources/views/file_manager/index.html.twig
  27. 93
      core/Resources/views/file_manager/info.html.twig
  28. 33
      core/Resources/views/file_manager/upload.html.twig
  29. 2
      core/Resources/views/form/bootstrap_4_form_theme.html.twig
  30. 6
      package.json
  31. 18
      symfony.lock
  32. 1
      webpack.config.js
  33. 239
      yarn.lock

1
assets/js/admin/admin.js

@ -19,3 +19,4 @@ require('./modules/form-collection.js')();
require('./modules/datepicker.js')();
require('./modules/sortable.js')();
require('./modules/batch.js')();
require('./modules/file-manager.js')();

45
assets/js/admin/components/file-manager/FileIcon.vue

@ -0,0 +1,45 @@
<template>
<span v-bind:class="getIcon(mime)"></span>
</template>
<script>
const map = {
'fa-file-pdf': ['application/pdf'],
'fa-file-image': ['image/png', 'image/jpg', 'image/jpeg', 'image/gif'],
'fa-file-audio': ['application/ogg', 'audio/mp3', 'audio/mpeg', 'audio/wav'],
'fa-file-archive': ['application/zip', 'multipart/x-zip', 'application/rar', 'application/x-rar-compressed', 'application/x-zip-compressed', 'application/tar', 'application/x-tar'],
'fa-file-alt': ['application/rtf'],
'fa-file-excel': ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
'fa-file-powerpoint': ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
'fa-file-video': ['video/x-msvideo', 'video/mpeg'],
}
export default {
name: 'FileIcon',
methods: {
getIcon(mime) {
let icons = ['fa']
let iconFound = false
for (let icon in map) {
if (map[icon].indexOf(mime) !== -1) {
iconFound = true
icons.push(icon)
}
}
if (!iconFound) {
icons.push('fa-file')
}
return icons
},
},
props: {
mime: {
type: String,
required: true
},
},
}
</script>

23
assets/js/admin/components/file-manager/FileManager.vue

@ -0,0 +1,23 @@
<template>
<div>
<div class="row">
<div class="col">
<Files />
</div>
</div>
</div>
</template>
<style scoped>
</style>
<script>
import Files from './Files'
export default {
name: "FileManager",
components: {
Files,
}
}
</script>

284
assets/js/admin/components/file-manager/Files.vue

@ -0,0 +1,284 @@
<template>
<div>
<nav aria-label="breadcrumb bg-light">
<div class="float-right">
</div>
<ol class="breadcrumb mb-0 float-right file-manager-views">
<li class="breadcrumb-item">
<span class="fa fa-grip-horizontal" v-on:click="setView('grid')"></span>
</li>
<li class="breadcrumb-item">
<span class="fa fa-list" v-on:click="setView('list')"></span>
</li>
</ol>
<ol class="breadcrumb mb-0 float-right file-manager-actions">
<li class="breadcrumb-item">
<span class="fa fa-upload text-primary" v-bind:data-modal="generateUploadLink(directory)"></span>
<span class="fa fa-folder-plus text-primary" v-bind:data-modal="generateNewDirectoryLink(directory)"></span>
</li>
</ol>
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item" v-for="item in breadcrumb">
<a href="#" v-on:click="setDirectory(item.path)" v-html="item.label"></a>
</li>
</ol>
</nav>
<div class="card-deck" v-if="view == 'grid'">
<div v-if="parent" class="card mt-3 ml-3 mb-3 border-0">
<div class="card-body p-2">
<div class="card-text" v-on:dblclick="setDirectory(parent)">
<div class="text-center display-4 text-warning">
<span class="fa fa-folder"></span>
</div>
<div class="text-center">
..
</div>
</div>
</div>
</div>
<div v-for="item in directories" class="card mt-3 ml-3 mb-3 border-0">
<div class="card-body p-2">
<div class="card-text" v-on:dblclick="setDirectory(item.path)" v-bind:data-modal="generateInfoLink(item, true)">
<div class="text-center">
<div class="display-4 text-warning">
<span class="fa fa-folder"></span>
</div>
<div v-if="item.locked" class="file-manager-grid-lock">
<span class="btn btn-sm">
<span class="fa fa-lock"></span>
</span>
</div>
</div>
<div class="text-center">
<span v-html="item.basename"></span>
</div>
</div>
</div>
</div>
<div v-for="item in files" class="card mt-3 ml-3 mb-3 border-0" v-bind:data-modal="generateInfoLink(item)">
<div class="card-body p-2">
<div class="card-text">
<div class="text-center">
<div class="display-4 text-muted">
<FileIcon v-bind:mime="item.mime" />
</div>
<div v-if="item.locked" class="file-manager-grid-lock">
<span class="btn btn-sm">
<span class="fa fa-lock"></span>
</span>
</div>
</div>
<div class="text-center">
<span v-html="item.basename"></span>
</div>
</div>
</div>
</div>
</div>
<div class="table-responsive" v-if="view == 'list'">
<table class="table">
<tr v-if="parent" v-on:dblclick="setDirectory(parent)">
<td width="10">
<span class="fa fa-folder text-warning"></span>
</td>
<td>
..
</td>
</tr>
<tr v-for="item in directories" v-on:dblclick="setDirectory(item.path)" v-bind:data-modal="generateInfoLink(item, true)">
<td width="10">
<span class="fa fa-folder text-warning"></span>
</td>
<td>
<div v-if="item.locked" class="float-right">
<span class="btn btn-sm btn-light">
<span class="fa fa-lock"></span>
</span>
</div>
<span v-html="item.basename"></span>
</td>
</tr>
<tr v-for="item in files">
<td width="10">
<FileIcon v-bind:mime="item.mime" />
</td>
<td v-bind:data-modal="generateInfoLink(item)">
<div v-if="item.locked" class="float-right">
<span class="btn btn-sm btn-light">
<span class="fa fa-lock"></span>
</span>
</div>
<span v-html="item.basename"></span>
</td>
</tr>
</table>
</div>
</div>
</template>
<style scoped>
.card {
margin-right: 5px;
flex: 0 0 170px;
cursor: pointer;
}
* {
user-select: none;
}
tr {
cursor: pointer;
}
.file-manager-views {
cursor: pointer;
}
.file-manager-grid-lock {
margin-top: -26px;
padding-left: 40px;
}
.breadcrumb {
border-radius: 0;
}
.file-manager-actions .fa {
padding: 3px;
cursor: pointer;
}
</style>
<script>
const axios = require('axios').default
const routes = require('../../../../../public/js/fos_js_routes.json')
import Routing from '../../../../../vendor/friendsofsymfony/jsrouting-bundle/Resources/public/js/router.min.js';
import FileIcon from './FileIcon';
export default {
name: "Files",
components: {
FileIcon,
},
data() {
return {
view: 'list',
directory: null,
directories: [],
breadcrumb: [],
files: [],
parent: null,
}
},
methods: {
setDirectory(directory) {
this.directory = directory
},
setView(view) {
this.view = view
localStorage.setItem('file-manager.view', view)
},
generateInfoLink(item, directory) {
if (directory) {
return Routing.generate('admin_file_manager_info', {
file: item.path
})
} else {
return Routing.generate('admin_file_manager_info', {
file: item.path + '/' + item.basename
})
}
},
generateUploadLink(directory) {
return Routing.generate('admin_file_manager_upload', {
file: directory
})
},
generateNewDirectoryLink(directory) {
return Routing.generate('admin_file_manager_directory_new', {
file: directory
})
},
buildBreadcrum(elements) {
let path = '/'
this.breadcrumb = []
for (let i in elements) {
const element = elements[i]
if (element !== '/') {
path = path + '/' + element
this.breadcrumb.push({
path: path,
label: element,
})
} else {
this.breadcrumb.push({
path: '/',
label: 'Files',
})
}
}
}
},
mounted() {
Routing.setRoutingData(routes)
let view = localStorage.getItem('file-manager.view')
if (['grid', 'list'].indexOf(view) !== -1) {
this.view = view
}
const query = new URLSearchParams(window.location.search)
if (query.has('path')) {
this.setDirectory(query.get('path'))
} else {
this.setDirectory('/')
}
},
watch: {
directory(directory) {
axios.get(Routing.generate('admin_file_manager_api_directory', {
directory: this.directory
}))
.then((response) => {
this.buildBreadcrum(response.data.breadcrumb)
this.parent = response.data.parent
this.directories = response.data.directories
this.files = response.data.files
const query = new URLSearchParams(window.location.search)
query.set('path', directory)
history.pushState(
null,
'',
window.location.pathname + '?' + query.toString()
)
})
.catch(() => {
alert('An error occured')
})
}
}
}
</script>

2
assets/js/admin/modules/editor.js

@ -96,5 +96,5 @@ module.exports = function() {
const config = {attributes: false, childList: true, subtree: true};
observer.observe(document.querySelector('body'), config);
$(window).ready(doInitEditor);
doInitEditor();
};

18
assets/js/admin/modules/file-manager.js

@ -0,0 +1,18 @@
// file-manager
const Vue = require('vue').default
const FileManager = require('../components/file-manager/FileManager').default
module.exports = () => {
if (!document.getElementById('file-manager')) {
return
}
new Vue({
el: '#file-manager',
template: '<FileManager />',
components: {
FileManager
}
});
}

2
assets/js/admin/modules/form-confirm.js

@ -1,7 +1,7 @@
const $ = require('jquery');
module.exports = function() {
$('*[data-form-confirm]').submit(function(e) {
$('body').on('submit', '*[data-form-confirm]', function(e) {
let message = $(this).attr('data-form-confirm');
if (!message) {

2
assets/js/admin/modules/form.js

@ -1,7 +1,7 @@
const $ = require('jquery');
module.exports = function() {
$('.custom-file-input').on('change', function(event) {
$('body').on('change', '.custom-file-input', function(event) {
let inputFile = event.currentTarget;
$(inputFile).parent()

49
assets/js/admin/modules/modal.js

@ -1,31 +1,50 @@
const $ = require('jquery');
module.exports = function() {
let click = 0;
$('body').on('click', '*[data-modal]', (e) => {
e.preventDefault();
e.stopPropagation();
let container = $('#modal-container');
const body = $('body')
++click;
window.setTimeout(() => {
if (click !== 1) {
click = 0;
return;
}
click = 0;
let container = $('#modal-container');
const body = $('body')
if (!container.length) {
container = $('<div id="modal-container" class="modal">');
body.append(container);
}
if (!container.length) {
container = $('<div id="modal-container" class="modal">');
const loader = $('<div style="position: absolute; top: 25vh; left: 50vw; z-index: 2000">');
loader.html('<div class="spinner-border text-primary" role="status"><span class="sr-only">Loading...</span></div>');
body.append(loader);
body.append(container);
}
container.html('');
const loader = $('<div style="position: absolute; top: 25vh; left: 50vw; z-index: 2000">');
loader.html('<div class="spinner-border text-primary" role="status"><span class="sr-only">Loading...</span></div>');
body.append(loader);
let url = $(e.target).attr('data-modal');
container.html('');
if (!url) {
url = $(e.target).parents('*[data-modal]').first().attr('data-modal');
}
const url = $(e.target).attr('data-modal');
$(container).modal('show');
$(container).modal('show');
container.load(url, function() {
loader.remove()
});
container.load(url, function() {
loader.remove()
});
}, 250)
});
const urlParams = new URLSearchParams(window.location.search)

2
composer.json

@ -16,6 +16,7 @@
"doctrine/doctrine-bundle": "^2.2",
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^2.8",
"friendsofsymfony/jsrouting-bundle": "^2.7",
"knplabs/doctrine-behaviors": "^2.2",
"knplabs/knp-paginator-bundle": "^5.4",
"phpdocumentor/reflection-docblock": "^5.2",
@ -23,6 +24,7 @@
"scheb/2fa-qr-code": "^5.7",
"sensio/framework-extra-bundle": "^6.1",
"sensiolabs/ansi-to-html": "^1.2",
"spe/filesize-extension-bundle": "~2.0.0",
"stof/doctrine-extensions-bundle": "^1.6",
"symfony/apache-pack": "^1.0",
"symfony/asset": "5.2.*",

1
config/bundles.php

@ -20,4 +20,5 @@ return [
App\Core\Bundle\CoreBundle::class => ['all' => true],
App\Bundle\AppBundle::class => ['all' => true],
Knp\DoctrineBehaviors\DoctrineBehaviorsBundle::class => ['all' => true],
SPE\FilesizeExtensionBundle\SPEFilesizeExtensionBundle::class => ['all' => true],
];

23
config/packages/app.yaml

@ -9,3 +9,26 @@ core:
name: 'Simple page'
templates:
- {name: "Default", file: "page/simple/default.html.twig"}
file_manager:
# mimes:
# - image/png
# - image/jpg
# - image/jpeg
# - image/gif
# - application/pdf
# - application/ogg
# - video/mp4
# - application/zip
# - multipart/x-zip
# - application/rar
# - application/x-rar-compressed
# - application/x-zip-compressed
# - application/tar
# - application/x-tar
# - text/plain
# - text/x-asm
# - application/octet-stream
# path: "%kernel.project_dir%/public/uploads"
# path_uri: "/uploads"
# path_locked:
# - "%kernel.project_dir%/public/uploads"

1
config/packages/security.yaml

@ -49,5 +49,6 @@ security:
- { path: ^/admin/task, roles: ROLE_ADMIN }
- { path: ^/admin/setting, roles: ROLE_ADMIN }
- { path: ^/admin/site, roles: ROLE_WRITER }
- { path: ^/admin/file_manager, roles: ROLE_WRITER }
- { path: ^/admin, roles: ROLE_USER }
- { path: ^/_internal, roles: IS_AUTHENTICATED_ANONYMOUSLY }

232
core/Controller/FileManager/FileManagerAdminController.php

@ -0,0 +1,232 @@
<?php
namespace App\Core\Controller\FileManager;
use App\Core\Controller\Admin\AdminController;
use App\Core\FileManager\FsFileManager;
use App\Core\Form\FileManager\DirectoryCreateType;
use App\Core\Form\FileManager\DirectoryRenameType;
use App\Core\Form\FileManager\FileUploadType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route("/admin/file_manager")
*/
class FileManagerAdminController extends AdminController
{
/**
* @Route("/", name="admin_file_manager_index")
*/
public function index(): Response
{
return $this->render('@Core/file_manager/index.html.twig');
}
/**
* @Route("/api/directory", name="admin_file_manager_api_directory", options={"expose"=true})
*/
public function directory(FsFileManager $manager, Request $request): Response
{
$files = $manager->list($request->query->get('directory', '/'));
return $this->json($files);
}
/**
* @Route("/info", name="admin_file_manager_info", options={"expose"=true})
*/
public function info(FsFileManager $manager, Request $request): Response
{
$info = $manager->info($request->query->get('file'));
if (!$info) {
throw $this->createNotFoundException();
}
$path = $manager->getPathUri().'/'.$info->getRelativePathname();
return $this->render('@Core/file_manager/info.html.twig', [
'info' => $info,
'path' => $path,
'isLocked' => $manager->isLocked($info->getRelativePathname()),
]);
}
/**
* @Route("/directory/new", name="admin_file_manager_directory_new", options={"expose"=true}, methods={"GET", "POST"})
*/
public function directoryNew(FsFileManager $manager, Request $request): Response
{
$info = $manager->info($request->query->get('file'));
if (!$info) {
throw $this->createNotFoundException();
}
if (!$info->isDir()) {
throw $this->createNotFoundException();
}
if ($manager->isLocked($request->query->get('file'))) {
return $this->render('@Core/file_manager/directory_new.html.twig', [
'locked' => true,
]);
}
$form = $this->createForm(DirectoryCreateType::class);
if ($request->isMethod('POST')) {
$form->handleRequest($request);
if ($form->isValid()) {
$status = $manager->createDirectory($form->get('name')->getData(), $request->query->get('file'));
if (true === $status) {
$this->addFlash('success', 'Directory created.');
} else {
$this->addFlash('warning', 'Directory not created.');
}
} else {
$this->addFlash('warning', 'Unauthorized char(s).');
}
return $this->redirectToRoute('admin_file_manager_index', [
'path' => $info->getRelativePath(),
]);
}
return $this->render('@Core/file_manager/directory_new.html.twig', [
'form' => $form->createView(),
'file' => $request->query->get('file'),
'locked' => false,
]);
}
/**
* @Route("/directory/rename", name="admin_file_manager_directory_rename", methods={"GET", "POST"})
*/
public function directoryRename(FsFileManager $manager, Request $request): Response
{
$info = $manager->info($request->query->get('file'));
if (!$info) {
throw $this->createNotFoundException();
}
if (!$info->isDir()) {
throw $this->createNotFoundException();
}
if ($manager->isLocked($request->query->get('file'))) {
return $this->render('@Core/file_manager/directory_rename.html.twig', [
'locked' => true,
]);
}
$form = $this->createForm(DirectoryRenameType::class);
if ($request->isMethod('POST')) {
$form->handleRequest($request);
if ($form->isValid()) {
$status = $manager->renameDirectory($form->get('name')->getData(), $request->query->get('file'));
if (true === $status) {
$this->addFlash('success', 'Directory renamed.');
} else {
$this->addFlash('warning', 'Directory not renamed.');
}
} else {
$this->addFlash('warning', 'Unauthorized char(s).');
}
return $this->redirectToRoute('admin_file_manager_index', [
'path' => $info->getRelativePath(),
]);
}
return $this->render('@Core/file_manager/directory_rename.html.twig', [
'form' => $form->createView(),
'file' => $request->query->get('file'),
'locked' => false,
]);
}
/**
* @Route("/upload", name="admin_file_manager_upload", options={"expose"=true}, methods={"GET", "POST"})
*/
public function upload(FsFileManager $manager, Request $request): Response
{
$info = $manager->info($request->query->get('file'));
if (!$info) {
throw $this->createNotFoundException();
}
if (!$info->isDir()) {
throw $this->createAccessDeniedException();
}
if ($manager->isLocked($request->query->get('file'))) {
return $this->render('@Core/file_manager/upload.html.twig', [
'locked' => true,
]);
}
$form = $this->createForm(FileUploadType::class, null, [
'mimes' => $manager->getMimes(),
]);
if ($request->isMethod('POST')) {
$form->handleRequest($request);
if ($form->isValid()) {
$manager->upload($form->get('files')->getData(), $request->query->get('file'));
$this->addFlash('success', 'Files uploaded.');
} else {
$this->addFlash('warning', 'Unauthorized file type(s).');
}
return $this->redirectToRoute('admin_file_manager_index', [
'path' => $info->getRelativePath(),
]);
}
return $this->render('@Core/file_manager/upload.html.twig', [
'form' => $form->createView(),
'file' => $request->query->get('file'),
'locked' => false,
]);
}
/**
* @Route("/delete", name="admin_file_manager_delete", methods={"DELETE"})
*/
public function delete(FsFileManager $manager, Request $request): Response
{
$path = $request->request->get('file');
$info = $manager->info($request->request->get('file'));
if (!$info) {
throw $this->createNotFoundException();
}
if ($this->isCsrfTokenValid('delete', $request->request->get('_token'))) {
if ($manager->delete($path)) {
$this->addFlash('success', 'The data has been removed.');
} else {
$this->addFlash('warning', 'The data has not been removed.');
}
}
return $this->redirectToRoute('admin_file_manager_index', [
'path' => $info->getRelativePath(),
]);
}
protected function getSection(): string
{
return 'file_manager';
}
}

89
core/DependencyInjection/Configuration.php

@ -9,6 +9,30 @@ class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$defaultMimetypes = [
'image/png',
'image/jpg',
'image/jpeg',
'image/gif',
'application/pdf',
'application/ogg',
'video/mp4',
'application/zip',
'multipart/x-zip',
'application/rar',
'application/x-rar-compressed',
'application/x-zip-compressed',
'application/tar',
'application/x-tar',
'text/plain',
'text/x-asm',
'application/octet-stream'
];
$defaultLocked = [
'%kernel.project_dir%/public/uploads',
];
$treeBuilder = new TreeBuilder('core');
$treeBuilder->getRootNode()
@ -16,45 +40,70 @@ class Configuration implements ConfigurationInterface
->arrayNode('site')
->children()
->scalarNode('name')
->defaultValue('Murph')
->isRequired()
->cannotBeEmpty()
->end()
->scalarNode('logo')
->defaultValue('build/images/core/logo.svg')
->isRequired()
->cannotBeEmpty()
->end()
->arrayNode('controllers')
->prototype('array')
->children()
->scalarNode('name')
->cannotBeEmpty()
->end()
->scalarNode('action')
->cannotBeEmpty()
->children()
->scalarNode('name')
->cannotBeEmpty()
->end()
->scalarNode('action')
->cannotBeEmpty()
->end()
->end()
->end()
->end()
->end()
->arrayNode('pages')
->prototype('array')
->children()
->scalarNode('name')
->isRequired()
->cannotBeEmpty()
->end()
->arrayNode('templates')
->prototype('array')
->children()
->scalarNode('name')
->cannotBeEmpty()
->end()
->scalarNode('file')
->cannotBeEmpty()
->children()
->scalarNode('name')
->isRequired()
->cannotBeEmpty()
->end()
->arrayNode('templates')
->prototype('array')
->children()
->scalarNode('name')
->cannotBeEmpty()
->end()
->scalarNode('file')
->cannotBeEmpty()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->arrayNode('file_manager')
->children()
->arrayNode('mimes')
->scalarPrototype()
->end()
->defaultValue($defaultMimetypes)
->end()
->scalarNode('path')
->defaultValue('%kernel.project_dir%/public/uploads')
->cannotBeEmpty()
->end()
->scalarNode('path_uri')
->defaultValue('/uploads')
->cannotBeEmpty()
->end()
->arrayNode('path_locked')
->scalarPrototype()
->end()
->defaultValue($defaultLocked)
->end()
->end()
->end()

230
core/FileManager/FsFileManager.php

@ -0,0 +1,230 @@
<?php
namespace App\Core\FileManager;
use App\Core\Form\FileUploadHandler;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use function Symfony\Component\String\u;
/**
* class FsFileManager.
*
* @author Simon Vieille <simon@deblan.fr>
*/
class FsFileManager
{
protected array $mimes;
protected string $path;
protected string $pathUri;
protected array $pathLocked;
protected FileUploadHandler $uploadHandler;
public function __construct(ParameterBagInterface $params, FileUploadHandler $uploadHandler)
{
$config = $params->get('core')['file_manager'];
$this->uploadHandler = $uploadHandler;
$this->mimes = $config['mimes'];
$this->path = $config['path'];
$this->pathUri = $this->normalizePath($config['path_uri']);
foreach ($config['path_locked'] as $k => $v) {
$config['path_locked'][$k] = sprintf('/%s/', $this->normalizePath($v));
}
$this->pathLocked = $config['path_locked'];
}
public function list(string $directory): array
{
$directory = $this->normalizePath($directory);
$breadcrumb = ['/'];
if ($directory) {
$breadcrumb = array_merge(
$breadcrumb,
explode('/', $directory)
);
}
$data = [
'breadcrumb' => $breadcrumb,
'parent' => dirname($directory),
'directories' => [],
'files' => [],
];
$finder = new Finder();
$finder->directories()->depth('== 0')->in($this->path.'/'.$directory);
foreach ($finder as $file) {
$data['directories'][] = [
'basename' => $file->getBasename(),
'path' => $directory.'/'.$file->getBasename(),
'locked' => $this->isLocked($directory.'/'.$file->getBasename()),
'mime' => null,
];
}
$finder = new Finder();
$finder->files()->depth('== 0')->in($this->path.'/'.$directory);
foreach ($finder as $file) {
$data['files'][] = [
'basename' => $file->getBasename(),
'path' => $directory,
'locked' => $this->isLocked($directory.'/'.$file->getBasename()),
'mime' => mime_content_type($file->getRealPath()),
];
}
return $data;
}
public function info(string $path): ?SplFileInfo
{
$path = $this->normalizePath($path);
if ('' === $path) {
return new SplFileInfo($this->path, '', '');
}
$finder = new Finder();
$finder->in($this->path)
->name(basename($path))
;
$dirname = dirname($path);
if ('.' === $dirname) {
$dirname = '';
}
foreach ($finder as $file) {
if ($file->getRelativePath() === $dirname) {
return $file;
}
}
return null;
}
public function createDirectory(string $name, string $path): bool
{
$file = $this->info($path);
if (!$file || $this->isLocked($path)) {
return false;
}
$filesystem = new Filesystem();
$path = $file->getPathname().'/'.$this->normalizePath($name);
if ($filesystem->exists($path)) {
return false;
}
$filesystem->mkdir($path, 0700);
return true;
}
public function renameDirectory(string $name, string $path): bool
{
$file = $this->info($path);
if (!$file || $this->isLocked($path)) {
return false;
}
$filesystem = new Filesystem();
$newPath = $file->getPath().'/'.$this->normalizePath($name);
if ($filesystem->exists($newPath)) {
return false;
}
$filesystem->rename($file->getPathName(), $newPath);
return true;
}
public function upload($files, string $path)
{
if ($files instanceof UploadedFile) {
$files = [$files];
}
foreach ($files as $file) {
$this->uploadHandler->handleForm($file, $this->path.'/'.$path, null, true);
}
}
public function delete(string $path): bool
{
$file = $this->info($path);
if ($this->isLocked($file)) {
return false;
}
if ($file) {
$filesystem = new Filesystem();
$filesystem->remove($file);
}
return false;
}
public function isLocked($path): bool
{
$file = $this->info($path);
if (!$file) {
return false;
}
foreach ($this->pathLocked as $lock) {
if (u($file->getPathName().'/')->startsWith($lock)) {
return true;
}
}
return false;
}
public function getPath(): string
{
return $this->path;
}
public function getPathUri(): string
{
return $this->pathUri;
}
public function getMimes(): array
{
return $this->mimes;
}
public function getPathLocked(): array
{
return $this->pathLocked;
}
protected function normalizePath(string $path): string
{
return (string) u($path)
->replace('..', '.')
->replaceMatches('#/{2,}#', '/')
->replaceMatches('#^.$#', '')
->trim('/')
->trim()
;
}
}

43
core/Form/FileManager/DirectoryCreateType.php

@ -0,0 +1,43 @@
<?php
namespace App\Core\Form\FileManager;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Validator\Constraints\File;
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Validator\Constraints\Regex;
use Symfony\Component\Validator\Constraints\NotBlank;
class DirectoryCreateType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add(
'name',
TextType::class,
[
'label' => 'Name',
'required' => true,
'attr' => [
],
'constraints' => [
new Regex([
'pattern' => '#['.preg_quote('\\/?%*:|"<>').'\t\n\r]+#',
'match' => false,
]),
new NotBlank(),
],
]
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
]);
}
}

43
core/Form/FileManager/DirectoryRenameType.php

@ -0,0 +1,43 @@
<?php
namespace App\Core\Form\FileManager;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Validator\Constraints\File;
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Validator\Constraints\Regex;
use Symfony\Component\Validator\Constraints\NotBlank;
class DirectoryRenameType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add(
'name',
TextType::class,
[
'label' => 'Name',
'required' => true,
'attr' => [
],
'constraints' => [
new Regex([
'pattern' => '#['.preg_quote('\\/?%*:|"<>').'\t\n\r]+#',
'match' => false,
]),
new NotBlank(),
],
]
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
]);
}
}

42
core/Form/FileManager/FileUploadType.php

@ -0,0 +1,42 @@
<?php
namespace App\Core\Form\FileManager;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Validator\Constraints\File;
use Symfony\Component\Validator\Constraints\All;
class FileUploadType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add(
'files',
FileType::class,
[
'label' => 'Files',
'required' => true,
'multiple' => true,
'attr' => [
],
'constraints' => [
new All([
new File([
'mimeTypes' => $options['mimes'],
]),
]),
],
]
);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'mimes' => [],
]);
}
}

15
core/Form/FileUploadHandler.php

@ -11,18 +11,25 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
*/
class FileUploadHandler
{
public function handleForm(?UploadedFile $uploadedFile, string $path, callable $afterUploadCallback): void
public function handleForm(?UploadedFile $uploadedFile, string $path, ?callable $afterUploadCallback = null, bool $keepOriginalFilename = false): void
{
if (null === $uploadedFile) {
return;
}
$originalFilename = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME);
$safeFilename = transliterator_transliterate('Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()', $originalFilename);
$filename = date('Ymd-his').$safeFilename.'.'.$uploadedFile->guessExtension();
if ($keepOriginalFilename) {
$filename = $originalFilename.'.'.$uploadedFile->guessExtension();
} else {
$safeFilename = transliterator_transliterate('Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()', $originalFilename);
$filename = date('Ymd-his').$safeFilename.'.'.$uploadedFile->guessExtension();
}
$uploadedFile->move($path, $filename);
$afterUploadCallback($filename);
if ($afterUploadCallback) {
$afterUploadCallback($filename);
}
}
}

20
core/Resources/translations/messages.fr.yaml

@ -12,6 +12,7 @@
"Error": "Erreur"
"The data has been saved.": "Les données ont été sauvegardées."
"The data has been removed.": "Les données ont été supprimées."
"The data has not been removed.": "Les données n'ont pas été supprimées."
"You must add a navigation.": "Vous devez ajouter une navigation."
"E-mail sent.": "E-mail envoyé"
"name": "Nom"
@ -165,3 +166,22 @@
"For selection": "Pour la sélection"
"For all items": "Pour tous les éléments"
"Run": "Lancer"
"Download": "Télécharger"
"Absolute URL": "URL absolue"
"Relative URL": "URL relative"
"Creation date": "Date de création"
"Modification date": "Date de modification"
"File size": "Poids du fichier"
"File manager": "Gestionnaire de fichiers"
"File(s) uploaded.": "Fichier(s) déposé(s)."
"Unauthorized file type(s).": "Type(s) de fichier non autorisé(s)"
"Directory locked.": "Répertoire verrouillé."
"File locked.": "Fichier verrouillé."
"Upload files": "Envoyer des fichiers"
"Files": "Fichiers"
"Directory created.": "Répertoire créé."
"Directory not created.": "Répertoire non créé."
"Directory renamed.": "Répertoire renommé."
"Directory not renamed.": "Répertoire non renommé."
"Rename": "Renommer"
"Rename directory": "Renommer le répertoire"

10
core/Resources/views/admin/module/menu.html.twig

@ -47,6 +47,16 @@
</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ macros_menu.active_class('file_manager', section) }}" href="{{ path('admin_file_manager_index') }}">
<span class="fa fa-photo-video"></span>
<span class="nav-item-label">
{{ 'Files'|trans }}
</span>
</a>
</li>
</ul>
{% endif %}

1
core/Resources/views/file_manager/_form.html.twig

@ -0,0 +1 @@
{{ form_widget(form) }}

33
core/Resources/views/file_manager/directory_new.html.twig

@ -0,0 +1,33 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ 'New directory'|trans }}
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{% if locked %}
<p class="text-danger text-center">
<span class="d-block display-4 mb-3">
<span class="fa fa-lock"></span>
</span>
{{ 'Directory locked.'|trans }}
</p>
{% else %}
<form action="{{ path('admin_file_manager_directory_new', {file: file}) }}" id="form-file-manager-directory" method="POST" enctype="multipart/form-data">
{{ include('@Core/file_manager/_form.html.twig') }}
</form>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ 'Cancel'|trans }}</button>
{% if not locked %}
<button type="submit" form="form-file-manager-directory" class="btn btn-primary">{{ 'Save'|trans }}</button>
{% endif %}
</div>
</div>
</div>

33
core/Resources/views/file_manager/directory_rename.html.twig

@ -0,0 +1,33 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ 'Rename directory'|trans }}
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{% if locked %}
<p class="text-danger text-center">
<span class="d-block display-4 mb-3">
<span class="fa fa-lock"></span>
</span>
{{ 'Directory locked.'|trans }}
</p>
{% else %}
<form action="{{ path('admin_file_manager_directory_rename', {file: file}) }}" id="form-file-manager-directory" method="POST" enctype="multipart/form-data">
{{ include('@Core/file_manager/_form.html.twig') }}
</form>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ 'Cancel'|trans }}</button>
{% if not locked %}
<button type="submit" form="form-file-manager-directory" class="btn btn-primary">{{ 'Save'|trans }}</button>
{% endif %}
</div>
</div>
</div>

16
core/Resources/views/file_manager/index.html.twig

@ -0,0 +1,16 @@
{% extends '@Core/admin/layout.html.twig' %}
{% block body %}
<div class="bg-light pl-5 pr-4 pt-5 pb-5">
<div class="d-flex">
<div class="mr-auto w-50">
<h1 class="display-5">
{{ 'File manager'|trans }}
</h1>
</div>
</div>
</div>
<div id="file-manager">
</div>
{% endblock %}

93
core/Resources/views/file_manager/info.html.twig

@ -0,0 +1,93 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ 'Information'|trans }}
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{% if info.type == 'file' %}
<div class="form-group">
<label for="file-manager-url">{{ 'Absolute URL'|trans }}</label><br>
<input class="form-control" type="text" readonly value="{{ absolute_url(asset(path)) }}" id="file-manager-url" />
</div>
<p>
<label for="file-manager-url2">{{ 'Relative URL'|trans }}</label><br>
<input class="form-control" type="text" readonly value="{{ asset(path) }}" id="file-manager-url2" />
</p>
{% endif %}
<ul class="list-group mb-3">
{% if info.type == 'file' %}
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ 'File size'|trans }}
<button class="btn btn-sm btn-light">{{ info.size|readable_filesize }}</button>
</li>
{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ 'Creation date'|trans }}
<button class="btn btn-sm btn-light">{{ info.mTime|date('Y-m-d H:i') }}</button>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ 'Modification date'|trans }}
<button class="btn btn-sm btn-light">{{ info.cTime|date('Y-m-d H:i') }}</button>
</li>
</ul>
{% if info.extension in ['jpeg', 'jpg', 'gif', 'png', 'svg'] %}
<div class="card">
<div class="card-img-top bg-tiles text-center">
<a href="{{ asset(path) }}" target="_blank">
<img src="{{ asset(path) }}" class="img-fluid">
</a>
</div>
</div>
{% endif %}
</div>
<div class="modal-footer justify-content-between">
{% if not isLocked %}
<div>
<button type="submit" form="form-file-delete" class="btn btn-danger" form="form-file-delete">
{{ 'Delete'|trans }}
</button>
{% if info.isDir %}
<button form="form-file-delete" class="btn btn-primary" data-modal="{{ path('admin_file_manager_directory_rename', {file: info.relativePathname}) }}">
{{ 'Rename'|trans }}
</button>
{% endif %}
</div>
{% else %}
<span class="btn btn-light">
<span class="fa fa-lock"></span>
</span>
{% endif %}
<div class="div">
{% if info.type == 'file' %}
<a class="btn btn-primary" href="{{ asset(path) }}" target="_blank">
{{ 'Download'|trans }}
</a>
{% endif %}
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ 'Close'|trans }}</button>
</div>
</div>
</div>
</div>
{% if not isLocked %}
<form method="post" action="{{ path('admin_file_manager_delete') }}" id="form-file-delete" data-form-confirm>
<input type="hidden" name="file" value="{{ info.relativePathname }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete') }}">
<input type="hidden" name="_method" value="DELETE">
</form>
{% endif %}

33
core/Resources/views/file_manager/upload.html.twig

@ -0,0 +1,33 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ 'Upload files'|trans }}
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{% if locked %}
<p class="text-danger text-center">
<span class="d-block display-4 mb-3">
<span class="fa fa-lock"></span>
</span>
{{ 'Directory locked.'|trans }}
</p>
{% else %}
<form action="{{ path('admin_file_manager_upload', {file: file}) }}" id="form-file-manager-upload" method="POST" enctype="multipart/form-data">
{{ include('@Core/file_manager/_form.html.twig') }}
</form>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ 'Cancel'|trans }}</button>
{% if not locked %}
<button type="submit" form="form-file-manager-upload" class="btn btn-primary">{{ 'Upload'|trans }}</button>
{% endif %}
</div>
</div>
</div>

2
core/Resources/views/form/bootstrap_4_form_theme.html.twig

@ -24,7 +24,7 @@
<div class="p-2 text-center">
<a class="btn btn-primary" href="{{ asset(value.pathname) }}" target="_blank">
Télécharger
{{ 'Download'|trans }}
</a>
</div>
</div>

6
package.json

@ -2,12 +2,16 @@
"devDependencies": {
"@symfony/stimulus-bridge": "^2.0.0",
"@symfony/webpack-encore": "^1.0.0",
"@vue/babel-helper-vue-jsx-merge-props": "^1.2.1",
"@vue/babel-preset-jsx": "^1.2.4",
"core-js": "^3.0.0",
"file-loader": "^6.0.0",
"node-sass": "^4.13.1",
"regenerator-runtime": "^0.13.2",
"sass-loader": "^11.0.0",
"stimulus": "^2.0.0",
"vue-loader": "^15.9.5",
"vue-template-compiler": "^2.6.14",
"webpack-notifier": "^1.6.0"
},
"license": "UNLICENSED",
@ -20,6 +24,7 @@
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.11.2",
"axios": "^0.21.1",
"bootstrap": "^4.3.1",
"choices.js": "^9.0.1",
"flag-icon-css": "^3.5.0",
@ -29,6 +34,7 @@
"sortablejs": "^1.13.0",
"tinymce": "^5.7.1",
"vanillajs-datepicker": "^1.1.2",
"vue": "^2.6.14",
"zxcvbn": "^4.4.2"
}
}

18
symfony.lock

@ -108,6 +108,18 @@
"friendsofphp/proxy-manager-lts": {
"version": "v1.0.3"
},
"friendsofsymfony/jsrouting-bundle": {
"version": "2.3",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "master",
"version": "2.3",
"ref": "a9f2e49180f75cdc71ae279a929c4b2e0638de84"
},
"files": [
"config/routes/fos_js_routing.yaml"
]
},
"gedmo/doctrine-extensions": {
"version": "v3.0.3"
},
@ -220,6 +232,9 @@
"config/packages/ansi_to_html.yaml"
]
},
"spe/filesize-extension-bundle": {
"version": "2.0.0"
},
"spomky-labs/otphp": {
"version": "v10.0.1"
},
@ -667,5 +682,8 @@
},
"webmozart/assert": {
"version": "1.10.0"
},
"willdurand/jsonp-callback-validator": {
"version": "v1.1.0"
}
}

1
webpack.config.js

@ -38,6 +38,7 @@ Encore
*/
.cleanupOutputBeforeBuild()
.enableBuildNotifications()
.enableVueLoader()
.enableSourceMaps(!Encore.isProduction())
// enables hashed filenames (e.g. app.abc123.css)
.enableVersioning(Encore.isProduction())

239
yarn.lock

@ -141,6 +141,13 @@
dependencies:
"@babel/types" "^7.13.0"
"@babel/helper-module-imports@^7.0.0":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3"
integrity sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==
dependencies:
"@babel/types" "^7.14.5"
"@babel/helper-module-imports@^7.12.13":
version "7.12.13"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.13.tgz#ec67e4404f41750463e455cc3203f6a32e93fcb0"
@ -175,6 +182,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz#806526ce125aed03373bc416a828321e3a6a33af"
integrity sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ==
"@babel/helper-plugin-utils@^7.14.5":
version "7.14.5"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9"
integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==
"@babel/helper-remap-async-to-generator@^7.13.0":
version "7.13.0"