$upload * @return array{ok:bool, path?:string, code?:string} */ public function storeUploadedFile(array $upload): array { $tmpName = (string) ($upload['tmp_name'] ?? ''); $name = (string) ($upload['name'] ?? ''); $size = (int) ($upload['size'] ?? 0); $error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE); if ($error === UPLOAD_ERR_NO_FILE || $tmpName === '') { return ['ok' => false, 'code' => 'upload_missing']; } if ($error !== UPLOAD_ERR_OK) { return ['ok' => false, 'code' => 'invalid_file_type']; } if ($size <= 0) { return ['ok' => false, 'code' => 'empty_csv']; } if ($size > self::MAX_UPLOAD_BYTES) { return ['ok' => false, 'code' => 'upload_too_large']; } if (!$this->isAllowedExtension($name)) { return ['ok' => false, 'code' => 'invalid_file_type']; } $dir = $this->tmpDir(); if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { return ['ok' => false, 'code' => 'unexpected_error']; } // Lazy cleanup on each upload — removes abandoned files without needing a separate cron job. $this->cleanupExpired(); try { $target = $dir . '/import_' . bin2hex(random_bytes(16)) . '.csv'; } catch (\Throwable $exception) { $target = $dir . '/import_' . str_replace('.', '', uniqid('', true)) . '.csv'; } // Real uploads use move_uploaded_file (validates PHP upload context). // Fallback rename/copy path handles test uploads without a real HTTP context. $moved = false; if (is_uploaded_file($tmpName)) { $moved = @move_uploaded_file($tmpName, $target); } elseif (is_file($tmpName) && is_readable($tmpName)) { $moved = @rename($tmpName, $target); if (!$moved) { $moved = @copy($tmpName, $target); } } if (!$moved) { return ['ok' => false, 'code' => 'invalid_file_type']; } @chmod($target, 0640); return ['ok' => true, 'path' => $target]; } public function delete(string $path): void { $path = trim($path); if ($path === '') { return; } if (!$this->isInTmpDir($path)) { return; } if (is_file($path)) { @unlink($path); } } public function cleanupExpired(): void { $dir = $this->tmpDir(); if (!is_dir($dir)) { return; } $files = glob($dir . '/*'); if (!is_array($files)) { return; } $threshold = time() - self::MAX_FILE_AGE_SECONDS; foreach ($files as $file) { if (!is_file($file)) { continue; } $modifiedAt = @filemtime($file); if ($modifiedAt !== false && $modifiedAt < $threshold) { @unlink($file); } } } public function storageBase(): string { if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) { return rtrim((string) APP_STORAGE_PATH, '/'); } return rtrim(dirname(__DIR__, 3) . '/storage', '/'); } public function tmpDir(): string { return $this->storageBase() . self::TMP_SUBDIR; } private function isAllowedExtension(string $name): bool { $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); return in_array($extension, ['csv', 'txt'], true); } // Resolves both paths via realpath to prevent path traversal attacks (e.g. "../../../etc/passwd"). private function isInTmpDir(string $path): bool { $tmpDir = $this->tmpDir(); $realTmpDir = realpath($tmpDir); $realPath = realpath($path); if ($realTmpDir === false || $realPath === false) { return false; } return str_starts_with($realPath, $realTmpDir . DIRECTORY_SEPARATOR); } }