fix(api): implement pending uploads cleanup and enhanced error response (#1384)

## Summary
- Implements automatic cleanup mechanism for pending photo uploads older
than 24 hours
- Enhances error response to include actionable information about
pending uploads
- Adds CLI command for manual cleanup with dry-run support

## Changes Made
- **CLI/CleanupPendingUploadsCommand.php**: New command to auto-delete
stale `.oct` files
- **Web/Presenters/VKAPIPresenter.php**: Enhanced error response with
pending upload details
- **openvkctl**: Added cleanup command to CLI bootstrap
- **CLI/README.md**: Documentation with usage examples and cron setup

## Problem Solved
When users encounter "There are 3 pending already" error, they now
receive:
1. **Structured JSON response** with upload details (ID, filename, size,
age, timestamp)
2. **Automatic cleanup** removes uploads older than 24 hours
3. **Manual cleanup** available via CLI command with configurable age
threshold

## Usage
```bash
# Auto-cleanup (daily cron recommended)
php openvkctl cleanup-pending-uploads

# Custom age threshold
php openvkctl cleanup-pending-uploads --max-age=1

# Preview what would be deleted
php openvkctl cleanup-pending-uploads --dry-run
```

Fixes #1275
This commit is contained in:
Ilia Breitburg 2025-07-15 22:19:30 +02:00 committed by GitHub
parent c8a97f8b8d
commit 5ae8b62cd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 179 additions and 2 deletions

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace openvk\CLI;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class CleanupPendingUploadsCommand extends Command
{
protected static $defaultName = "cleanup-pending-uploads";
public function __construct()
{
parent::__construct();
}
protected function configure(): void
{
$this->setDescription("Cleanup pending photo uploads older than specified time")
->addOption(
"max-age",
"a",
InputOption::VALUE_OPTIONAL,
"Maximum age in hours (default: 24)",
24
)
->addOption(
"dry-run",
"d",
InputOption::VALUE_NONE,
"Show what would be deleted without actually deleting"
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$maxAge = (int) $input->getOption("max-age");
$dryRun = $input->getOption("dry-run");
$photoFolder = __DIR__ . "/../tmp/api-storage/photos";
if (!is_dir($photoFolder)) {
$output->writeln("<error>Photo upload directory not found: {$photoFolder}</error>");
return Command::FAILURE;
}
$output->writeln("<info>Scanning for pending uploads older than {$maxAge} hours...</info>");
$cutoffTime = time() - ($maxAge * 3600);
$deletedCount = 0;
$totalSize = 0;
$files = glob($photoFolder . "/*_*.oct");
foreach ($files as $file) {
$fileTime = filemtime($file);
if ($fileTime < $cutoffTime) {
$fileSize = filesize($file);
$totalSize += $fileSize;
if ($dryRun) {
$age = round((time() - $fileTime) / 3600, 1);
$output->writeln("<comment>Would delete: " . basename($file) . " (age: {$age}h, size: " . $this->formatBytes($fileSize) . ")</comment>");
} else {
if (unlink($file)) {
$deletedCount++;
$output->writeln("<info>Deleted: " . basename($file) . "</info>");
} else {
$output->writeln("<error>Failed to delete: " . basename($file) . "</error>");
}
}
}
}
if ($dryRun) {
$output->writeln("<info>Dry run completed. Would delete {$deletedCount} files (" . $this->formatBytes($totalSize) . ")</info>");
} else {
$output->writeln("<info>Cleanup completed. Deleted {$deletedCount} files (" . $this->formatBytes($totalSize) . ")</info>");
}
return Command::SUCCESS;
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . ' ' . $units[$pow];
}
}

40
CLI/README.md Normal file
View file

@ -0,0 +1,40 @@
# OpenVK CLI Commands
This directory contains command-line utilities for OpenVK management.
## Available Commands
### cleanup-pending-uploads
Automatically removes pending photo uploads older than the specified time.
**Usage:**
```bash
# Clean up uploads older than 24 hours (default)
php openvkctl cleanup-pending-uploads
# Clean up uploads older than 1 hour
php openvkctl cleanup-pending-uploads --max-age=1
# Dry run to see what would be deleted
php openvkctl cleanup-pending-uploads --dry-run
```
**Options:**
- `--max-age`, `-a`: Maximum age in hours (default: 24)
- `--dry-run`, `-d`: Show what would be deleted without actually deleting
**Cron Setup:**
To automatically clean up pending uploads daily, add to your crontab:
```bash
# Clean up pending uploads daily at 2 AM
0 2 * * * cd /path/to/openvk && php openvkctl cleanup-pending-uploads
```
### build-images
Rebuilds photo thumbnails and image sizes.
### fetch-toncoin-transactions
Fetches Toncoin transactions for payment processing.
### upgrade
Performs database upgrades and migrations.

View file

@ -126,8 +126,14 @@ final class VKAPIPresenter extends OpenVKPresenter
$maxFiles = OPENVK_ROOT_CONF["openvk"]["preferences"]["uploads"]["api"]["maxFilesPerDomain"];
$usrFiles = sizeof(glob("$folder/$data[USER]_*.oct"));
if ($usrFiles >= $maxFiles) {
$pendingInfo = $this->getPendingUploadInfo($folder, $data["USER"]);
header("HTTP/1.1 507 Insufficient Storage");
exit("There are $maxFiles pending already. Please save them before uploading more :3");
header("Content-Type: application/json");
exit(json_encode([
"error" => "insufficient_storage",
"error_description" => "There are $maxFiles pending already. Please save them before uploading more :3",
"pending_uploads" => $pendingInfo,
]));
}
# Not multifile
@ -166,8 +172,14 @@ final class VKAPIPresenter extends OpenVKPresenter
unlink($f);
}
$pendingInfo = $this->getPendingUploadInfo($folder, $data["USER"]);
header("HTTP/1.1 507 Insufficient Storage");
exit("There are $maxFiles pending already. Please save them before uploading more :3");
header("Content-Type: application/json");
exit(json_encode([
"error" => "insufficient_storage",
"error_description" => "There are $maxFiles pending already. Please save them before uploading more :3",
"pending_uploads" => $pendingInfo,
]));
}
$files[++$usrFiles] = move_uploaded_file($file["tmp_name"], "$folder/$data[USER]_$usrFiles.oct");
@ -194,6 +206,30 @@ final class VKAPIPresenter extends OpenVKPresenter
]));
}
private function getPendingUploadInfo(string $folder, string $userId): array
{
$pendingFiles = glob("$folder/$userId" . "_*.oct");
$pendingInfo = [];
foreach ($pendingFiles as $file) {
$filename = basename($file);
$uploadId = str_replace([$userId . "_", ".oct"], "", $filename);
$fileTime = filemtime($file);
$fileSize = filesize($file);
$ageHours = round((time() - $fileTime) / 3600, 1);
$pendingInfo[] = [
"upload_id" => $uploadId,
"filename" => $filename,
"size" => $fileSize,
"age_hours" => $ageHours,
"uploaded_at" => date("Y-m-d H:i:s", $fileTime),
];
}
return $pendingInfo;
}
public function renderRoute(string $object, string $method): void
{
$callback = $this->queryParam("callback");

View file

@ -13,5 +13,6 @@ $application = new Application();
$application->add(new CLI\UpgradeCommand());
$application->add(new CLI\RebuildImagesCommand());
$application->add(new CLI\FetchToncoinTransactions());
$application->add(new CLI\CleanupPendingUploadsCommand());
$application->run();