Skip to content

Admin: Add temp upload cleanup UI + CLI (cache:clear-uploads) - refs #5601 #6601

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions src/CoreBundle/Command/ClearTempUploadsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

/* For licensing terms, see /license.txt */

declare(strict_types=1);

namespace Chamilo\CoreBundle\Command;

use Chamilo\CoreBundle\Framework\Container;
use Chamilo\CoreBundle\Helpers\TempUploadHelper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
name: 'cache:clear-uploads',
description: 'Clear temporary uploaded files (async upload chunks/cache).',
)]
final class ClearTempUploadsCommand extends Command
{
public function __construct(
private readonly TempUploadHelper $helper
) {
parent::__construct();
}

protected function configure(): void
{
$this
->addOption('older-than', null, InputOption::VALUE_REQUIRED, 'Minutes threshold (0 = delete all)', '60')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Preview only, do not delete')
->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Override temp upload directory')
->setHelp(
<<<'HELP'
Clears the configured temporary uploads directory (async upload chunks/cache).
Options:
--older-than=MIN Only delete files older than MIN minutes (default: 60). Use 0 to delete all files.
--dry-run Report what would be deleted without deleting.
--dir=PATH Override the configured temp directory for this run.

Examples:
php bin/console cache:clear-uploads --older-than=60
php bin/console cache:clear-uploads --dry-run
php bin/console cache:clear-uploads --dir=/var/www/chamilo/var/uploads_tmp
HELP
);
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$kernelContainer = $this->getApplication()?->getKernel()?->getContainer();
if ($kernelContainer) {
Container::setContainer($kernelContainer);
}

$io = new SymfonyStyle($input, $output);
$olderThan = (int) $input->getOption('older-than');
$dryRun = (bool) $input->getOption('dry-run');
$dir = $input->getOption('dir');

if ($olderThan < 0) {
$io->error('Option --older-than must be >= 0.');
return Command::INVALID;
}

// Select helper (allow --dir override)
$targetHelper = $this->helper;
if (!empty($dir)) {
// quick override instance (no service registration needed)
$targetHelper = new TempUploadHelper($dir);
if (!is_dir($targetHelper->getTempDir()) || !is_readable($targetHelper->getTempDir())) {
$io->error(sprintf('Directory not readable: %s', $targetHelper->getTempDir()));
return Command::FAILURE;
}
}

$tempDir = $targetHelper->getTempDir();

// Run purge
$stats = $targetHelper->purge($olderThan, $dryRun);

$mb = $stats['bytes'] / 1048576;
if ($dryRun) {
$io->note(sprintf(
'DRY RUN: %d files (%.2f MB) would be removed in %s',
$stats['files'],
$mb,
$tempDir
));
} else {
$io->success(sprintf(
'CLEANED: %d files removed (%.2f MB) in %s',
$stats['files'],
$mb,
$tempDir
));
}

return Command::SUCCESS;
}
}
75 changes: 75 additions & 0 deletions src/CoreBundle/Controller/Admin/AdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@

namespace Chamilo\CoreBundle\Controller\Admin;

use Chamilo\CoreBundle\Component\Composer\ScriptHandler;
use Chamilo\CoreBundle\Controller\BaseController;
use Chamilo\CoreBundle\Entity\ResourceLink;
use Chamilo\CoreBundle\Entity\ResourceType;
use Chamilo\CoreBundle\Helpers\AccessUrlHelper;
use Chamilo\CoreBundle\Helpers\QueryCacheHelper;
use Chamilo\CoreBundle\Helpers\TempUploadHelper;
use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Chamilo\CoreBundle\Repository\ResourceFileRepository;
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
Expand Down Expand Up @@ -194,4 +196,77 @@ public function invalidateCacheAllUsers(QueryCacheHelper $queryCacheHelper): Jso
'invalidated_cache_key' => $cacheKey,
]);
}

