fix(docker): make the site icon configurable at runtime

Add SITE_ICON_FILE and SITE_ICON_URL support for the Docker image, route the app shell and manifest through a runtime icon endpoint, document the new Docker branding flow, and bump the app version to 4.8.3.
This commit is contained in:
kuekhaoyang
2026-04-12 11:26:57 +08:00
parent e2d7549a70
commit 727fc2b0ed
9 changed files with 175 additions and 24 deletions
+40 -9
View File
@@ -378,15 +378,6 @@ docker run -d -p 3000:3000 \
- 变量名:`NEXT_PUBLIC_SITE_NAME`
- 变量值:`我的视频平台`
**Docker 部署:**
```bash
docker run -d -p 3000:3000 \
-e NEXT_PUBLIC_SITE_NAME="我的视频平台" \
-e NEXT_PUBLIC_SITE_TITLE="我的视频 - 聚合播放平台" \
-e NEXT_PUBLIC_SITE_DESCRIPTION="专属视频聚合播放平台" \
--name kvideo kuekhaoyang/kvideo:latest
```
**本地开发:**
在项目根目录创建 `.env.local` 文件:
```env
@@ -395,6 +386,44 @@ NEXT_PUBLIC_SITE_TITLE=我的视频 - 聚合播放平台
NEXT_PUBLIC_SITE_DESCRIPTION=专属视频聚合播放平台
```
> [!NOTE]
> `NEXT_PUBLIC_SITE_*` 属于构建时变量。直接运行 Docker Hub 的预构建镜像时,`docker run -e NEXT_PUBLIC_SITE_* ...` 不会覆盖已经打包进前端的文案。
## Docker 图标自定义
Docker 预构建镜像支持在运行时替换图标,无需重新构建镜像。该配置会同时作用于顶部 Logo、浏览器 favicon 和 PWA 图标。
### 可用环境变量:
| 变量名 | 说明 |
|--------|------|
| `SITE_ICON_FILE` | 从容器内文件路径读取图标,适合 Docker 挂载,优先级高于 `SITE_ICON_URL` |
| `SITE_ICON_URL` | 直接使用外部 URL 或站内路径作为图标 |
### 配置示例:
**Docker 挂载文件(推荐):**
```bash
docker run -d -p 3000:3000 \
-v /path/to/icon.png:/app/custom/icon.png:ro \
-e SITE_ICON_FILE=/app/custom/icon.png \
--name kvideo kuekhaoyang/kvideo:latest
```
**Docker 使用 URL**
```bash
docker run -d -p 3000:3000 \
-e SITE_ICON_URL="https://example.com/icon.png" \
--name kvideo kuekhaoyang/kvideo:latest
```
**Docker 使用站内路径:**
```bash
docker run -d -p 3000:3000 \
-e SITE_ICON_URL="/placeholder-poster.svg" \
--name kvideo kuekhaoyang/kvideo:latest
```
## 自动订阅源配置
可以通过环境变量自动配置订阅源,应用启动时会自动加载并设置为自动更新。
@@ -632,6 +661,8 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` |
| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` |
| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` |
| `SITE_ICON_FILE` | Docker 运行时图标文件路径(优先于 `SITE_ICON_URL` | - |
| `SITE_ICON_URL` | Docker 运行时图标 URL 或站内路径 | - |
| `SUBSCRIPTION_SOURCES` | 自动订阅源配置(服务端) | - |
| `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` | 自动订阅源配置(客户端) | - |
| `IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` | IPTV 直播源配置 | - |
+118
View File
@@ -0,0 +1,118 @@
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;
+3 -3
View File
@@ -8,7 +8,7 @@ import { TVNavigationInitializer } from "@/components/TVNavigationInitializer";
import { Analytics } from "@vercel/analytics/react";
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
import { PasswordGate } from "@/components/PasswordGate";
import { siteConfig } from "@/lib/config/site-config";
import { siteConfig, SITE_ICON_PATH } from "@/lib/config/site-config";
import { AdKeywordsInjector } from "@/components/AdKeywordsInjector";
import { BackToTop } from "@/components/ui/BackToTop";
import { ScrollPositionManager } from "@/components/ScrollPositionManager";
@@ -65,7 +65,7 @@ export const metadata: Metadata = {
title: siteConfig.title,
description: siteConfig.description,
icons: {
icon: '/icon.png',
icon: SITE_ICON_PATH,
},
};
@@ -89,7 +89,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="/icon.png" />
<link rel="apple-touch-icon" href={SITE_ICON_PATH} />
{/* Theme Color (for browser address bar) */}
<meta name="theme-color" content="#000000" />
{/* Mobile viewport */}
+3 -2
View File
@@ -5,7 +5,7 @@ import Link from 'next/link';
import Image from 'next/image';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { Icons } from '@/components/ui/Icon';
import { siteConfig } from '@/lib/config/site-config';
import { siteConfig, SITE_ICON_PATH } 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';
@@ -45,10 +45,11 @@ 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="/icon.png"
src={SITE_ICON_PATH}
alt={siteConfig.name}
width={40}
height={40}
unoptimized
className="object-contain"
/>
</div>
+3 -2
View File
@@ -4,7 +4,7 @@ import Image from 'next/image';
import { Button } from '@/components/ui/Button';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { Icons } from '@/components/ui/Icon';
import { siteConfig } from '@/lib/config/site-config';
import { siteConfig, SITE_ICON_PATH } from '@/lib/config/site-config';
export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) {
const router = useRouter();
@@ -20,10 +20,11 @@ export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) {
title={isPremium ? "返回高级主页" : "返回首页"}
>
<Image
src="/icon.png"
src={SITE_ICON_PATH}
alt={siteConfig.name}
width={40}
height={40}
unoptimized
className="object-contain"
/>
</button>
+3 -1
View File
@@ -9,6 +9,8 @@ export interface SiteConfig {
name: string;
}
export const SITE_ICON_PATH = "/api/site-icon";
/**
* Site configuration object
* Uses environment variables with fallback to default values
@@ -18,4 +20,4 @@ export const siteConfig: SiteConfig = {
title: process.env.NEXT_PUBLIC_SITE_TITLE || "KVideo - 视频聚合平台",
description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION || "视频聚合平台",
name: process.env.NEXT_PUBLIC_SITE_NAME || "KVideo",
};
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "4.8.2",
"version": "4.8.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.8.2",
"version": "4.8.3",
"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.8.2",
"version": "4.8.3",
"private": true,
"scripts": {
"dev": "next dev --port ${PORT:-3000}",
+2 -4
View File
@@ -9,9 +9,7 @@
"orientation": "any",
"icons": [
{
"src": "/icon.png",
"sizes": "512x512",
"type": "image/png",
"src": "/api/site-icon",
"purpose": "any maskable"
}
],
@@ -23,4 +21,4 @@
"dir": "ltr",
"scope": "/",
"prefer_related_applications": false
}
}