Files
Tstore/app/Http/Controllers/Api/RepoController.php
浪子 04a2034975 前端极简黑白重设计,并纳入客户端注册、站点设置与后台相关改动
- storefront 全站改为极简黑白配色:直角、细线分隔、卡片 hover 浅底、深色模式(跟随系统+手动切换)
- 抽出 storefront/partials/card 复用卡片
- 一并提交此前暂存的客户端注册 API、站点设置、后台页面与测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 08:35:49 +08:00

406 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\DownloadLog;
use App\Models\Package;
use App\Models\StoreClient;
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' => $this->buildDownloadUrl($request, $type, $slug, $latest->version),
],
];
}
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 = $this->extractRequestToken($request);
$client = $this->resolveClientByToken($requestToken);
$legacyTokenMatched = $pluginToken !== '' && $requestToken !== '' && hash_equals($pluginToken, $requestToken);
if (!$legacyTokenMatched && !$client) {
return response()->json([
'code' => 403,
'message' => 'client token required',
'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) {
if ($client) {
$client->status = 'online';
$client->last_ip = (string) ($request->ip() ?? '');
$client->last_user_agent = (string) ($request->userAgent() ?? '');
$client->last_seen_at = now();
$client->save();
}
DownloadLog::create([
'package_id' => $package->id,
'version_id' => $versionModel->id,
'site_url' => $client?->site_url ?: (string) $request->query('site_url', ''),
'typecho_version' => $client?->typecho_version ?: (string) $request->query('typecho_version', ''),
'php_version' => $client?->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';
$baseUrl = $this->resolveDownloadBaseUrl($request);
if ($baseUrl !== '') {
return $baseUrl . $path;
}
return $path;
}
private function resolveDownloadBaseUrl(Request $request): string
{
$appUrl = $this->normalizeBaseUrl((string) config('app.url', ''));
if ($this->isPublicHttpsBaseUrl($appUrl)) {
return $appUrl;
}
$requestRoot = $this->normalizeBaseUrl($request->root());
if ($requestRoot !== '') {
return $requestRoot;
}
return $appUrl;
}
private function normalizeBaseUrl(string $url): string
{
return rtrim(trim($url), '/');
}
private function isPublicHttpsBaseUrl(string $url): bool
{
if ($url === '') {
return false;
}
$parts = parse_url($url);
if (!is_array($parts)) {
return false;
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = strtolower((string) ($parts['host'] ?? ''));
if ($scheme !== 'https' || $host === '') {
return false;
}
if (filter_var($host, FILTER_VALIDATE_IP)) {
return filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
if (in_array($host, ['localhost', 'host.docker.internal'], true)) {
return false;
}
foreach (['.localhost', '.local', '.internal', '.test'] as $suffix) {
if (str_ends_with($host, $suffix)) {
return false;
}
}
return true;
}
private function extractRequestToken(Request $request): string
{
return trim((string) (
$request->bearerToken()
?: $request->header('X-Store-Plugin-Token')
?: $request->query('access_token', '')
));
}
private function resolveClientByToken(string $token): ?StoreClient
{
if ($token === '') {
return null;
}
return StoreClient::query()->where('access_token', $token)->first();
}
}