#[IsGranted('ROLE_ADMIN')]
#[Route('/cleanup-temp-uploads', name: 'admin_cleanup_temp_uploads', methods: ['GET'])]
public function showCleanupTempUploads(
TempUploadHelper $tempUploadHelper,
): Response {
$stats = $tempUploadHelper->stats(); // ['files' => int, 'bytes' => int]

return $this->render('@ChamiloCore/Admin/cleanup_temp_uploads.html.twig', [
'tempDir' => $tempUploadHelper->getTempDir(),
'stats' => $stats,
'defaultOlderThan' => 0, // 0 = delete all
]);
}

#[IsGranted('ROLE_ADMIN')]
#[Route('/cleanup-temp-uploads', name: 'admin_cleanup_temp_uploads_run', methods: ['POST'])]
public function runCleanupTempUploads(
Request $request,
TempUploadHelper $tempUploadHelper,
): Response {
// CSRF
$token = (string) $request->request->get('_token', '');
if (!$this->isCsrfTokenValid('cleanup_temp_uploads', $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}

// Read inputs
$olderThan = (int) ($request->request->get('older_than', 0));
$dryRun = (bool) $request->request->get('dry_run', false);

// 1) Purge temp uploads/cache (configurable dir via helper parameter)
$purge = $tempUploadHelper->purge(olderThanMinutes: $olderThan, dryRun: $dryRun);

if ($dryRun) {
$this->addFlash('success', sprintf(
'DRY RUN: %d files (%.2f MB) would be removed from %s.',
$purge['files'],
$purge['bytes'] / 1048576,
$tempUploadHelper->getTempDir()
));
} else {
$this->addFlash('success', sprintf(
'Temporary uploads/cache cleaned: %d files removed (%.2f MB) in %s.',
$purge['files'],
$purge['bytes'] / 1048576,
$tempUploadHelper->getTempDir()
));
}

// 2) Remove legacy build main.js and hashed variants (best effort)
$publicBuild = $this->getParameter('kernel.project_dir').'/public/build';
if (is_dir($publicBuild) && is_readable($publicBuild)) {
@unlink($publicBuild.'/main.js');
$files = @scandir($publicBuild) ?: [];
foreach ($files as $f) {
if (preg_match('/^main\..*\.js$/', $f)) {
@unlink($publicBuild.'/'.$f);
}
}
}

// 3) Rebuild styles/assets like original archive_cleanup.php
try {
ScriptHandler::dumpCssFiles();
$this->addFlash('success', 'The styles and assets in the web/ folder have been refreshed.');
} catch (\Throwable $e) {
$this->addFlash('error', 'The styles and assets could not be refreshed. Ensure public/ is writable.');
error_log($e->getMessage());
}

return $this->redirectToRoute('admin_cleanup_temp_uploads', [], Response::HTTP_SEE_OTHER);
}
}
6 changes: 6 additions & 0 deletions src/CoreBundle/Controller/Admin/IndexBlocksController.php
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,12 @@ private function getItemsSettings(): array
'label' => $this->translator->trans('Resources by type'),
];

$items[] = [
'class' => 'item-cleanup-temp-uploads',
'url' => '/admin/cleanup-temp-uploads',
'label' => $this->translator->trans('Clean uploaded temp files'),
];

return $items;
}

Expand Down
186 changes: 186 additions & 0 deletions src/CoreBundle/Helpers/TempUploadHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

/* For licensing terms, see /license.txt */

declare(strict_types=1);

namespace Chamilo\CoreBundle\Helpers;

