diff --git a/CLI/CleanupPendingUploadsCommand.php b/CLI/CleanupPendingUploadsCommand.php
new file mode 100644
index 00000000..8772c015
--- /dev/null
+++ b/CLI/CleanupPendingUploadsCommand.php
@@ -0,0 +1,100 @@
+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("Photo upload directory not found: {$photoFolder}");
+ return Command::FAILURE;
+ }
+
+ $output->writeln("Scanning for pending uploads older than {$maxAge} hours...");
+
+ $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("Would delete: " . basename($file) . " (age: {$age}h, size: " . $this->formatBytes($fileSize) . ")");
+ } else {
+ if (unlink($file)) {
+ $deletedCount++;
+ $output->writeln("Deleted: " . basename($file) . "");
+ } else {
+ $output->writeln("Failed to delete: " . basename($file) . "");
+ }
+ }
+ }
+ }
+
+ if ($dryRun) {
+ $output->writeln("Dry run completed. Would delete {$deletedCount} files (" . $this->formatBytes($totalSize) . ")");
+ } else {
+ $output->writeln("Cleanup completed. Deleted {$deletedCount} files (" . $this->formatBytes($totalSize) . ")");
+ }
+
+ 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];
+ }
+}
diff --git a/CLI/README.md b/CLI/README.md
new file mode 100644
index 00000000..1b66610d
--- /dev/null
+++ b/CLI/README.md
@@ -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.
\ No newline at end of file
diff --git a/Web/Presenters/VKAPIPresenter.php b/Web/Presenters/VKAPIPresenter.php
index 2ab0818a..008e98c9 100644
--- a/Web/Presenters/VKAPIPresenter.php
+++ b/Web/Presenters/VKAPIPresenter.php
@@ -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");
diff --git a/openvkctl b/openvkctl
index 13b02d45..dd1f08cc 100755
--- a/openvkctl
+++ b/openvkctl
@@ -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();