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
@@ -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,
],
]);
}
}
+317
View File
@@ -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;
}
}
+8
View File
@@ -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();
}
}
+47
View File
@@ -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', ''));
}
}
+22
View File
@@ -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'],
];
}
}