first commit

This commit is contained in:
浪子
2026-03-19 16:44:38 +08:00
commit ff2af385b9
100 changed files with 16826 additions and 0 deletions
+159
View File
@@ -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);
}
}
+98
View File
@@ -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,
];
}
}
+215
View File
@@ -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;
}
}