fix(cloudflare): remove node-only site icon route

This commit is contained in:
kuekhaoyang
2026-04-12 12:23:03 +08:00
parent 4fb718f8d8
commit 833077a1f6
13 changed files with 194 additions and 164 deletions
+6 -1
View File
@@ -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 判定以及全屏和画中画相关兼容性问题。
+1 -1
View File
@@ -391,7 +391,7 @@ NEXT_PUBLIC_SITE_DESCRIPTION=专属视频聚合播放平台
## Docker 图标自定义
Docker 预构建镜像支持在运行时替换图标,无需重新构建镜像。该配置会同时作用于顶部 Logo浏览器 favicon 和 PWA 图标
Docker 预构建镜像支持在运行时替换图标,无需重新构建镜像。该配置会作用于顶部 Logo浏览器 favicon;如果你还要同步替换安装后的 PWA 图标,请直接覆盖仓库中的 `public/icon.png` 后重新构建镜像
### 可用环境变量:
+11 -1
View File
@@ -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",
-118
View File
@@ -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<Response> {
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;
+15 -6
View File
@@ -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 <AdKeywordsInjector keywords={keywords} />;
}
export const metadata: Metadata = {
export async function generateMetadata(): Promise<Metadata> {
const siteIconSrc = await resolveSiteIconSrc();
return {
title: siteConfig.title,
description: siteConfig.description,
icons: {
icon: SITE_ICON_PATH,
icon: siteIconSrc,
},
};
};
}
export default function RootLayout({
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({
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="KVideo" />
<link rel="apple-touch-icon" href={SITE_ICON_PATH} />
<link rel="apple-touch-icon" href={siteIconSrc} />
{/* Theme Color (for browser address bar) */}
<meta name="theme-color" content="#000000" />
{/* Mobile viewport */}
@@ -99,6 +106,7 @@ export default function RootLayout({
className="antialiased"
suppressHydrationWarning
>
<SiteIconProvider iconSrc={siteIconSrc}>
<ThemeProvider>
<RuntimeFeaturesProvider initialFeatures={runtimeFeatures}>
<VideoTogetherController
@@ -123,6 +131,7 @@ export default function RootLayout({
<ServiceWorkerRegister />
</RuntimeFeaturesProvider>
</ThemeProvider>
</SiteIconProvider>
{/* ARIA Live Region for Screen Reader Announcements */}
<div
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { createContext, useContext } from 'react';
const SiteIconContext = createContext('/icon.png');
export function SiteIconProvider({
children,
iconSrc,
}: {
children: React.ReactNode;
iconSrc: string;
}) {
return <SiteIconContext.Provider value={iconSrc}>{children}</SiteIconContext.Provider>;
}
export function useSiteIcon() {
return useContext(SiteIconContext);
}
+4 -2
View File
@@ -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<AuthSession | null>(() => getSession());
const { iptvEnabled } = useRuntimeFeatures();
const siteIconSrc = useSiteIcon();
const handleLogout = () => {
clearSession();
@@ -45,7 +47,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
>
<div className="w-8 h-8 sm:w-10 sm:h-10 relative flex items-center justify-center flex-shrink-0">
<Image
src={SITE_ICON_PATH}
src={siteIconSrc}
alt={siteConfig.name}
width={40}
height={40}
+4 -2
View File
@@ -3,11 +3,13 @@ import Link from 'next/link';
import Image from 'next/image';
import { Button } from '@/components/ui/Button';
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';
export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) {
const router = useRouter();
const siteIconSrc = useSiteIcon();
return (
<nav className="sticky top-0 z-50 pt-4 pb-2 px-4" style={{ transform: 'translateZ(0)' }}>
@@ -20,7 +22,7 @@ export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) {
title={isPremium ? "返回高级主页" : "返回首页"}
>
<Image
src={SITE_ICON_PATH}
src={siteIconSrc}
alt={siteConfig.name}
width={40}
height={40}
-2
View File
@@ -9,8 +9,6 @@ export interface SiteConfig {
name: string;
}
export const SITE_ICON_PATH = "/api/site-icon";
/**
* Site configuration object
* Uses environment variables with fallback to default values
+103
View File
@@ -0,0 +1,103 @@
import fs from 'fs';
import path from 'path';
export const DEFAULT_SITE_ICON_PATH = '/icon.png';
const LEGACY_SITE_ICON_ROUTE = '/api/site-icon';
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 normalizeIconUrl(iconUrl?: string | null): string | null {
const value = iconUrl?.trim();
if (!value) {
return null;
}
if (value === LEGACY_SITE_ICON_ROUTE) {
console.warn('[SiteIcon] Ignoring legacy /api/site-icon path.');
return null;
}
return value;
}
async function resolveIconFileAsDataUrl(iconFile: string): Promise<string | null> {
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<string> {
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;
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.9.0",
"version": "4.9.1",
"private": true,
"scripts": {
"dev": "next dev --port ${PORT:-3000}",
+1 -1
View File
@@ -9,7 +9,7 @@
"orientation": "any",
"icons": [
{
"src": "/api/site-icon",
"src": "/icon.png",
"purpose": "any maskable"
}
],