From 833077a1f67668ea0c6bbb1daa2ee71a706d82a7 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sun, 12 Apr 2026 12:23:03 +0800 Subject: [PATCH] fix(cloudflare): remove node-only site icon route --- CHANGELOG.md | 7 +- README.md | 2 +- app-release.json | 12 ++- app/api/site-icon/route.ts | 118 ----------------------------- app/layout.tsx | 75 ++++++++++-------- components/SiteIconProvider.tsx | 19 +++++ components/layout/Navbar.tsx | 6 +- components/player/PlayerNavbar.tsx | 6 +- lib/config/site-config.ts | 2 - lib/server/site-icon.ts | 103 +++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- public/manifest.json | 2 +- 13 files changed, 194 insertions(+), 164 deletions(-) delete mode 100644 app/api/site-icon/route.ts create mode 100644 components/SiteIconProvider.tsx create mode 100644 lib/server/site-icon.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c1758cd..9d71222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 4.9.1 - 2026-04-12 + +- 移除仅支持 Node.js 的 `/api/site-icon` 路由,避免 `next-on-pages` 因非 Edge 路由中止构建。 +- 站点图标改为由服务端布局直接解析,导航栏和播放器页继续显示运行时配置的图标。 +- PWA manifest 回退为静态图标,确保 Cloudflare Pages、Docker 和本地构建链路一致可用。 + ## 4.9.0 - 2026-04-12 - 设置页新增“版本与更新”卡片,直接显示当前版本、最近更新内容和检查结果。 @@ -11,4 +17,3 @@ - 站点图标改为支持运行时配置,Docker 镜像无需重新构建即可替换。 - 扩展播放器默认视口,减少桌面端播放器黑边。 - 修复 Android WebView 中 Cast SDK 判定以及全屏和画中画相关兼容性问题。 - diff --git a/README.md b/README.md index d5203f5..e42ff97 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ NEXT_PUBLIC_SITE_DESCRIPTION=专属视频聚合播放平台 ## Docker 图标自定义 -Docker 预构建镜像支持在运行时替换图标,无需重新构建镜像。该配置会同时作用于顶部 Logo、浏览器 favicon 和 PWA 图标。 +Docker 预构建镜像支持在运行时替换图标,无需重新构建镜像。该配置会作用于顶部 Logo 和浏览器 favicon;如果你还要同步替换安装后的 PWA 图标,请直接覆盖仓库中的 `public/icon.png` 后重新构建镜像。 ### 可用环境变量: diff --git a/app-release.json b/app-release.json index bd14c4a..f05743d 100644 --- a/app-release.json +++ b/app-release.json @@ -4,8 +4,18 @@ "name": "KVideo", "branch": "main" }, - "currentVersion": "4.9.0", + "currentVersion": "4.9.1", "releases": [ + { + "version": "4.9.1", + "publishedAt": "2026-04-12", + "title": "修复 Cloudflare Pages 构建失败", + "notes": [ + "移除仅支持 Node.js 的 /api/site-icon 路由,避免 next-on-pages 因非 Edge 路由中止构建。", + "站点图标改为由服务端布局直接解析,导航栏和播放器页继续显示运行时配置的图标。", + "PWA manifest 回退为静态图标,确保 Cloudflare Pages、Docker 和本地构建链路一致可用。" + ] + }, { "version": "4.9.0", "publishedAt": "2026-04-12", diff --git a/app/api/site-icon/route.ts b/app/api/site-icon/route.ts deleted file mode 100644 index 79506fb..0000000 --- a/app/api/site-icon/route.ts +++ /dev/null @@ -1,118 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { NextResponse } from 'next/server'; - -const DEFAULT_ICON_PATH = '/icon.png'; -const SITE_ICON_ROUTE = '/api/site-icon'; - -export const runtime = 'nodejs'; -export const dynamic = 'force-dynamic'; - -function getMimeType(filePath: string): string { - switch (path.extname(filePath).toLowerCase()) { - case '.avif': - return 'image/avif'; - case '.gif': - return 'image/gif'; - case '.ico': - return 'image/x-icon'; - case '.jpg': - case '.jpeg': - return 'image/jpeg'; - case '.png': - return 'image/png'; - case '.svg': - return 'image/svg+xml'; - case '.webp': - return 'image/webp'; - default: - return 'application/octet-stream'; - } -} - -function getIconFileCandidates(filePath: string): string[] { - if (path.isAbsolute(filePath)) { - return [filePath]; - } - - const currentWorkingDirectory = process.cwd(); - const candidates = [path.join(currentWorkingDirectory, filePath)]; - const standaloneSuffix = `${path.sep}.next${path.sep}standalone`; - - if (currentWorkingDirectory.endsWith(standaloneSuffix)) { - candidates.push(path.resolve(currentWorkingDirectory, '..', '..', filePath)); - } - - return [...new Set(candidates)]; -} - -function buildIconRedirect(request: Request, iconUrl: string): NextResponse | null { - try { - const origin = new URL(request.url).origin; - const resolvedUrl = new URL(iconUrl, `${origin}/`); - - if (resolvedUrl.pathname === SITE_ICON_ROUTE) { - console.warn('[SiteIcon] Ignoring SITE_ICON_URL because it points back to /api/site-icon.'); - return null; - } - - return NextResponse.redirect(resolvedUrl, 307); - } catch (error) { - console.warn('[SiteIcon] Invalid SITE_ICON_URL:', iconUrl, error); - return null; - } -} - -async function serveIconFile(filePath: string): Promise { - const [fileBuffer, fileStat] = await Promise.all([ - fs.promises.readFile(filePath), - fs.promises.stat(filePath), - ]); - const etag = `W/"${fileStat.size}-${Math.trunc(fileStat.mtimeMs)}"`; - - return new Response(fileBuffer, { - headers: { - 'Cache-Control': 'public, max-age=0, must-revalidate', - 'Content-Length': String(fileStat.size), - 'Content-Type': getMimeType(filePath), - ETag: etag, - 'Last-Modified': fileStat.mtime.toUTCString(), - }, - }); -} - -export async function GET(request: Request) { - const iconFile = process.env.SITE_ICON_FILE?.trim(); - - if (iconFile) { - const resolvedFilePaths = getIconFileCandidates(iconFile); - let lastError: unknown = null; - - for (const resolvedFilePath of resolvedFilePaths) { - try { - return await serveIconFile(resolvedFilePath); - } catch (error) { - lastError = error; - } - } - - console.warn( - `[SiteIcon] Failed to read SITE_ICON_FILE from any supported path: ${resolvedFilePaths.join(', ')}`, - lastError, - ); - } - - const iconUrl = process.env.SITE_ICON_URL?.trim() || process.env.NEXT_PUBLIC_SITE_ICON_URL?.trim(); - - if (iconUrl) { - const redirectResponse = buildIconRedirect(request, iconUrl); - - if (redirectResponse) { - return redirectResponse; - } - } - - return NextResponse.redirect(new URL(DEFAULT_ICON_PATH, request.url), 307); -} - -export const HEAD = GET; diff --git a/app/layout.tsx b/app/layout.tsx index 390ca0f..8f248f5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,12 +3,13 @@ import type { Metadata } from "next"; import "./globals.css"; import { ThemeProvider } from "@/components/ThemeProvider"; import { AutoSync } from '@/components/AutoSync'; // <-- 引入了自动同步组件 +import { SiteIconProvider } from '@/components/SiteIconProvider'; import { TVProvider } from "@/lib/contexts/TVContext"; import { TVNavigationInitializer } from "@/components/TVNavigationInitializer"; import { Analytics } from "@vercel/analytics/react"; import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister"; import { PasswordGate } from "@/components/PasswordGate"; -import { siteConfig, SITE_ICON_PATH } from "@/lib/config/site-config"; +import { siteConfig } from "@/lib/config/site-config"; import { AdKeywordsInjector } from "@/components/AdKeywordsInjector"; import { BackToTop } from "@/components/ui/BackToTop"; import { ScrollPositionManager } from "@/components/ScrollPositionManager"; @@ -16,6 +17,7 @@ import { LocaleProvider } from "@/components/LocaleProvider"; import { RuntimeFeaturesProvider } from "@/components/RuntimeFeaturesProvider"; import { VideoTogetherController } from '@/components/VideoTogetherController'; import { getRuntimeFeatures } from "@/lib/server/runtime-features"; +import { resolveSiteIconSrc } from '@/lib/server/site-icon'; import fs from 'fs'; import path from 'path'; @@ -61,19 +63,24 @@ async function AdKeywordsWrapper() { return ; } -export const metadata: Metadata = { - title: siteConfig.title, - description: siteConfig.description, - icons: { - icon: SITE_ICON_PATH, - }, -}; +export async function generateMetadata(): Promise { + const siteIconSrc = await resolveSiteIconSrc(); -export default function RootLayout({ + return { + title: siteConfig.title, + description: siteConfig.description, + icons: { + icon: siteIconSrc, + }, + }; +} + +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const siteIconSrc = await resolveSiteIconSrc(); const runtimeFeatures = getRuntimeFeatures(); const videoTogetherScriptUrl = process.env.VIDEOTOGETHER_SCRIPT_URL?.trim() || DEFAULT_VIDEOTOGETHER_SCRIPT_URL; @@ -89,7 +96,7 @@ export default function RootLayout({ - + {/* Theme Color (for browser address bar) */} {/* Mobile viewport */} @@ -99,30 +106,32 @@ export default function RootLayout({ className="antialiased" suppressHydrationWarning > - - - - {/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */} - - + + + + + {/* 加入自动同步组件,它会在后台默默工作,我们放在 ThemeProvider 内部的最前面 */} + + - - - - - {children} - - - - - - - - + + + + + {children} + + + + + + + + + {/* ARIA Live Region for Screen Reader Announcements */}
{children}; +} + +export function useSiteIcon() { + return useContext(SiteIconContext); +} diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx index 716e236..576e211 100644 --- a/components/layout/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -4,8 +4,9 @@ import { useState } from 'react'; import Link from 'next/link'; import Image from 'next/image'; import { ThemeSwitcher } from '@/components/ThemeSwitcher'; +import { useSiteIcon } from '@/components/SiteIconProvider'; import { Icons } from '@/components/ui/Icon'; -import { siteConfig, SITE_ICON_PATH } from '@/lib/config/site-config'; +import { siteConfig } from '@/lib/config/site-config'; import { getSession, clearSession, hasPermission, type AuthSession } from '@/lib/store/auth-store'; import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider'; import { LogOut } from 'lucide-react'; @@ -20,6 +21,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) { const favoritesHref = isPremiumMode ? '/premium/favorites' : '/favorites'; const [session] = useState(() => getSession()); const { iptvEnabled } = useRuntimeFeatures(); + const siteIconSrc = useSiteIcon(); const handleLogout = () => { clearSession(); @@ -45,7 +47,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) { >
{siteConfig.name} @@ -20,7 +22,7 @@ export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) { title={isPremium ? "返回高级主页" : "返回首页"} > {siteConfig.name} { + const resolvedFilePaths = getIconFileCandidates(iconFile); + let lastError: unknown = null; + + for (const resolvedFilePath of resolvedFilePaths) { + try { + const fileBuffer = await fs.promises.readFile(resolvedFilePath); + const mimeType = getMimeType(resolvedFilePath); + return `data:${mimeType};base64,${fileBuffer.toString('base64')}`; + } catch (error) { + lastError = error; + } + } + + console.warn( + `[SiteIcon] Failed to read SITE_ICON_FILE from any supported path: ${resolvedFilePaths.join(', ')}`, + lastError, + ); + return null; +} + +export async function resolveSiteIconSrc(): Promise { + const resolvedRuntimeIcon = normalizeIconUrl(process.env.SITE_ICON_RESOLVED_URL); + if (resolvedRuntimeIcon) { + return resolvedRuntimeIcon; + } + + const runtimeIconUrl = normalizeIconUrl( + process.env.SITE_ICON_URL?.trim() || process.env.NEXT_PUBLIC_SITE_ICON_URL?.trim(), + ); + if (runtimeIconUrl) { + return runtimeIconUrl; + } + + const iconFile = process.env.SITE_ICON_FILE?.trim(); + if (iconFile) { + const dataUrl = await resolveIconFileAsDataUrl(iconFile); + if (dataUrl) { + return dataUrl; + } + } + + return DEFAULT_SITE_ICON_PATH; +} diff --git a/package-lock.json b/package-lock.json index b96fa8f..89117d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.9.0", + "version": "4.9.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.9.0", + "version": "4.9.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 45e8b4a..1a98e88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.9.0", + "version": "4.9.1", "private": true, "scripts": { "dev": "next dev --port ${PORT:-3000}", diff --git a/public/manifest.json b/public/manifest.json index 6bac464..b4b2b33 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -9,7 +9,7 @@ "orientation": "any", "icons": [ { - "src": "/api/site-icon", + "src": "/icon.png", "purpose": "any maskable" } ],