first commit
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Package;
|
||||
use App\Models\Version;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AdminPackageService
|
||||
{
|
||||
public function createPackage(array $data): Package
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$package = Package::query()->create([
|
||||
'type' => $data['type'],
|
||||
'slug' => $data['slug'],
|
||||
'name' => $data['name'],
|
||||
'summary' => $data['summary'] ?? '',
|
||||
'description' => $data['description'] ?? '',
|
||||
'author' => $data['author'] ?? '',
|
||||
'homepage' => $data['homepage'] ?? '',
|
||||
'icon_url' => $data['icon_url'] ?? '',
|
||||
'license' => $data['license'] ?? '',
|
||||
'status' => $data['status'] ?? 'draft',
|
||||
'is_featured' => (bool) ($data['is_featured'] ?? false),
|
||||
'sort_order' => (int) ($data['sort_order'] ?? 0),
|
||||
'download_count' => 0,
|
||||
'latest_version' => '',
|
||||
]);
|
||||
|
||||
$this->syncCategories($package, Arr::get($data, 'categories', []));
|
||||
|
||||
return $package->load(['categories', 'latestStableVersion']);
|
||||
});
|
||||
}
|
||||
|
||||
public function updatePackage(Package $package, array $data): Package
|
||||
{
|
||||
return DB::transaction(function () use ($package, $data) {
|
||||
$package->fill([
|
||||
'name' => $data['name'] ?? $package->name,
|
||||
'summary' => $data['summary'] ?? $package->summary,
|
||||
'description' => $data['description'] ?? $package->description,
|
||||
'author' => $data['author'] ?? $package->author,
|
||||
'homepage' => $data['homepage'] ?? $package->homepage,
|
||||
'icon_url' => $data['icon_url'] ?? $package->icon_url,
|
||||
'license' => $data['license'] ?? $package->license,
|
||||
'status' => $data['status'] ?? $package->status,
|
||||
'is_featured' => array_key_exists('is_featured', $data) ? (bool) $data['is_featured'] : $package->is_featured,
|
||||
'sort_order' => array_key_exists('sort_order', $data) ? (int) $data['sort_order'] : $package->sort_order,
|
||||
]);
|
||||
$package->save();
|
||||
|
||||
if (array_key_exists('categories', $data)) {
|
||||
$this->syncCategories($package, Arr::get($data, 'categories', []));
|
||||
}
|
||||
|
||||
$this->refreshLatestVersion($package->fresh());
|
||||
|
||||
return $package->fresh(['categories', 'latestStableVersion']);
|
||||
});
|
||||
}
|
||||
|
||||
public function updateStatus(Package $package, string $status): Package
|
||||
{
|
||||
$package->status = $status;
|
||||
$package->save();
|
||||
|
||||
return $package->fresh(['categories', 'latestStableVersion']);
|
||||
}
|
||||
|
||||
public function createVersion(Package $package, array $data): Version
|
||||
{
|
||||
return DB::transaction(function () use ($package, $data) {
|
||||
$markAsLatest = array_key_exists('mark_as_latest', $data) ? (bool) $data['mark_as_latest'] : true;
|
||||
|
||||
if ($markAsLatest) {
|
||||
$package->versions()->update(['is_latest' => false]);
|
||||
}
|
||||
|
||||
$version = $package->versions()->create([
|
||||
'version' => $data['version'],
|
||||
'changelog' => $data['changelog'] ?? '',
|
||||
'typecho_min' => $data['typecho_min'] ?? '1.2.0',
|
||||
'typecho_max' => $data['typecho_max'] ?? '',
|
||||
'php_min' => $data['php_min'] ?? '7.4',
|
||||
'php_max' => $data['php_max'] ?? '',
|
||||
'php_extensions' => json_encode($data['php_extensions'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||
'package_url' => $data['package_url'],
|
||||
'package_size' => (int) ($data['package_size'] ?? 0),
|
||||
'sha256' => strtolower($data['sha256']),
|
||||
'is_stable' => array_key_exists('is_stable', $data) ? (bool) $data['is_stable'] : true,
|
||||
'is_latest' => $markAsLatest,
|
||||
'download_count' => 0,
|
||||
'published_at' => $data['published_at'] ?? now(),
|
||||
]);
|
||||
|
||||
$this->refreshLatestVersion($package->fresh());
|
||||
|
||||
return $version->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteVersion(Version $version): void
|
||||
{
|
||||
DB::transaction(function () use ($version) {
|
||||
$package = $version->package;
|
||||
$version->delete();
|
||||
if ($package) {
|
||||
$this->refreshLatestVersion($package->fresh());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function refreshLatestVersion(Package $package): void
|
||||
{
|
||||
$latestStable = $package->versions()
|
||||
->where('is_stable', true)
|
||||
->orderByDesc('is_latest')
|
||||
->orderByDesc('published_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($latestStable) {
|
||||
$package->latest_version = $latestStable->version;
|
||||
$package->save();
|
||||
return;
|
||||
}
|
||||
|
||||
$latestAny = $package->versions()
|
||||
->orderByDesc('is_latest')
|
||||
->orderByDesc('published_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$package->latest_version = $latestAny?->version ?: '';
|
||||
$package->save();
|
||||
}
|
||||
|
||||
private function syncCategories(Package $package, array $categorySlugs): void
|
||||
{
|
||||
$categorySlugs = array_values(array_unique(array_filter(array_map('strval', $categorySlugs))));
|
||||
|
||||
if (empty($categorySlugs)) {
|
||||
$package->categories()->sync([]);
|
||||
return;
|
||||
}
|
||||
|
||||
$categoryIds = Category::query()
|
||||
->where('type', $package->type)
|
||||
->whereIn('slug', $categorySlugs)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
$package->categories()->sync($categoryIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\Version;
|
||||
|
||||
class RepoFormatter
|
||||
{
|
||||
public static function packageListItem(Package $package, bool $allowPrerelease = false): array
|
||||
{
|
||||
$latest = $allowPrerelease
|
||||
? ($package->latestVersion ?: $package->latestStableVersion)
|
||||
: $package->latestStableVersion;
|
||||
|
||||
return [
|
||||
'type' => $package->type,
|
||||
'slug' => $package->slug,
|
||||
'name' => $package->name,
|
||||
'summary' => $package->summary,
|
||||
'author' => $package->author,
|
||||
'icon_url' => $package->icon_url,
|
||||
'latest_version' => $latest?->version ?: $package->latest_version,
|
||||
'download_count' => (int) $package->download_count,
|
||||
'is_featured' => (bool) $package->is_featured,
|
||||
'categories' => $package->categories->pluck('slug')->values()->all(),
|
||||
'compatibility' => $latest ? self::compatibility($latest) : [
|
||||
'typecho_min' => '',
|
||||
'typecho_max' => '',
|
||||
'php_min' => '',
|
||||
'php_max' => '',
|
||||
],
|
||||
'updated_at' => optional($package->updated_at)->toAtomString(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function packageDetail(Package $package, bool $includeDownloadMeta = true): array
|
||||
{
|
||||
return [
|
||||
'type' => $package->type,
|
||||
'slug' => $package->slug,
|
||||
'name' => $package->name,
|
||||
'summary' => $package->summary,
|
||||
'description' => $package->description,
|
||||
'author' => $package->author,
|
||||
'homepage' => $package->homepage,
|
||||
'icon_url' => $package->icon_url,
|
||||
'license' => $package->license,
|
||||
'is_featured' => (bool) $package->is_featured,
|
||||
'download_count' => (int) $package->download_count,
|
||||
'categories' => $package->categories->pluck('slug')->values()->all(),
|
||||
'screenshots' => $package->screenshots->map(function ($item) {
|
||||
return [
|
||||
'url' => $item->image_url,
|
||||
'caption' => $item->caption,
|
||||
];
|
||||
})->values()->all(),
|
||||
'versions' => $package->versions->map(function ($version) use ($includeDownloadMeta) {
|
||||
return self::versionDetail($version, $includeDownloadMeta);
|
||||
})->values()->all(),
|
||||
'created_at' => optional($package->created_at)->toAtomString(),
|
||||
'updated_at' => optional($package->updated_at)->toAtomString(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function versionDetail(Version $version, bool $includeDownloadMeta = true): array
|
||||
{
|
||||
$payload = [
|
||||
'version' => $version->version,
|
||||
'is_stable' => (bool) $version->is_stable,
|
||||
'is_latest' => (bool) $version->is_latest,
|
||||
'changelog' => $version->changelog,
|
||||
'compatibility' => self::compatibility($version),
|
||||
'published_at' => optional($version->published_at)->toAtomString(),
|
||||
];
|
||||
|
||||
if ($includeDownloadMeta) {
|
||||
$payload['package'] = [
|
||||
'size' => (int) $version->package_size,
|
||||
'sha256' => $version->sha256,
|
||||
'download_url' => $version->package_url,
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public static function compatibility(Version $version): array
|
||||
{
|
||||
return [
|
||||
'typecho_min' => $version->typecho_min,
|
||||
'typecho_max' => $version->typecho_max,
|
||||
'php_min' => $version->php_min,
|
||||
'php_max' => $version->php_max,
|
||||
'php_extensions' => $version->php_extensions_array,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Package;
|
||||
use App\Models\Version;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
class VersionPublishService
|
||||
{
|
||||
public function __construct(private readonly AdminPackageService $packageService)
|
||||
{
|
||||
}
|
||||
|
||||
public function publishFromZip(Package $package, UploadedFile $file, array $options = []): Version
|
||||
{
|
||||
$tmpDir = storage_path('app/tmp/store-publish/' . Str::uuid()->toString());
|
||||
$extractDir = $tmpDir . '/extract';
|
||||
$incomingPath = $tmpDir . '/incoming.zip';
|
||||
|
||||
File::ensureDirectoryExists($tmpDir);
|
||||
File::ensureDirectoryExists($extractDir);
|
||||
|
||||
try {
|
||||
File::copy($file->getRealPath(), $incomingPath);
|
||||
|
||||
$sha256 = hash_file('sha256', $incomingPath);
|
||||
$size = filesize($incomingPath) ?: 0;
|
||||
|
||||
$manifest = $this->extractAndValidate($incomingPath, $extractDir, $package, $sha256);
|
||||
$versionNumber = (string) $manifest['version'];
|
||||
|
||||
if ($package->versions()->where('version', $versionNumber)->exists()) {
|
||||
throw new RuntimeException('version already exists');
|
||||
}
|
||||
|
||||
$relativePath = 'packages/' . $package->type . '/' . $package->slug . '/' . $versionNumber . '.zip';
|
||||
$storagePath = storage_path('app/' . $relativePath);
|
||||
File::ensureDirectoryExists(dirname($storagePath));
|
||||
File::copy($incomingPath, $storagePath);
|
||||
|
||||
$payload = [
|
||||
'version' => $versionNumber,
|
||||
'changelog' => $options['changelog'] ?? ($manifest['changelog'] ?? ''),
|
||||
'typecho_min' => data_get($manifest, 'compatibility.typecho_min', '1.2.0'),
|
||||
'typecho_max' => data_get($manifest, 'compatibility.typecho_max', ''),
|
||||
'php_min' => data_get($manifest, 'compatibility.php_min', '7.4'),
|
||||
'php_max' => data_get($manifest, 'compatibility.php_max', ''),
|
||||
'php_extensions' => data_get($manifest, 'compatibility.php_extensions', []),
|
||||
'package_url' => $this->buildPackageUrl($package, $versionNumber),
|
||||
'package_size' => $size,
|
||||
'sha256' => $sha256,
|
||||
'is_stable' => array_key_exists('is_stable', $options)
|
||||
? (bool) $options['is_stable']
|
||||
: !Str::contains($versionNumber, ['-beta', '-alpha', '-rc']),
|
||||
'published_at' => $options['published_at'] ?? now(),
|
||||
'mark_as_latest' => array_key_exists('mark_as_latest', $options)
|
||||
? (bool) $options['mark_as_latest']
|
||||
: true,
|
||||
];
|
||||
|
||||
return DB::transaction(function () use ($package, $payload) {
|
||||
return $this->packageService->createVersion($package, $payload);
|
||||
});
|
||||
} finally {
|
||||
if (is_dir($tmpDir)) {
|
||||
File::deleteDirectory($tmpDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function extractAndValidate(string $zipPath, string $extractDir, Package $package, string $sha256): array
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$opened = $zip->open($zipPath);
|
||||
if ($opened !== true) {
|
||||
throw new RuntimeException('failed to open zip package');
|
||||
}
|
||||
|
||||
$topLevel = [];
|
||||
$manifestRaw = null;
|
||||
$rootDirExists = false;
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
$name = $stat['name'] ?? '';
|
||||
$this->assertSafeEntry($name);
|
||||
|
||||
$trimmed = trim($name, '/');
|
||||
if ($trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$first = explode('/', $trimmed)[0];
|
||||
$topLevel[$first] = true;
|
||||
|
||||
if ($trimmed === 'manifest.json') {
|
||||
$manifestRaw = $zip->getFromIndex($i);
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifestRaw === null) {
|
||||
$zip->close();
|
||||
throw new RuntimeException('manifest.json not found at zip root');
|
||||
}
|
||||
|
||||
$manifest = json_decode($manifestRaw, true);
|
||||
if (!is_array($manifest)) {
|
||||
$zip->close();
|
||||
throw new RuntimeException('invalid manifest.json');
|
||||
}
|
||||
|
||||
$this->validateManifest($manifest, $package, $sha256);
|
||||
|
||||
$allowedTopLevel = ['manifest.json', $package->slug];
|
||||
$topLevelNames = array_keys($topLevel);
|
||||
sort($topLevelNames);
|
||||
sort($allowedTopLevel);
|
||||
if ($topLevelNames !== $allowedTopLevel) {
|
||||
$zip->close();
|
||||
throw new RuntimeException('zip root structure must contain only manifest.json and package root dir');
|
||||
}
|
||||
|
||||
if (!$zip->extractTo($extractDir)) {
|
||||
$zip->close();
|
||||
throw new RuntimeException('failed to extract zip package');
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$rootDir = $extractDir . DIRECTORY_SEPARATOR . $package->slug;
|
||||
$rootDirExists = is_dir($rootDir);
|
||||
if (!$rootDirExists) {
|
||||
throw new RuntimeException('package root dir missing after extraction');
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
private function validateManifest(array $manifest, Package $package, string $sha256): void
|
||||
{
|
||||
if (($manifest['schema_version'] ?? '') !== '1.0') {
|
||||
throw new RuntimeException('unsupported manifest schema_version');
|
||||
}
|
||||
|
||||
if (($manifest['type'] ?? '') !== $package->type) {
|
||||
throw new RuntimeException('manifest type does not match package type');
|
||||
}
|
||||
|
||||
if (($manifest['slug'] ?? '') !== $package->slug) {
|
||||
throw new RuntimeException('manifest slug does not match package slug');
|
||||
}
|
||||
|
||||
if (!preg_match('/^[A-Za-z][A-Za-z0-9]*$/', (string) ($manifest['slug'] ?? ''))) {
|
||||
throw new RuntimeException('manifest slug format invalid');
|
||||
}
|
||||
|
||||
if (empty($manifest['name']) || empty($manifest['author']) || empty($manifest['version'])) {
|
||||
throw new RuntimeException('manifest missing required fields');
|
||||
}
|
||||
|
||||
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/', (string) $manifest['version'])) {
|
||||
throw new RuntimeException('manifest version is not valid semver');
|
||||
}
|
||||
|
||||
if (($manifest['install']['root_dir'] ?? '') !== $package->slug) {
|
||||
throw new RuntimeException('manifest install.root_dir invalid');
|
||||
}
|
||||
|
||||
$expectedTargetDir = $package->type === 'plugin'
|
||||
? 'usr/plugins/' . $package->slug
|
||||
: 'usr/themes/' . $package->slug;
|
||||
if (($manifest['install']['target_dir'] ?? '') !== $expectedTargetDir) {
|
||||
throw new RuntimeException('manifest install.target_dir invalid');
|
||||
}
|
||||
|
||||
if (empty($manifest['compatibility']['typecho_min']) || empty($manifest['compatibility']['php_min'])) {
|
||||
throw new RuntimeException('manifest compatibility fields missing');
|
||||
}
|
||||
|
||||
$manifestSha = strtolower((string) data_get($manifest, 'package.sha256', ''));
|
||||
if ($manifestSha === '' || $manifestSha !== strtolower($sha256)) {
|
||||
throw new RuntimeException('manifest package sha256 mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSafeEntry(string $name): void
|
||||
{
|
||||
if ($name === '' || str_contains($name, '../') || str_contains($name, '..\\')) {
|
||||
throw new RuntimeException('zip contains unsafe path');
|
||||
}
|
||||
|
||||
if (preg_match('#^[A-Za-z]:[\\/]#', $name) || str_starts_with($name, '/') || str_starts_with($name, '\\')) {
|
||||
throw new RuntimeException('zip contains absolute path');
|
||||
}
|
||||
|
||||
$blockedExt = ['phar', 'sh', 'bat', 'exe', 'cmd', 'com', 'ps1'];
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if ($ext !== '' && in_array($ext, $blockedExt, true)) {
|
||||
throw new RuntimeException('zip contains blocked file type');
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPackageUrl(Package $package, string $version): string
|
||||
{
|
||||
$path = '/api/v1/repo/download/' . $package->type . '/' . $package->slug . '/' . $version . '?redirect=1';
|
||||
$base = rtrim(config('app.url', ''), '/');
|
||||
|
||||
return $base === '' ? $path : $base . $path;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user