use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
* Cleans temporary upload artifacts inside %kernel.project_dir%/var/cache
* while SAFELY skipping Symfony's own cache directories/files.
*/
final class TempUploadHelper
{
/** @var string[] Top-level directories to skip under var/cache */
private array $excludeTop = ['dev', 'prod', 'test', 'pools'];

/** @var string[] Regex patterns to skip anywhere under var/cache */
private array $excludePatterns = [
'#/(vich_uploader|twig|doctrine|http_cache|profiler)/#i',
'#/jms_[^/]+/#i',
];

public function __construct(
#[Autowire('%kernel.project_dir%/var/cache')]
private readonly string $tempUploadDir
) {}

public function getTempDir(): string
{
return rtrim($this->tempUploadDir, DIRECTORY_SEPARATOR);
}

/**
* Stats for files that WOULD be targeted (i.e., excluding Symfony cache).
* @return array{files:int,bytes:int}
*/
public function stats(): array
{
$dir = $this->getTempDir();
$this->assertBaseDir($dir);

$files = 0; $bytes = 0;

if (!is_dir($dir) || !is_readable($dir)) {
return ['files' => 0, 'bytes' => 0];
}

$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(
$dir,
\FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS
),
\RecursiveIteratorIterator::SELF_FIRST
);

foreach ($it as $f) {
if ($this->isExcluded($dir, $f)) {
// Skip whole excluded subtree quickly
if ($f->isDir()) {
$it->next(); // let iterator move on
}
continue;
}
if ($f->isFile()) {
$bn = $f->getBasename();
if ($this->isProtected($bn)) {
continue;
}
$files++;
$bytes += (int) $f->getSize();
}
}

return ['files' => $files, 'bytes' => $bytes];
}

/**
* Purge target files (excluding Symfony cache) older than $olderThanMinutes.
* If $olderThanMinutes = 0, delete all target files.
* If $dryRun = true, do not delete; only count.
*
* If $strict = true, DO NOT exclude Symfony cache: dangerous; use only
* for manual maintenance and ensure proper permissions afterwards.
*
* @return array{files:int,bytes:int}
*/
public function purge(int $olderThanMinutes = 0, bool $dryRun = false, bool $strict = false): array
{
$dir = $this->getTempDir();
$this->assertBaseDir($dir);

$deleted = 0; $bytes = 0;

if (!is_dir($dir) || !is_readable($dir)) {
return ['files' => 0, 'bytes' => 0];
}

$cutoff = $olderThanMinutes > 0 ? (time() - $olderThanMinutes * 60) : null;

$rii = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(
$dir,
\FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS
),
\RecursiveIteratorIterator::CHILD_FIRST
);

foreach ($rii as $f) {
if (!$strict && $this->isExcluded($dir, $f)) {
// Skip excluded subtree
if ($f->isDir()) {
$rii->next();
}
continue;
}

$bn = $f->getBasename();
if ($this->isProtected($bn)) {
continue;
}

if ($f->isFile()) {
if (null !== $cutoff && $f->getMTime() > $cutoff) {
continue;
}
$bytes += (int) $f->getSize();
if (!$dryRun) {
@unlink($f->getPathname());
}
$deleted++;
} elseif ($f->isDir()) {
if (!$dryRun) {
@rmdir($f->getPathname()); // best-effort (only if empty)
}
}
}

return ['files' => $deleted, 'bytes' => $bytes];
}

private function isProtected(string $basename): bool
{
return $basename === '.htaccess' || $basename === '.gitignore';
}

/**
* Prevent catastrophes and ensure directory exists & is writable.
*/
private function assertBaseDir(string $dir): void
{
// Ensure base exists
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
if (!is_writable($dir)) {
throw new \InvalidArgumentException(sprintf('Temp dir not writable: %s', $dir));
}
}

/**
* Decide if a file/dir should be excluded from cleanup.
*/
private function isExcluded(string $base, \SplFileInfo $f): bool
{
$path = $f->getPathname();
$rel = ltrim(str_replace('\\', '/', substr($path, strlen($base))), '/');

// Top-level directory name?
$first = explode('/', $rel, 2)[0] ?? '';
if ($first !== '' && in_array($first, $this->excludeTop, true)) {
return true;
}

// Pattern matches anywhere in the path
foreach ($this->excludePatterns as $re) {
if (preg_match($re, '/'.$rel.'/')) {
return true;
}
}

return false;
}
}
Loading
Loading