v-wordpress-plugin-updater/v-update-api/app/Models/PluginModel.php
Nikolai X. Shadeauxs 8fc3bc20ad
Some checks failed
CI & Security / CI Scan (push) Failing after 9s
CI & Security / CodeQL (JavaScript) (push) Failing after 6s
CI & Security / Semgrep (PHP) (push) Failing after 8s
modified: .github/copilot-instructions.md
modified:   CHANGELOG.md
	modified:   README.md
2026-04-06 14:39:21 -04:00

424 lines
15 KiB
PHP

<?php
// phpcs:ignoreFile PSR1.Files.SideEffects.FoundWithSymbols
/**
* Project: UpdateAPI
* Author: Vontainment <services@vontainment.com>
* License: https://opensource.org/licenses/MIT MIT License
* Link: https://vontainment.com
* Version: 4.5.0
*
* File: PluginModel.php
* Description: WordPress Update API
*/
namespace App\Models;
use App\Core\DatabaseManager;
use App\Helpers\ValidationHelper;
class PluginModel
{
private const DIR = PLUGINS_DIR;
/**
* Return plugin version by slug, or null when not found.
*/
public static function getVersionBySlug(string $slug): ?string
{
$version = DatabaseManager::connection()->fetchOne('SELECT version FROM plugins WHERE slug = ?', [$slug]);
if ($version === false || $version === null) {
return null;
}
return (string) $version;
}
/**
* Return array of plugin data.
*
* @return array<int, array{slug: string, version: string}>
*/
public static function getPlugins(): array
{
$rows = DatabaseManager::connection()->fetchAllAssociative('SELECT slug, version FROM plugins ORDER BY slug');
$plugins = [];
foreach ($rows as $row) {
$plugins[] = [
'slug' => $row['slug'],
'version' => $row['version'],
];
}
return $plugins;
}
/**
* Delete a plugin file.
*/
public static function deletePlugin(string $pluginName): bool
{
$basename = basename($pluginName);
$parsed = ValidationHelper::parsePackageFilename($basename);
if ($parsed === null) {
error_log('Plugin delete rejected: invalid filename format "' . $basename . '".');
return false;
}
$slug = $parsed['slug'];
$pluginPath = self::DIR . '/' . $basename;
if (!file_exists($pluginPath)) {
error_log('Plugin delete skipped: file missing "' . $pluginPath . '".');
return false;
}
$realPath = realpath($pluginPath);
$realDir = realpath(self::DIR);
if ($realPath === false || $realDir === false || dirname($realPath) !== $realDir) {
error_log('Plugin delete rejected: path outside plugin directory "' . $pluginPath . '".');
return false;
}
if (!unlink($pluginPath)) {
error_log('Plugin delete failed: unable to unlink "' . $pluginPath . '" (permission/race condition).');
return false;
}
DatabaseManager::connection()->executeStatement('DELETE FROM plugins WHERE slug = ?', [$slug]);
return true;
}
/**
* Upload plugin files.
*
* @param array<string, array<int, mixed>> $fileArray $_FILES['plugin_file'] structure
* @param bool $isAjax Whether the request was via AJAX
*
* @return string[] Array of status messages
*/
public static function uploadFiles(array $fileArray, bool $isAjax = false): array
{
$messages = [];
$allowedExtensions = ['zip'];
$normalized = self::normalizeUploadPayload($fileArray);
foreach ($normalized['errors'] as $errorMessage) {
$messages[] = $errorMessage;
}
foreach ($normalized['entries'] as $entry) {
$originalFilename = basename($entry['name']);
$fileName = ValidationHelper::validateFilename($entry['name']) ?? '';
$fileTmp = $entry['tmp_name'];
$fileError = $entry['error'];
$fileExtension = $fileName ? strtolower(pathinfo($fileName, PATHINFO_EXTENSION)) : '';
$parsedFilename = $fileName ? ValidationHelper::parsePackageFilename($fileName) : null;
$pluginSlug = $parsedFilename['slug'] ?? '';
$current = DatabaseManager::connection()->fetchOne('SELECT version FROM plugins WHERE slug = ?', [$pluginSlug]);
$maxUploadSize = min(
self::parseIniSize(ini_get('upload_max_filesize')),
self::parseIniSize(ini_get('post_max_size'))
);
if ($entry['size'] > $maxUploadSize) {
$messages[] = 'Error uploading: ' . htmlspecialchars($originalFilename, ENT_QUOTES, 'UTF-8') .
'. File size exceeds the maximum allowed size of ' . ($maxUploadSize / (1024 * 1024)) . ' MB.';
continue;
}
if ($fileError !== UPLOAD_ERR_OK || !in_array($fileExtension, $allowedExtensions)) {
$messages[] = 'Error uploading: ' . htmlspecialchars($originalFilename, ENT_QUOTES, 'UTF-8') .
'. Only .zip files are allowed, and filenames must follow the format: plugin-name_1.0.zip';
continue;
}
if ($fileName === '' || $parsedFilename === null) {
$messages[] = 'Error uploading: ' . htmlspecialchars($originalFilename, ENT_QUOTES, 'UTF-8') .
'. Only .zip files are allowed, and filenames must follow the format: plugin-name_1.0.zip';
continue;
}
$slug = $parsedFilename['slug'];
$version = $parsedFilename['version'];
if ($current && version_compare($version, $current, '<=')) {
$messages[] = 'Error uploading: ' . htmlspecialchars($originalFilename, ENT_QUOTES, 'UTF-8') .
'. Uploaded version (' . $version . ') is not newer than current version (' . $current . ').';
continue;
}
if (class_exists('\\ZipArchive')) {
$za = new \ZipArchive();
if ($za->open($fileTmp) !== true) {
$messages[] = 'Error uploading: ' . htmlspecialchars($originalFilename, ENT_QUOTES, 'UTF-8') .
'. File is not a valid ZIP archive.';
continue;
}
$za->close();
} else {
$messages[] = 'Error uploading: ' . htmlspecialchars($originalFilename, ENT_QUOTES, 'UTF-8') .
'. ZIP validation unavailable: the PHP zip extension is not installed.';
continue;
}
$tempPath = self::DIR . '/' . uniqid('tmp_upload_', true) . '.zip';
$finalPath = self::DIR . '/' . $fileName;
if (!move_uploaded_file($fileTmp, $tempPath)) {
$messages[] = 'Error uploading: ' . htmlspecialchars($originalFilename, ENT_QUOTES, 'UTF-8');
continue;
}
$result = self::persistUploadedArtifact('plugins', $slug, $version, $tempPath, $finalPath);
if (!$result['success']) {
$messages[] = 'Error uploading: ' . htmlspecialchars($originalFilename, ENT_QUOTES, 'UTF-8');
error_log($result['error']);
continue;
}
$messages[] = htmlspecialchars($originalFilename, ENT_QUOTES, 'UTF-8') . ' uploaded successfully.';
}
return $messages;
}
/**
* Persist upload with transactional DB update and filesystem compensation.
*
* @return array{success: bool, error: string}
*/
private static function persistUploadedArtifact(
string $table,
string $slug,
string $version,
string $tempPath,
string $finalPath
): array {
$deletedBackups = [];
$movedToFinal = false;
try {
DatabaseManager::connection()->beginTransaction();
if (!rename($tempPath, $finalPath)) {
throw new \RuntimeException('Failed to move staged upload into final path.');
}
$movedToFinal = true;
$existing = glob(self::DIR . '/' . $slug . '_*');
if ($existing === false) {
throw new \RuntimeException('Failed to list existing plugin artifacts.');
}
foreach ($existing as $artifact) {
if (!is_file($artifact) || $artifact === $finalPath) {
continue;
}
$backupPath = $artifact . '.bak_upload_' . uniqid('', true);
if (!rename($artifact, $backupPath)) {
throw new \RuntimeException('Failed to stage old plugin artifact for replacement.');
}
$deletedBackups[] = ['original' => $artifact, 'backup' => $backupPath];
}
DatabaseManager::connection()->executeStatement(
"INSERT INTO $table (slug, version) VALUES (?, ?) "
. 'ON CONFLICT(slug) DO UPDATE SET version = excluded.version',
[$slug, $version]
);
DatabaseManager::connection()->commit();
foreach ($deletedBackups as $backup) {
@unlink($backup['backup']);
}
return ['success' => true, 'error' => ''];
} catch (\Throwable $exception) {
if (DatabaseManager::connection()->isTransactionActive()) {
DatabaseManager::connection()->rollBack();
}
foreach ($deletedBackups as $backup) {
if (file_exists($backup['backup'])) {
@rename($backup['backup'], $backup['original']);
}
}
if ($movedToFinal && file_exists($finalPath)) {
@unlink($finalPath);
}
if (file_exists($tempPath)) {
@unlink($tempPath);
}
return [
'success' => false,
'error' => 'Plugin upload transaction failed for slug "' . $slug . '": ' . $exception->getMessage(),
];
}
}
/**
* Normalize upload payload into a predictable list structure.
*
* @param array<string, mixed> $fileArray
* @return array{entries: array<int, array{name: string, tmp_name: string, error: int, size: int}>, errors: string[]}
*/
private static function normalizeUploadPayload(array $fileArray): array
{
$requiredKeys = ['name', 'tmp_name', 'error', 'size'];
foreach ($requiredKeys as $requiredKey) {
if (!array_key_exists($requiredKey, $fileArray)) {
return [
'entries' => [],
'errors' => ['Error uploading: malformed upload payload (missing "' . $requiredKey . '").'],
];
}
}
$isMulti = is_array($fileArray['name'])
|| is_array($fileArray['tmp_name'])
|| is_array($fileArray['error'])
|| is_array($fileArray['size']);
$entries = [];
$errors = [];
if (!$isMulti) {
$single = self::buildEntry(
$fileArray['name'],
$fileArray['tmp_name'],
$fileArray['error'],
$fileArray['size'],
0
);
if ($single['entry'] !== null) {
$entries[] = $single['entry'];
}
if ($single['error'] !== null) {
$errors[] = $single['error'];
}
return ['entries' => $entries, 'errors' => $errors];
}
if (!is_array($fileArray['name']) || !is_array($fileArray['tmp_name']) || !is_array($fileArray['error']) || !is_array($fileArray['size'])) {
return [
'entries' => [],
'errors' => ['Error uploading: malformed upload payload (mixed single/multi-file format).'],
];
}
$totalFiles = count($fileArray['name']);
for ($i = 0; $i < $totalFiles; $i++) {
$single = self::buildEntry(
$fileArray['name'][$i] ?? null,
$fileArray['tmp_name'][$i] ?? null,
$fileArray['error'][$i] ?? null,
$fileArray['size'][$i] ?? null,
$i
);
if ($single['entry'] !== null) {
$entries[] = $single['entry'];
}
if ($single['error'] !== null) {
$errors[] = $single['error'];
}
}
return ['entries' => $entries, 'errors' => $errors];
}
/**
* Build one normalized upload entry, or a descriptive validation error.
*
* @return array{entry: array{name: string, tmp_name: string, error: int, size: int}|null, error: string|null}
*/
private static function buildEntry(mixed $name, mixed $tmpName, mixed $error, mixed $size, int $index): array
{
if (!is_string($name) || !is_string($tmpName)) {
return [
'entry' => null,
'error' => 'Error uploading: malformed upload entry at index ' . $index . ' (name/tmp_name must be strings).',
];
}
$parsedError = filter_var($error, FILTER_VALIDATE_INT);
$parsedSize = filter_var($size, FILTER_VALIDATE_INT);
if ($parsedError === false || $parsedSize === false) {
return [
'entry' => null,
'error' => 'Error uploading: malformed upload entry at index ' . $index . ' (error/size must be integers).',
];
}
return [
'entry' => [
'name' => $name,
'tmp_name' => $tmpName,
'error' => $parsedError,
'size' => max(0, $parsedSize),
],
'error' => null,
];
}
/**
* Parse a size string from php.ini into bytes.
*/
private static function parseIniSize(string $size): int
{
$unit = strtoupper(substr($size, -1));
$value = (int)$size;
switch ($unit) {
case 'K':
return $value * 1024;
case 'M':
return $value * 1024 * 1024;
case 'G':
return $value * 1024 * 1024 * 1024;
default:
return $value;
}
}
/**
* Sync ZIP files from directory into the plugins table.
*/
public static function syncFromDirectory(string $dir): void
{
if (!is_dir($dir) || !is_readable($dir)) {
error_log(sprintf('PluginModel::syncFromDirectory cannot read directory "%s".', $dir));
return;
}
$files = glob($dir . '/*.zip');
if ($files === false) {
error_log(sprintf('PluginModel::syncFromDirectory glob() failed for directory "%s".', $dir));
return;
}
$found = [];
foreach ($files as $file) {
$name = basename($file);
if (preg_match('/^(.+)_([\d\.]+)\.zip$/', $name, $matches)) {
$slug = $matches[1];
$version = $matches[2];
$found[$slug] = true;
DatabaseManager::connection()->executeStatement(
'INSERT INTO plugins (slug, version) VALUES (?, ?) ' .
'ON CONFLICT(slug) DO UPDATE SET version = excluded.version',
[$slug, $version]
);
}
}
$rows = DatabaseManager::connection()->fetchAllAssociative('SELECT slug FROM plugins');
foreach ($rows as $row) {
if (!isset($found[$row['slug']])) {
DatabaseManager::connection()->executeStatement('DELETE FROM plugins WHERE slug = ?', [$row['slug']]);
}
}
}
}