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(); } }