first commit
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreCategoryRequest;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Category::query()->orderByDesc('sort_order')->orderBy('id');
|
||||
|
||||
if ($type = $request->query('type')) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
if ($keyword = trim((string) $request->query('keyword', ''))) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('slug', 'like', '%' . $keyword . '%')
|
||||
->orWhere('description', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$items = $query->get()->map(fn (Category $category) => $this->format($category))->values()->all();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'ok',
|
||||
'data' => [
|
||||
'items' => $items,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreCategoryRequest $request): JsonResponse
|
||||
{
|
||||
$category = Category::query()->create([
|
||||
'type' => $request->validated('type'),
|
||||
'name' => $request->validated('name'),
|
||||
'slug' => $request->validated('slug'),
|
||||
'description' => $request->validated('description', ''),
|
||||
'sort_order' => (int) $request->validated('sort_order', 0),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'category created',
|
||||
'data' => $this->format($category),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(StoreCategoryRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$category = Category::query()->find($id);
|
||||
if (!$category) {
|
||||
return response()->json([
|
||||
'code' => 404,
|
||||
'message' => 'category not found',
|
||||
'data' => null,
|
||||
], 404);
|
||||
}
|
||||
|
||||
$category->fill([
|
||||
'type' => $request->validated('type'),
|
||||
'name' => $request->validated('name'),
|
||||
'slug' => $request->validated('slug'),
|
||||
'description' => $request->validated('description', ''),
|
||||
'sort_order' => (int) $request->validated('sort_order', 0),
|
||||
]);
|
||||
$category->save();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'category updated',
|
||||
'data' => $this->format($category),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$category = Category::query()->find($id);
|
||||
if (!$category) {
|
||||
return response()->json([
|
||||
'code' => 404,
|
||||
'message' => 'category not found',
|
||||
'data' => null,
|
||||
], 404);
|
||||
}
|
||||
|
||||
$category->packages()->detach();
|
||||
$category->delete();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'category deleted',
|
||||
'data' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function format(Category $category): array
|
||||
{
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'type' => $category->type,
|
||||
'name' => $category->name,
|
||||
'slug' => $category->slug,
|
||||
'description' => $category->description,
|
||||
'sort_order' => (int) $category->sort_order,
|
||||
'package_count' => $category->packages()->count(),
|
||||
'created_at' => optional($category->created_at)->toAtomString(),
|
||||
'updated_at' => optional($category->updated_at)->toAtomString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StorePackageRequest;
|
||||
use App\Models\Package;
|
||||
use App\Services\AdminPackageService;
|
||||
use App\Services\RepoFormatter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function __construct(private readonly AdminPackageService $service)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Package::query()->with(['categories', 'latestStableVersion']);
|
||||
|
||||
if ($type = $request->query('type')) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
if ($status = $request->query('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($keyword = trim((string) $request->query('keyword', ''))) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('slug', 'like', '%' . $keyword . '%')
|
||||
->orWhere('summary', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$result = $query->orderByDesc('sort_order')->orderByDesc('updated_at')
|
||||
->paginate(min(max((int) $request->query('size', 20), 1), 100));
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'ok',
|
||||
'data' => [
|
||||
'page' => $result->currentPage(),
|
||||
'size' => $result->perPage(),
|
||||
'total' => $result->total(),
|
||||
'items' => collect($result->items())->map(fn ($package) => RepoFormatter::packageDetail($package))->values()->all(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StorePackageRequest $request): JsonResponse
|
||||
{
|
||||
$package = $this->service->createPackage($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'package created',
|
||||
'data' => RepoFormatter::packageDetail($package->load(['versions', 'screenshots', 'categories'])),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(StorePackageRequest $request, string $type, string $slug): JsonResponse
|
||||
{
|
||||
$package = $this->findPackage($type, $slug);
|
||||
$package = $this->service->updatePackage($package, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'package updated',
|
||||
'data' => RepoFormatter::packageDetail($package->load(['versions', 'screenshots', 'categories'])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateStatus(Request $request, string $type, string $slug): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => ['required', Rule::in(['draft', 'published', 'hidden', 'deprecated'])],
|
||||
]);
|
||||
|
||||
$package = $this->findPackage($type, $slug);
|
||||
$package = $this->service->updateStatus($package, $validated['status']);
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'package status updated',
|
||||
'data' => RepoFormatter::packageDetail($package->load(['versions', 'screenshots', 'categories'])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(string $type, string $slug): JsonResponse
|
||||
{
|
||||
$package = $this->findPackage($type, $slug);
|
||||
$package->delete();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'package deleted',
|
||||
'data' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function findPackage(string $type, string $slug): Package
|
||||
{
|
||||
$package = Package::query()
|
||||
->where('type', $type)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if (!$package) {
|
||||
abort(response()->json([
|
||||
'code' => 404,
|
||||
'message' => 'package not found',
|
||||
'data' => null,
|
||||
], 404));
|
||||
}
|
||||
|
||||
return $package;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\PublishZipVersionRequest;
|
||||
use App\Http\Requests\Admin\StoreVersionRequest;
|
||||
use App\Models\Package;
|
||||
use App\Models\Version;
|
||||
use App\Services\AdminPackageService;
|
||||
use App\Services\RepoFormatter;
|
||||
use App\Services\VersionPublishService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class VersionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdminPackageService $service,
|
||||
private readonly VersionPublishService $publishService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(string $type, string $slug): JsonResponse
|
||||
{
|
||||
$package = $this->findPackage($type, $slug);
|
||||
$versions = $package->versions()->orderByDesc('published_at')->orderByDesc('id')->get();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'ok',
|
||||
'data' => [
|
||||
'package' => [
|
||||
'type' => $package->type,
|
||||
'slug' => $package->slug,
|
||||
'name' => $package->name,
|
||||
],
|
||||
'items' => $versions->map(fn ($version) => RepoFormatter::versionDetail($version))->values()->all(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreVersionRequest $request, string $type, string $slug): JsonResponse
|
||||
{
|
||||
$package = $this->findPackage($type, $slug);
|
||||
$version = $this->service->createVersion($package, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'version created',
|
||||
'data' => RepoFormatter::versionDetail($version),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function publish(PublishZipVersionRequest $request, string $type, string $slug): JsonResponse
|
||||
{
|
||||
$package = $this->findPackage($type, $slug);
|
||||
$version = $this->publishService->publishFromZip(
|
||||
$package,
|
||||
$request->file('package_file'),
|
||||
$request->validated()
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'version published',
|
||||
'data' => RepoFormatter::versionDetail($version),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$version = Version::query()->with('package')->find($id);
|
||||
if (!$version) {
|
||||
return response()->json([
|
||||
'code' => 404,
|
||||
'message' => 'version not found',
|
||||
'data' => null,
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->service->deleteVersion($version);
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'version deleted',
|
||||
'data' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function findPackage(string $type, string $slug): Package
|
||||
{
|
||||
$package = Package::query()
|
||||
->where('type', $type)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if (!$package) {
|
||||
abort(response()->json([
|
||||
'code' => 404,
|
||||
'message' => 'package not found',
|
||||
'data' => null,
|
||||
], 404));
|
||||
}
|
||||
|
||||
return $package;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Category::query()->orderByDesc('sort_order')->orderBy('id');
|
||||
|
||||
if ($type = $request->query('type')) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
$categories = $query->get()->map(function ($item) {
|
||||
return [
|
||||
'slug' => $item->slug,
|
||||
'name' => $item->name,
|
||||
'type' => $item->type,
|
||||
'package_count' => $item->packages()->count(),
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'ok',
|
||||
'data' => [
|
||||
'categories' => $categories,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DownloadLog;
|
||||
use App\Models\Package;
|
||||
use App\Services\RepoFormatter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class RepoController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$allowPrerelease = (string) $request->query('allow_prerelease', '0') === '1';
|
||||
|
||||
$relation = $allowPrerelease ? 'latestVersion' : 'latestStableVersion';
|
||||
$query = Package::query()
|
||||
->with([$relation, 'categories'])
|
||||
->where('status', 'published');
|
||||
|
||||
if ($type = $request->query('type')) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
if ($keyword = trim((string) $request->query('keyword', ''))) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('summary', 'like', '%' . $keyword . '%')
|
||||
->orWhere('slug', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($category = $request->query('category')) {
|
||||
$query->whereHas('categories', function ($q) use ($category) {
|
||||
$q->where('slug', $category);
|
||||
});
|
||||
}
|
||||
|
||||
$sort = $request->query('sort', 'latest');
|
||||
if ($sort === 'downloads') {
|
||||
$query->orderByDesc('download_count')->orderByDesc('sort_order');
|
||||
} elseif ($sort === 'name') {
|
||||
$query->orderBy('name');
|
||||
} else {
|
||||
$query->orderByDesc('sort_order')->orderByDesc('updated_at');
|
||||
}
|
||||
|
||||
$size = min(max((int) $request->query('size', 20), 1), 100);
|
||||
$page = max((int) $request->query('page', 1), 1);
|
||||
$result = $query->paginate($size, ['*'], 'page', $page);
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'ok',
|
||||
'data' => [
|
||||
'page' => $result->currentPage(),
|
||||
'size' => $result->perPage(),
|
||||
'total' => $result->total(),
|
||||
'items' => collect($result->items())->map(function ($package) use ($allowPrerelease) {
|
||||
return RepoFormatter::packageListItem($package, $allowPrerelease);
|
||||
})->values()->all(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function detail(string $type, string $slug): JsonResponse
|
||||
{
|
||||
$package = Package::query()
|
||||
->with([
|
||||
'categories',
|
||||
'screenshots',
|
||||
'versions' => function ($q) {
|
||||
$q->orderByDesc('published_at')->orderByDesc('id');
|
||||
},
|
||||
])
|
||||
->where('status', 'published')
|
||||
->where('type', $type)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if (!$package) {
|
||||
return response()->json([
|
||||
'code' => 404,
|
||||
'message' => 'package not found',
|
||||
'data' => null,
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'ok',
|
||||
'data' => RepoFormatter::packageDetail($package, false),
|
||||
]);
|
||||
}
|
||||
|
||||
public function checkUpdates(Request $request): JsonResponse
|
||||
{
|
||||
$installed = $request->input('installed', []);
|
||||
$typechoVersion = (string) $request->input('typecho_version', '');
|
||||
$phpVersion = (string) $request->input('php_version', '');
|
||||
$allowPrerelease = (bool) $request->input('allow_prerelease', false);
|
||||
|
||||
$updates = [];
|
||||
$upToDate = [];
|
||||
$incompatible = [];
|
||||
$unknown = [];
|
||||
|
||||
foreach ($installed as $item) {
|
||||
$type = $item['type'] ?? '';
|
||||
$slug = $item['slug'] ?? '';
|
||||
$currentVersion = $item['version'] ?? '';
|
||||
|
||||
$package = Package::query()
|
||||
->with(['latestStableVersion', 'latestVersion'])
|
||||
->where('status', 'published')
|
||||
->where('type', $type)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
$latest = $allowPrerelease
|
||||
? ($package?->latestVersion ?: $package?->latestStableVersion)
|
||||
: $package?->latestStableVersion;
|
||||
|
||||
if (!$package || !$latest) {
|
||||
$unknown[] = [
|
||||
'type' => $type,
|
||||
'slug' => $slug,
|
||||
'current_version' => $currentVersion,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (version_compare($currentVersion, $latest->version, '>=')) {
|
||||
$upToDate[] = [
|
||||
'type' => $type,
|
||||
'slug' => $slug,
|
||||
'current_version' => $currentVersion,
|
||||
'latest_version' => $latest->version,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$compatibilityOk = $this->isCompatible($latest, $typechoVersion, $phpVersion);
|
||||
if (!$compatibilityOk) {
|
||||
$incompatible[] = [
|
||||
'type' => $type,
|
||||
'slug' => $slug,
|
||||
'current_version' => $currentVersion,
|
||||
'latest_version' => $latest->version,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$updates[] = [
|
||||
'type' => $type,
|
||||
'slug' => $slug,
|
||||
'name' => $package->name,
|
||||
'current_version' => $currentVersion,
|
||||
'latest_version' => $latest->version,
|
||||
'changelog' => $latest->changelog,
|
||||
'compatibility_ok' => true,
|
||||
'package' => [
|
||||
'size' => (int) $latest->package_size,
|
||||
'sha256' => $latest->sha256,
|
||||
'download_url' => $latest->package_url,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'ok',
|
||||
'data' => [
|
||||
'updates' => $updates,
|
||||
'up_to_date' => $upToDate,
|
||||
'incompatible' => $incompatible,
|
||||
'unknown' => $unknown,
|
||||
'store_plugin_update' => null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function download(Request $request, string $type, string $slug, string $version): JsonResponse|BinaryFileResponse|RedirectResponse
|
||||
{
|
||||
$pluginToken = (string) config('store.plugin_access_token', '');
|
||||
$requestToken = (string) ($request->header('X-Store-Plugin-Token') ?: $request->query('access_token', ''));
|
||||
|
||||
if ($pluginToken !== '' && $requestToken !== $pluginToken) {
|
||||
return response()->json([
|
||||
'code' => 403,
|
||||
'message' => 'download forbidden',
|
||||
'data' => null,
|
||||
], 403);
|
||||
}
|
||||
|
||||
$package = Package::query()
|
||||
->where('status', 'published')
|
||||
->where('type', $type)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if (!$package) {
|
||||
return response()->json([
|
||||
'code' => 404,
|
||||
'message' => 'package not found',
|
||||
'data' => null,
|
||||
], 404);
|
||||
}
|
||||
|
||||
$versionModel = $version === 'latest'
|
||||
? $package->versions()->where('is_stable', 1)->where('is_latest', 1)->first()
|
||||
: $package->versions()->where('version', $version)->first();
|
||||
|
||||
if (!$versionModel) {
|
||||
return response()->json([
|
||||
'code' => 404,
|
||||
'message' => 'version not found',
|
||||
'data' => null,
|
||||
], 404);
|
||||
}
|
||||
|
||||
$filePath = storage_path('app/packages/' . $type . '/' . $slug . '/' . $versionModel->version . '.zip');
|
||||
$hasLocalFile = is_file($filePath);
|
||||
$redirect = (string) $request->query('redirect', '0') === '1';
|
||||
|
||||
if ($redirect) {
|
||||
DownloadLog::create([
|
||||
'package_id' => $package->id,
|
||||
'version_id' => $versionModel->id,
|
||||
'site_url' => (string) $request->query('site_url', ''),
|
||||
'typecho_version' => (string) $request->query('typecho_version', ''),
|
||||
'php_version' => (string) $request->query('php_version', ''),
|
||||
'ip' => (string) $request->ip(),
|
||||
'user_agent' => (string) $request->userAgent(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$package->increment('download_count');
|
||||
$versionModel->increment('download_count');
|
||||
|
||||
if ($hasLocalFile) {
|
||||
return response()->download($filePath, $slug . '-' . $versionModel->version . '.zip', [
|
||||
'Content-Type' => 'application/zip',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!empty($versionModel->package_url)) {
|
||||
return redirect()->away($versionModel->package_url);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 404,
|
||||
'message' => 'package file not found',
|
||||
'data' => null,
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'ok',
|
||||
'data' => [
|
||||
'type' => $type,
|
||||
'slug' => $slug,
|
||||
'version' => $versionModel->version,
|
||||
'package' => [
|
||||
'download_url' => $this->buildDownloadUrl($request, $type, $slug, $versionModel->version),
|
||||
'size' => (int) $versionModel->package_size,
|
||||
'sha256' => $versionModel->sha256,
|
||||
'filename' => $slug . '-' . $versionModel->version . '.zip',
|
||||
'storage' => [
|
||||
'driver' => $hasLocalFile ? 'local' : 'remote',
|
||||
'exists' => $hasLocalFile,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function isCompatible($version, string $typechoVersion, string $phpVersion): bool
|
||||
{
|
||||
if ($typechoVersion !== '' && $version->typecho_min !== '' && version_compare($typechoVersion, $version->typecho_min, '<')) {
|
||||
return false;
|
||||
}
|
||||
if ($typechoVersion !== '' && $version->typecho_max !== '') {
|
||||
$max = str_replace('*', '999', $version->typecho_max);
|
||||
if (version_compare($typechoVersion, $max, '>')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ($phpVersion !== '' && $version->php_min !== '' && version_compare($phpVersion, $version->php_min, '<')) {
|
||||
return false;
|
||||
}
|
||||
if ($phpVersion !== '' && $version->php_max !== '') {
|
||||
$max = str_replace('*', '999', $version->php_max);
|
||||
if (version_compare($phpVersion, $max, '>')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function buildDownloadUrl(Request $request, string $type, string $slug, string $version): string
|
||||
{
|
||||
$path = '/api/v1/repo/download/' . $type . '/' . $slug . '/' . $version . '?redirect=1';
|
||||
$appUrl = rtrim((string) config('app.url', ''), '/');
|
||||
|
||||
if ($appUrl !== '') {
|
||||
return $appUrl . $path;
|
||||
}
|
||||
|
||||
return $request->getSchemeAndHttpHost() . $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Package;
|
||||
use App\Services\RepoFormatter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class StorefrontController extends Controller
|
||||
{
|
||||
public function home(): View
|
||||
{
|
||||
$featuredPlugins = Package::query()
|
||||
->with(['categories', 'latestStableVersion'])
|
||||
->where('status', 'published')
|
||||
->where('type', 'plugin')
|
||||
->orderByDesc('is_featured')
|
||||
->orderByDesc('sort_order')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(6)
|
||||
->get();
|
||||
|
||||
$featuredThemes = Package::query()
|
||||
->with(['categories', 'latestStableVersion'])
|
||||
->where('status', 'published')
|
||||
->where('type', 'theme')
|
||||
->orderByDesc('is_featured')
|
||||
->orderByDesc('sort_order')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(6)
|
||||
->get();
|
||||
|
||||
$stats = [
|
||||
'packages' => Package::query()->where('status', 'published')->count(),
|
||||
'plugins' => Package::query()->where('status', 'published')->where('type', 'plugin')->count(),
|
||||
'themes' => Package::query()->where('status', 'published')->where('type', 'theme')->count(),
|
||||
'categories' => Category::count(),
|
||||
];
|
||||
|
||||
return view('storefront.home', compact('featuredPlugins', 'featuredThemes', 'stats'));
|
||||
}
|
||||
|
||||
public function packages(Request $request, string $type): View
|
||||
{
|
||||
abort_unless(in_array($type, ['plugin', 'theme'], true), 404);
|
||||
|
||||
$query = Package::query()
|
||||
->with(['categories', 'latestStableVersion'])
|
||||
->where('status', 'published')
|
||||
->where('type', $type);
|
||||
|
||||
if ($keyword = trim((string) $request->query('keyword', ''))) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('slug', 'like', '%' . $keyword . '%')
|
||||
->orWhere('summary', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($category = (string) $request->query('category', '')) {
|
||||
$query->whereHas('categories', fn ($q) => $q->where('slug', $category));
|
||||
}
|
||||
|
||||
$sort = (string) $request->query('sort', 'latest');
|
||||
if ($sort === 'name') {
|
||||
$query->orderBy('name');
|
||||
} else {
|
||||
$query->orderByDesc('is_featured')->orderByDesc('sort_order')->orderByDesc('updated_at');
|
||||
}
|
||||
|
||||
$packages = $query->paginate(12)->withQueryString();
|
||||
$categories = Category::query()->where('type', $type)->orderByDesc('sort_order')->get();
|
||||
|
||||
return view('storefront.packages', [
|
||||
'type' => $type,
|
||||
'packages' => $packages,
|
||||
'categories' => $categories,
|
||||
'filters' => [
|
||||
'keyword' => (string) $request->query('keyword', ''),
|
||||
'category' => (string) $request->query('category', ''),
|
||||
'sort' => $sort,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $type, string $slug): View
|
||||
{
|
||||
abort_unless(in_array($type, ['plugin', 'theme'], true), 404);
|
||||
|
||||
$package = Package::query()
|
||||
->with([
|
||||
'categories',
|
||||
'screenshots',
|
||||
'versions' => fn ($q) => $q->orderByDesc('published_at')->orderByDesc('id'),
|
||||
])
|
||||
->where('status', 'published')
|
||||
->where('type', $type)
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
$detail = RepoFormatter::packageDetail($package, false);
|
||||
|
||||
$related = Package::query()
|
||||
->with(['categories', 'latestStableVersion'])
|
||||
->where('status', 'published')
|
||||
->where('type', $type)
|
||||
->where('id', '!=', $package->id)
|
||||
->orderByDesc('is_featured')
|
||||
->orderByDesc('updated_at')
|
||||
->limit(4)
|
||||
->get();
|
||||
|
||||
return view('storefront.show', [
|
||||
'package' => $package,
|
||||
'detail' => $detail,
|
||||
'related' => $related,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WebAdmin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin(Request $request): View
|
||||
{
|
||||
return view('admin.login', [
|
||||
'redirect' => (string) $request->query('redirect', route('webadmin.home')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
'redirect' => ['nullable', 'string'],
|
||||
'remember' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$user = User::query()->where('email', $validated['email'])->first();
|
||||
|
||||
if (!$user || !Hash::check($validated['password'], $user->password)) {
|
||||
return back()->withInput($request->except('password'))->with('error', '账号或密码不正确');
|
||||
}
|
||||
|
||||
Auth::login($user, (bool) ($validated['remember'] ?? false));
|
||||
$request->session()->regenerate();
|
||||
|
||||
$redirect = $validated['redirect'] ?: route('webadmin.home');
|
||||
|
||||
return redirect()->to($redirect);
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('webadmin.login')->with('success', '已退出后台');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WebAdmin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreCategoryRequest;
|
||||
use App\Http\Requests\Admin\StorePackageRequest;
|
||||
use App\Models\Category;
|
||||
use App\Models\Package;
|
||||
use App\Models\Version;
|
||||
use App\Services\AdminPackageService;
|
||||
use App\Services\VersionPublishService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdminPackageService $service,
|
||||
private readonly VersionPublishService $publishService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function home(): View
|
||||
{
|
||||
$stats = [
|
||||
'packages' => Package::count(),
|
||||
'plugins' => Package::where('type', 'plugin')->count(),
|
||||
'themes' => Package::where('type', 'theme')->count(),
|
||||
'versions' => Version::count(),
|
||||
'downloads' => (int) Package::sum('download_count'),
|
||||
'categories' => Category::count(),
|
||||
];
|
||||
|
||||
$recentPackages = Package::query()
|
||||
->with(['categories', 'latestStableVersion'])
|
||||
->latest('updated_at')
|
||||
->limit(6)
|
||||
->get();
|
||||
|
||||
$recentVersions = Version::query()
|
||||
->with('package')
|
||||
->latest('published_at')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
return view('admin.dashboard', compact('stats', 'recentPackages', 'recentVersions'));
|
||||
}
|
||||
|
||||
public function packages(Request $request): View
|
||||
{
|
||||
$query = Package::query()->with(['categories', 'latestStableVersion']);
|
||||
|
||||
if ($type = $request->query('type')) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
if ($status = $request->query('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($keyword = trim((string) $request->query('keyword', ''))) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('slug', 'like', '%' . $keyword . '%')
|
||||
->orWhere('summary', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$packages = $query->orderByDesc('sort_order')->orderByDesc('updated_at')->paginate(15)->withQueryString();
|
||||
|
||||
return view('admin.packages.index', [
|
||||
'packages' => $packages,
|
||||
'filters' => [
|
||||
'type' => (string) $request->query('type', ''),
|
||||
'status' => (string) $request->query('status', ''),
|
||||
'keyword' => (string) $request->query('keyword', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function storePackage(StorePackageRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['categories'] = $this->normalizeCategories($request);
|
||||
$package = $this->service->createPackage($data);
|
||||
|
||||
return redirect()->route('webadmin.packages.show', [$package->type, $package->slug])->with('success', '扩展已创建');
|
||||
}
|
||||
|
||||
public function updatePackage(StorePackageRequest $request, string $type, string $slug): RedirectResponse
|
||||
{
|
||||
$package = $this->findPackage($type, $slug);
|
||||
$data = $request->validated();
|
||||
$data['categories'] = $this->normalizeCategories($request);
|
||||
$this->service->updatePackage($package, $data);
|
||||
|
||||
return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', '扩展已更新');
|
||||
}
|
||||
|
||||
public function updatePackageStatus(Request $request, string $type, string $slug): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => ['required', 'in:draft,published,hidden,deprecated'],
|
||||
]);
|
||||
|
||||
$package = $this->findPackage($type, $slug);
|
||||
$this->service->updateStatus($package, $validated['status']);
|
||||
|
||||
return redirect()->route('webadmin.packages')->with('success', '状态已更新');
|
||||
}
|
||||
|
||||
public function categories(Request $request): View
|
||||
{
|
||||
$query = Category::query()->withCount('packages');
|
||||
|
||||
if ($type = $request->query('type')) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
if ($keyword = trim((string) $request->query('keyword', ''))) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('slug', 'like', '%' . $keyword . '%')
|
||||
->orWhere('description', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$categories = $query->orderBy('type')->orderByDesc('sort_order')->paginate(20)->withQueryString();
|
||||
|
||||
return view('admin.categories.index', [
|
||||
'categories' => $categories,
|
||||
'filters' => [
|
||||
'type' => (string) $request->query('type', ''),
|
||||
'keyword' => (string) $request->query('keyword', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeCategory(StoreCategoryRequest $request): RedirectResponse
|
||||
{
|
||||
Category::create($request->validated());
|
||||
|
||||
return redirect()->route('webadmin.categories')->with('success', '分类已创建');
|
||||
}
|
||||
|
||||
public function updateCategory(StoreCategoryRequest $request, int $id): RedirectResponse
|
||||
{
|
||||
$category = Category::findOrFail($id);
|
||||
$category->update($request->validated());
|
||||
|
||||
return redirect()->route('webadmin.categories')->with('success', '分类已更新');
|
||||
}
|
||||
|
||||
public function destroyCategory(int $id): RedirectResponse
|
||||
{
|
||||
$category = Category::findOrFail($id);
|
||||
$category->packages()->detach();
|
||||
$category->delete();
|
||||
|
||||
return redirect()->route('webadmin.categories')->with('success', '分类已删除');
|
||||
}
|
||||
|
||||
public function showPackage(string $type, string $slug): View
|
||||
{
|
||||
$package = Package::query()
|
||||
->with(['categories', 'screenshots', 'versions' => fn ($q) => $q->latest('published_at')])
|
||||
->where('type', $type)
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
return view('admin.packages.show', [
|
||||
'package' => $package,
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeVersion(Request $request, string $type, string $slug): RedirectResponse
|
||||
{
|
||||
$package = $this->findPackage($type, $slug);
|
||||
|
||||
$validated = $request->validate([
|
||||
'version' => ['required', 'string', 'max:32'],
|
||||
'changelog' => ['nullable', 'string'],
|
||||
'typecho_min' => ['nullable', 'string', 'max:32'],
|
||||
'typecho_max' => ['nullable', 'string', 'max:32'],
|
||||
'php_min' => ['nullable', 'string', 'max:32'],
|
||||
'php_max' => ['nullable', 'string', 'max:32'],
|
||||
'package_url' => ['nullable', 'url', 'max:512'],
|
||||
'package_size' => ['nullable', 'integer', 'min:0'],
|
||||
'sha256' => ['nullable', 'string', 'max:64'],
|
||||
'is_stable' => ['nullable', 'boolean'],
|
||||
'is_latest' => ['nullable', 'boolean'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
'php_extensions' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'version' => $validated['version'],
|
||||
'changelog' => $validated['changelog'] ?? '',
|
||||
'typecho_min' => $validated['typecho_min'] ?? '1.2.0',
|
||||
'typecho_max' => $validated['typecho_max'] ?? '',
|
||||
'php_min' => $validated['php_min'] ?? '7.4',
|
||||
'php_max' => $validated['php_max'] ?? '',
|
||||
'php_extensions' => collect(explode(',', (string) ($validated['php_extensions'] ?? '')))
|
||||
->map(fn ($item) => trim($item))
|
||||
->filter()
|
||||
->values()
|
||||
->all(),
|
||||
'package_url' => $validated['package_url'] ?? '',
|
||||
'package_size' => (int) ($validated['package_size'] ?? 0),
|
||||
'sha256' => $validated['sha256'] ?? str_repeat('0', 64),
|
||||
'is_stable' => (bool) ($validated['is_stable'] ?? false),
|
||||
'mark_as_latest' => (bool) ($validated['is_latest'] ?? false),
|
||||
'published_at' => $validated['published_at'] ?? now(),
|
||||
];
|
||||
|
||||
$version = $this->service->createVersion($package, $payload);
|
||||
|
||||
return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', '版本已添加:v' . $version->version);
|
||||
}
|
||||
|
||||
public function publishVersion(Request $request, string $type, string $slug): RedirectResponse
|
||||
{
|
||||
$package = $this->findPackage($type, $slug);
|
||||
|
||||
$validated = $request->validate([
|
||||
'package_file' => ['required', 'file', 'mimes:zip', 'max:51200'],
|
||||
'is_stable' => ['nullable', 'boolean'],
|
||||
'mark_as_latest' => ['nullable', 'boolean'],
|
||||
'changelog' => ['nullable', 'string'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$version = $this->publishService->publishFromZip($package, $request->file('package_file'), $validated);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withInput()->with('error', 'zip 发布失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
$msg = 'zip 发布成功:v' . $version->version;
|
||||
$msg .= $version->is_stable ? ' · stable' : ' · prerelease';
|
||||
$msg .= $version->is_latest ? ' · latest' : '';
|
||||
|
||||
return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', $msg);
|
||||
}
|
||||
|
||||
public function destroyVersion(string $type, string $slug, int $id): RedirectResponse
|
||||
{
|
||||
$package = $this->findPackage($type, $slug);
|
||||
$version = $package->versions()->where('id', $id)->firstOrFail();
|
||||
$label = $version->version;
|
||||
$this->service->deleteVersion($version);
|
||||
|
||||
return redirect()->route('webadmin.packages.show', [$type, $slug])->with('success', '版本已删除:v' . $label);
|
||||
}
|
||||
|
||||
private function findPackage(string $type, string $slug): Package
|
||||
{
|
||||
return Package::query()
|
||||
->where('type', $type)
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function normalizeCategories(Request $request): array
|
||||
{
|
||||
$raw = (string) $request->input('categories_text', '');
|
||||
|
||||
return collect(preg_split('/[,,\s]+/u', $raw))
|
||||
->map(fn ($item) => trim((string) $item))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AdminTokenAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$configuredToken = (string) config('store.admin_token', env('STORE_ADMIN_TOKEN', ''));
|
||||
|
||||
if ($configuredToken === '') {
|
||||
return response()->json([
|
||||
'code' => 500,
|
||||
'message' => 'admin token not configured',
|
||||
'data' => null,
|
||||
], 500);
|
||||
}
|
||||
|
||||
$incomingToken = $this->extractToken($request);
|
||||
if (!hash_equals($configuredToken, $incomingToken)) {
|
||||
return response()->json([
|
||||
'code' => 401,
|
||||
'message' => 'unauthorized',
|
||||
'data' => null,
|
||||
], 401);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function extractToken(Request $request): string
|
||||
{
|
||||
$header = (string) $request->header('Authorization', '');
|
||||
if (preg_match('/^Bearer\s+(.+)$/i', $header, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
|
||||
return (string) ($request->header('X-Admin-Token')
|
||||
?: $request->query('admin_token')
|
||||
?: $request->input('admin_token', ''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class WebAdminTokenAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return redirect()->route('webadmin.login', [
|
||||
'redirect' => $request->fullUrl(),
|
||||
])->with('error', '请先登录后台账号');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PublishZipVersionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'package_file' => ['required', 'file', 'mimes:zip', 'max:51200'],
|
||||
'is_stable' => ['nullable', 'boolean'],
|
||||
'mark_as_latest' => ['nullable', 'boolean'],
|
||||
'changelog' => ['nullable', 'string'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreCategoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', Rule::in(['plugin', 'theme'])],
|
||||
'name' => ['required', 'string', 'max:64'],
|
||||
'slug' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9-]+$/'],
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
'sort_order' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StorePackageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', Rule::in(['plugin', 'theme'])],
|
||||
'slug' => ['required', 'string', 'max:64', 'regex:/^[A-Za-z][A-Za-z0-9_-]*$/'],
|
||||
'name' => ['required', 'string', 'max:128'],
|
||||
'summary' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'author' => ['nullable', 'string', 'max:64'],
|
||||
'homepage' => ['nullable', 'url', 'max:512'],
|
||||
'icon_url' => ['nullable', 'url', 'max:512'],
|
||||
'license' => ['nullable', 'string', 'max:32'],
|
||||
'status' => ['nullable', Rule::in(['draft', 'published', 'hidden', 'deprecated'])],
|
||||
'is_featured' => ['nullable', 'boolean'],
|
||||
'sort_order' => ['nullable', 'integer'],
|
||||
'categories' => ['nullable', 'array'],
|
||||
'categories.*' => ['string', 'max:64'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreVersionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'version' => ['required', 'string', 'max:32'],
|
||||
'changelog' => ['nullable', 'string'],
|
||||
'typecho_min' => ['nullable', 'string', 'max:16'],
|
||||
'typecho_max' => ['nullable', 'string', 'max:16'],
|
||||
'php_min' => ['nullable', 'string', 'max:16'],
|
||||
'php_max' => ['nullable', 'string', 'max:16'],
|
||||
'php_extensions' => ['nullable', 'array'],
|
||||
'php_extensions.*' => ['string', 'max:32'],
|
||||
'package_url' => ['required', 'url', 'max:1024'],
|
||||
'package_size' => ['nullable', 'integer', 'min:0'],
|
||||
'sha256' => ['required', 'string', 'size:64'],
|
||||
'is_stable' => ['nullable', 'boolean'],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
'mark_as_latest' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
protected $table = 'store_categories';
|
||||
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
public function packages()
|
||||
{
|
||||
return $this->belongsToMany(Package::class, 'store_package_categories', 'category_id', 'package_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DownloadLog extends Model
|
||||
{
|
||||
protected $table = 'store_download_logs';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'package_id',
|
||||
'version_id',
|
||||
'site_url',
|
||||
'typecho_version',
|
||||
'php_version',
|
||||
'ip',
|
||||
'user_agent',
|
||||
'created_at',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Package extends Model
|
||||
{
|
||||
protected $table = 'store_packages';
|
||||
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'slug',
|
||||
'name',
|
||||
'summary',
|
||||
'description',
|
||||
'author',
|
||||
'homepage',
|
||||
'icon_url',
|
||||
'license',
|
||||
'status',
|
||||
'is_featured',
|
||||
'sort_order',
|
||||
'download_count',
|
||||
'latest_version',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_featured' => 'boolean',
|
||||
];
|
||||
|
||||
public function versions()
|
||||
{
|
||||
return $this->hasMany(Version::class, 'package_id');
|
||||
}
|
||||
|
||||
public function latestStableVersion()
|
||||
{
|
||||
return $this->hasOne(Version::class, 'package_id')
|
||||
->where('is_stable', 1)
|
||||
->where('is_latest', 1);
|
||||
}
|
||||
|
||||
public function latestVersion()
|
||||
{
|
||||
return $this->hasOne(Version::class, 'package_id')
|
||||
->where('is_latest', 1);
|
||||
}
|
||||
|
||||
public function screenshots()
|
||||
{
|
||||
return $this->hasMany(Screenshot::class, 'package_id')->orderBy('sort_order')->orderBy('id');
|
||||
}
|
||||
|
||||
public function categories()
|
||||
{
|
||||
return $this->belongsToMany(Category::class, 'store_package_categories', 'package_id', 'category_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Screenshot extends Model
|
||||
{
|
||||
protected $table = 'store_screenshots';
|
||||
|
||||
protected $fillable = [
|
||||
'package_id',
|
||||
'image_url',
|
||||
'caption',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
public function package()
|
||||
{
|
||||
return $this->belongsTo(Package::class, 'package_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Version extends Model
|
||||
{
|
||||
protected $table = 'store_versions';
|
||||
|
||||
protected $fillable = [
|
||||
'package_id',
|
||||
'version',
|
||||
'changelog',
|
||||
'typecho_min',
|
||||
'typecho_max',
|
||||
'php_min',
|
||||
'php_max',
|
||||
'php_extensions',
|
||||
'package_url',
|
||||
'package_size',
|
||||
'sha256',
|
||||
'is_stable',
|
||||
'is_latest',
|
||||
'download_count',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_stable' => 'boolean',
|
||||
'is_latest' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function package()
|
||||
{
|
||||
return $this->belongsTo(Package::class, 'package_id');
|
||||
}
|
||||
|
||||
public function getPhpExtensionsArrayAttribute()
|
||||
{
|
||||
$value = $this->php_extensions;
|
||||
if (empty($value)) {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -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