mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
fix(cloudflare): remove node-only site icon route
This commit is contained in:
+6
-1
@@ -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 判定以及全屏和画中画相关兼容性问题。
|
||||
|
||||
|
||||
@@ -391,7 +391,7 @@ NEXT_PUBLIC_SITE_DESCRIPTION=专属视频聚合播放平台
|
||||
|
||||
## Docker 图标自定义
|
||||
|
||||
Docker 预构建镜像支持在运行时替换图标,无需重新构建镜像。该配置会同时作用于顶部 Logo、浏览器 favicon 和 PWA 图标。
|
||||
Docker 预构建镜像支持在运行时替换图标,无需重新构建镜像。该配置会作用于顶部 Logo 和浏览器 favicon;如果你还要同步替换安装后的 PWA 图标,请直接覆盖仓库中的 `public/icon.png` 后重新构建镜像。
|
||||
|
||||
### 可用环境变量:
|
||||
|
||||
|
||||
+11
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.9.0",
|
||||
"version": "4.9.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port ${PORT:-3000}",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/api/site-icon",
|
||||
"src": "/icon.png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user