's'
This commit is contained in:
55
app/api/admin/stats/route.ts
Normal file
55
app/api/admin/stats/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getFileContent } from '@/lib/github'
|
||||
import { NavigationCategory } from '@/types/navigation'
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 动态读取最新的导航数据,而不是静态导入
|
||||
const navigationData = await getFileContent('navsphere/content/navigation.json')
|
||||
const navigationItems = navigationData.navigationItems || []
|
||||
|
||||
// 计算一级分类数量
|
||||
const parentCategories = navigationItems.length
|
||||
console.log('Parent categories:', parentCategories)
|
||||
|
||||
// 计算二级分类数量
|
||||
const subCategories = navigationItems.reduce((total: number, category: any) => {
|
||||
return total + (category.subCategories?.length || 0)
|
||||
}, 0)
|
||||
|
||||
// 计算总分类数量
|
||||
const totalCategories = parentCategories + subCategories
|
||||
|
||||
// 计算站点总数
|
||||
const totalSites = navigationItems.reduce((total: number, category: any) => {
|
||||
// 一级分类的站点
|
||||
const parentSites = category.items?.length || 0
|
||||
|
||||
// 二级分类的站点
|
||||
const subCategoriesSites = Array.isArray(category.subCategories)
|
||||
? (category.subCategories as NavigationCategory[]).reduce((sum, subCategory) => {
|
||||
return sum + (subCategory.items?.length || 0);
|
||||
}, 0)
|
||||
: 0;
|
||||
|
||||
return total + parentSites + subCategoriesSites
|
||||
}, 0)
|
||||
|
||||
const result = {
|
||||
parentCategories,
|
||||
subCategories,
|
||||
totalCategories,
|
||||
totalSites
|
||||
}
|
||||
|
||||
console.log('Sending stats:', result)
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch stats' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
20
app/api/auth/[...nextauth]/auth.ts
Normal file
20
app/api/auth/[...nextauth]/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { NextAuthConfig } from 'next-auth'
|
||||
import GithubProvider from 'next-auth/providers/github'
|
||||
|
||||
export const authConfig: NextAuthConfig = {
|
||||
providers: [
|
||||
GithubProvider({
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_SECRET!,
|
||||
authorization: {
|
||||
params: { scope: 'repo' }
|
||||
}
|
||||
})
|
||||
],
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
session: {
|
||||
strategy: 'jwt'
|
||||
}
|
||||
}
|
||||
|
||||
export default authConfig
|
||||
37
app/api/auth/[...nextauth]/config.ts
Normal file
37
app/api/auth/[...nextauth]/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { NextAuthConfig } from 'next-auth'
|
||||
import GitHub from 'next-auth/providers/github'
|
||||
import type { DefaultSession, JWT } from 'next-auth'
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
accessToken?: string
|
||||
} & DefaultSession['user']
|
||||
}
|
||||
}
|
||||
|
||||
export const authConfig: NextAuthConfig = {
|
||||
providers: [
|
||||
GitHub({
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_SECRET!,
|
||||
authorization: {
|
||||
params: { scope: 'repo' }
|
||||
}
|
||||
})
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, account }) {
|
||||
if (account?.access_token) {
|
||||
token.accessToken = account.access_token
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session?.user) {
|
||||
session.user.accessToken = token.accessToken as string
|
||||
}
|
||||
return session
|
||||
}
|
||||
}
|
||||
}
|
||||
4
app/api/auth/[...nextauth]/route.ts
Normal file
4
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { GET, POST } from '@/lib/auth'
|
||||
|
||||
export const runtime = 'edge'
|
||||
export { GET, POST }
|
||||
70
app/api/extension/[id]/route.ts
Normal file
70
app/api/extension/[id]/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { commitFile, getFileContent } from '@/lib/github'
|
||||
import type { NavigationData, NavigationItem } from '@/types/navigation'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const updatedItem: NavigationItem = await request.json()
|
||||
const data = await getFileContent('navsphere/content/navigation.json') as NavigationData
|
||||
|
||||
// 确保更新的导航项包含所有必需的字段
|
||||
const existingItem = data.navigationItems.find(item => item.id === id)
|
||||
if (!existingItem) {
|
||||
return new Response('Navigation item not found', { status: 404 })
|
||||
}
|
||||
|
||||
// 更新导航项,保持原有的 ID
|
||||
const mergedItem: NavigationItem = {
|
||||
...existingItem,
|
||||
...updatedItem,
|
||||
id: id,
|
||||
items: updatedItem.items || existingItem.items || [],
|
||||
subCategories: [
|
||||
...(
|
||||
[
|
||||
...(existingItem.subCategories || []),
|
||||
...(updatedItem.subCategories || [])
|
||||
].reduce((acc, sub) => {
|
||||
const exist = acc.get(sub.id);
|
||||
acc.set(sub.id, {
|
||||
...exist,
|
||||
...sub,
|
||||
items: [
|
||||
...(exist?.items || []),
|
||||
...(sub.items || [])
|
||||
] });
|
||||
return acc;
|
||||
}, new Map<string, NavigationItem>())
|
||||
).values()
|
||||
]
|
||||
}
|
||||
|
||||
const updatedItems = data.navigationItems.map(item =>
|
||||
item.id === id ? mergedItem : item
|
||||
)
|
||||
|
||||
await commitFile(
|
||||
'navsphere/content/navigation.json',
|
||||
JSON.stringify({ navigationItems: updatedItems }, null, 2),
|
||||
'Update navigation item',
|
||||
session.user.accessToken
|
||||
)
|
||||
|
||||
return NextResponse.json(mergedItem)
|
||||
} catch (error) {
|
||||
console.error('Update error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update navigation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
37
app/api/favicon-im/route.ts
Normal file
37
app/api/favicon-im/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const domain = searchParams.get('domain');
|
||||
const larger = searchParams.get('larger') === 'true';
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: '缺少有效的 domain 参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 构建 favicon.im 的 URL
|
||||
const faviconUrl = `https://favicon.im/zh/${domain}?larger=true' }`;
|
||||
|
||||
try {
|
||||
const response = await fetch(faviconUrl);
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: '无法访问该网站或未找到 Favicon' }, { status: response.status });
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const contentType = response.headers.get('content-type') || 'image/png';
|
||||
|
||||
return new NextResponse(arrayBuffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching favicon from favicon.im:', error);
|
||||
return NextResponse.json({ error: '发生错误,请重试。' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
32
app/api/favicon/route.ts
Normal file
32
app/api/favicon/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const domain = searchParams.get('domain');
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: '缺少有效的 domain 参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const faviconUrl = `https://www.google.com/s2/favicons?sz=128&domain=${domain}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(faviconUrl);
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: '无法访问该网站或未找到 Favicon' }, { status: response.status });
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return new NextResponse(arrayBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'image/x-icon',
|
||||
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching favicon:', error);
|
||||
return NextResponse.json({ error: '发生错误,请重试。' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
11
app/api/health/route.ts
Normal file
11
app/api/health/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
app:'NavSphere',
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
})
|
||||
}
|
||||
26
app/api/home/navigation/route.ts
Normal file
26
app/api/home/navigation/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import navigationData from '@/navsphere/content/navigation.json'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json(navigationData, {
|
||||
headers: {
|
||||
'Cache-Control': 's-maxage=3600, stale-while-revalidate',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in navigation API:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取导航数据失败' },
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
16
app/api/home/site/route.ts
Normal file
16
app/api/home/site/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import siteData from '@/navsphere/content/site.json'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json(siteData)
|
||||
} catch (error) {
|
||||
console.error('Error in site API:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取站点数据失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
62
app/api/navigation/[id]/categories/route.ts
Normal file
62
app/api/navigation/[id]/categories/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { commitFile, getFileContent } from '@/lib/github'
|
||||
import type { NavigationData, NavigationItem } from '@/types/navigation'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const { categoryId } = await request.json()
|
||||
if (!categoryId) {
|
||||
return NextResponse.json({ error: 'Category ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const data = await getFileContent('navsphere/content/navigation.json') as NavigationData
|
||||
|
||||
const navigation = data.navigationItems.find(nav => nav.id === id)
|
||||
if (!navigation) {
|
||||
return NextResponse.json({ error: 'Navigation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// 检查分类是否存在
|
||||
const categoryExists = navigation.subCategories?.some(cat => cat.id === categoryId)
|
||||
if (!categoryExists) {
|
||||
return NextResponse.json({ error: 'Category not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// 删除指定的分类
|
||||
const updatedSubCategories = navigation.subCategories?.filter(cat => cat.id !== categoryId) || []
|
||||
|
||||
const updatedNavigations = data.navigationItems.map(nav => {
|
||||
if (nav.id === id) {
|
||||
return {
|
||||
...nav,
|
||||
subCategories: updatedSubCategories
|
||||
}
|
||||
}
|
||||
return nav
|
||||
})
|
||||
|
||||
await commitFile(
|
||||
'navsphere/content/navigation.json',
|
||||
JSON.stringify({ navigationItems: updatedNavigations }, null, 2),
|
||||
`Delete category: ${categoryId}`,
|
||||
session.user.accessToken
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Delete category error:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete category' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
150
app/api/navigation/[id]/items/route.ts
Normal file
150
app/api/navigation/[id]/items/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { commitFile, getFileContent } from '@/lib/github'
|
||||
import type { NavigationData, NavigationItem, NavigationSubItem } from '@/types/navigation'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const data = await getFileContent('navsphere/content/navigation.json') as NavigationData
|
||||
const item = data.navigationItems.find(item => item.id === id)
|
||||
|
||||
if (!item) {
|
||||
return NextResponse.json({ error: 'Navigation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(item.items)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch items' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const newItem: NavigationSubItem = await request.json()
|
||||
const data = await getFileContent('navsphere/content/navigation.json') as NavigationData
|
||||
|
||||
const updatedItems = data.navigationItems.map(item => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
...item,
|
||||
items: [...(item.items || []), newItem]
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
await commitFile(
|
||||
'navsphere/content/navigation.json',
|
||||
JSON.stringify({ navigationItems: updatedItems }, null, 2),
|
||||
'Add navigation item',
|
||||
session.user.accessToken
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to add item' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const { index, item }: { index: number, item: NavigationSubItem } = await request.json()
|
||||
const data = await getFileContent('navsphere/content/navigation.json') as NavigationData
|
||||
|
||||
const navigation = data.navigationItems.find(nav => nav.id === id)
|
||||
if (!navigation) {
|
||||
return NextResponse.json({ error: 'Navigation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updatedItems = [...(navigation.items || [])]
|
||||
updatedItems[index] = item
|
||||
|
||||
const updatedNavigations = data.navigationItems.map(nav => {
|
||||
if (nav.id === id) {
|
||||
return {
|
||||
...nav,
|
||||
items: updatedItems
|
||||
}
|
||||
}
|
||||
return nav
|
||||
})
|
||||
|
||||
await commitFile(
|
||||
'navsphere/content/navigation.json',
|
||||
JSON.stringify({ navigationItems: updatedNavigations }, null, 2),
|
||||
'Update navigation item',
|
||||
session.user.accessToken
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to update item' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const { index } = await request.json()
|
||||
const data = await getFileContent('navsphere/content/navigation.json') as NavigationData
|
||||
|
||||
const navigation = data.navigationItems.find(nav => nav.id === id)
|
||||
if (!navigation) {
|
||||
return NextResponse.json({ error: 'Navigation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updatedItems = (navigation.items || []).filter((_, i) => i !== index)
|
||||
const updatedNavigations = data.navigationItems.map(nav => {
|
||||
if (nav.id === id) {
|
||||
return {
|
||||
...nav,
|
||||
items: updatedItems
|
||||
}
|
||||
}
|
||||
return nav
|
||||
})
|
||||
|
||||
await commitFile(
|
||||
'navsphere/content/navigation.json',
|
||||
JSON.stringify({ navigationItems: updatedNavigations }, null, 2),
|
||||
'Delete navigation item',
|
||||
session.user.accessToken
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to delete item' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
100
app/api/navigation/[id]/route.ts
Normal file
100
app/api/navigation/[id]/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { commitFile, getFileContent } from '@/lib/github'
|
||||
import type { NavigationData, NavigationItem } from '@/types/navigation'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const data = await getFileContent('navsphere/content/navigation.json') as NavigationData
|
||||
const item = data.navigationItems.find(item => item.id === id)
|
||||
|
||||
if (!item) {
|
||||
return new Response('Not Found', { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(item)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch navigation item' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const updatedItem: NavigationItem = await request.json()
|
||||
const data = await getFileContent('navsphere/content/navigation.json') as NavigationData
|
||||
|
||||
// 确保更新的导航项包含所有必需的字段
|
||||
const existingItem = data.navigationItems.find(item => item.id === id)
|
||||
if (!existingItem) {
|
||||
return new Response('Navigation item not found', { status: 404 })
|
||||
}
|
||||
|
||||
// 更新导航项,保持原有的 ID
|
||||
const mergedItem: NavigationItem = {
|
||||
...existingItem,
|
||||
...updatedItem,
|
||||
id: id,
|
||||
items: updatedItem.items || existingItem.items || [],
|
||||
subCategories: updatedItem.subCategories || existingItem.subCategories || []
|
||||
}
|
||||
|
||||
const updatedItems = data.navigationItems.map(item =>
|
||||
item.id === id ? mergedItem : item
|
||||
)
|
||||
|
||||
await commitFile(
|
||||
'navsphere/content/navigation.json',
|
||||
JSON.stringify({ navigationItems: updatedItems }, null, 2),
|
||||
'Update navigation item',
|
||||
session.user.accessToken
|
||||
)
|
||||
|
||||
return NextResponse.json(mergedItem)
|
||||
} catch (error) {
|
||||
console.error('Update error:', error)
|
||||
return NextResponse.json({ error: 'Failed to update navigation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const data = await getFileContent('navsphere/content/navigation.json') as NavigationData
|
||||
const updatedItems = data.navigationItems.filter(item => item.id !== id)
|
||||
|
||||
await commitFile(
|
||||
'navsphere/content/navigation.json',
|
||||
JSON.stringify({ navigationItems: updatedItems }, null, 2),
|
||||
'Delete navigation item',
|
||||
session.user.accessToken
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete navigation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
48
app/api/navigation/check-default/route.ts
Normal file
48
app/api/navigation/check-default/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { getFileContent } from '@/lib/github'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const defaultData = await getFileContent('navsphere/content/navigation-default.json')
|
||||
|
||||
// 验证文件格式
|
||||
const isValid = defaultData &&
|
||||
typeof defaultData === 'object' &&
|
||||
Array.isArray(defaultData.navigationItems)
|
||||
|
||||
return NextResponse.json({
|
||||
exists: true,
|
||||
valid: isValid,
|
||||
itemCount: isValid ? defaultData.navigationItems.length : 0
|
||||
})
|
||||
} catch (error) {
|
||||
// 文件不存在
|
||||
if ((error as Error).message.includes('404') || (error as Error).message.includes('not found')) {
|
||||
return NextResponse.json({
|
||||
exists: false,
|
||||
valid: false,
|
||||
itemCount: 0
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check default file:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to check default file',
|
||||
details: (error as Error).message
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
53
app/api/navigation/reorder/route.ts
Normal file
53
app/api/navigation/reorder/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { commitFile, getFileContent } from '@/lib/github'
|
||||
import type { NavigationData, NavigationItem } from '@/types/navigation'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const { sourceIndex, destinationIndex, itemId } = await request.json()
|
||||
|
||||
// 获取当前导航数据
|
||||
const data = await getFileContent('navsphere/content/navigation.json') as NavigationData
|
||||
|
||||
// 确保导航项存在
|
||||
if (!data.navigationItems || !Array.isArray(data.navigationItems)) {
|
||||
throw new Error('无效的导航数据')
|
||||
}
|
||||
|
||||
// 创建新的导航项数组副本
|
||||
const updatedItems = [...data.navigationItems]
|
||||
|
||||
// 找到要移动的项目
|
||||
const [movedItem] = updatedItems.splice(sourceIndex, 1)
|
||||
|
||||
// 将项目插入到新位置
|
||||
updatedItems.splice(destinationIndex, 0, movedItem)
|
||||
|
||||
// 更新数据
|
||||
data.navigationItems = updatedItems
|
||||
|
||||
// 提交更改到 GitHub
|
||||
await commitFile(
|
||||
'navsphere/content/navigation.json',
|
||||
JSON.stringify(data, null, 2),
|
||||
`重新排序导航项 - ${new Date().toISOString()}`,
|
||||
session.user.accessToken
|
||||
)
|
||||
|
||||
return NextResponse.json(data.navigationItems, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('重新排序导航项错误:', error)
|
||||
return NextResponse.json({
|
||||
error: '重新排序导航项失败',
|
||||
details: (error as Error).message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
61
app/api/navigation/restore/route.ts
Normal file
61
app/api/navigation/restore/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { commitFile, getFileContent } from '@/lib/github'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
// 检查默认数据文件是否存在
|
||||
try {
|
||||
const defaultData = await getFileContent('navsphere/content/navigation-default.json')
|
||||
|
||||
// 验证默认数据格式
|
||||
if (!defaultData || typeof defaultData !== 'object' || !defaultData.navigationItems) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid default data format',
|
||||
details: 'navigation-default.json does not contain valid navigation data'
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 将默认数据写入到navigation.json
|
||||
await commitFile(
|
||||
'navsphere/content/navigation.json',
|
||||
JSON.stringify(defaultData, null, 2),
|
||||
'Restore navigation data to default',
|
||||
session.user.accessToken
|
||||
)
|
||||
|
||||
return NextResponse.json(defaultData)
|
||||
} catch (fileError) {
|
||||
// 检查是否是文件不存在的错误
|
||||
if ((fileError as Error).message.includes('404') || (fileError as Error).message.includes('not found')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Default data file not found',
|
||||
details: 'navigation-default.json file does not exist in the repository'
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
throw fileError
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore navigation data:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to restore navigation data',
|
||||
details: (error as Error).message
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
106
app/api/navigation/route.ts
Normal file
106
app/api/navigation/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { commitFile, getFileContent } from '@/lib/github'
|
||||
import type { NavigationData, NavigationItem } from '@/types/navigation'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await getFileContent('navsphere/content/navigation.json')
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch navigation data:', error)
|
||||
// 返回默认数据结构
|
||||
return NextResponse.json({
|
||||
navigationItems: []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAndSaveNavigationData(data: any, accessToken: string) {
|
||||
// 详细的数据结构验证和日志
|
||||
console.log('Received navigation data:', JSON.stringify(data, null, 2))
|
||||
|
||||
// 严格验证数据结构
|
||||
if (!data || typeof data !== 'object') {
|
||||
console.error('Invalid data: not an object')
|
||||
throw new Error('Invalid navigation data: not an object')
|
||||
}
|
||||
|
||||
if (!('navigationItems' in data)) {
|
||||
console.error('Missing navigationItems key')
|
||||
throw new Error('Invalid navigation data: missing navigationItems')
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.navigationItems)) {
|
||||
console.error('navigationItems is not an array', typeof data.navigationItems)
|
||||
throw new Error('Invalid navigation data: navigationItems must be an array')
|
||||
}
|
||||
|
||||
// 额外的数据验证
|
||||
const invalidItems = data.navigationItems.filter((item: NavigationItem) =>
|
||||
!item.id ||
|
||||
!item.title ||
|
||||
(item.items && !Array.isArray(item.items)) ||
|
||||
(item.subCategories && !Array.isArray(item.subCategories))
|
||||
)
|
||||
|
||||
if (invalidItems.length > 0) {
|
||||
console.error('Invalid navigation items:', invalidItems)
|
||||
throw new Error('Invalid navigation data: some items are malformed')
|
||||
}
|
||||
|
||||
await commitFile(
|
||||
'navsphere/content/navigation.json',
|
||||
JSON.stringify(data, null, 2),
|
||||
'Update navigation data',
|
||||
accessToken
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const data = await request.json()
|
||||
await validateAndSaveNavigationData(data, session.user.accessToken)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to save navigation data:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to save navigation data',
|
||||
details: (error as Error).message
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const data = await request.json()
|
||||
await validateAndSaveNavigationData(data, session.user.accessToken)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to update navigation data:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to update navigation data',
|
||||
details: (error as Error).message
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
105
app/api/resource/check-references/route.ts
Normal file
105
app/api/resource/check-references/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getFileContent } from '@/lib/github'
|
||||
import type { NavigationData } from '@/types/navigation'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { resourcePaths } = await request.json()
|
||||
|
||||
if (!Array.isArray(resourcePaths)) {
|
||||
return NextResponse.json({ error: 'Invalid resource paths' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 获取导航数据
|
||||
const navigationData = await getFileContent('navsphere/content/navigation.json') as NavigationData
|
||||
|
||||
// 获取站点配置数据
|
||||
const siteData = await getFileContent('navsphere/content/site.json') as any
|
||||
|
||||
const references: Record<string, Array<{ type: string; location: string; title?: string }>> = {}
|
||||
|
||||
// 检查每个资源路径的引用
|
||||
for (const resourcePath of resourcePaths) {
|
||||
references[resourcePath] = []
|
||||
|
||||
// 检查导航数据中的引用
|
||||
if (navigationData?.navigationItems) {
|
||||
for (const navItem of navigationData.navigationItems) {
|
||||
// 检查主导航项的图标
|
||||
if (navItem.icon === resourcePath) {
|
||||
references[resourcePath].push({
|
||||
type: 'navigation',
|
||||
location: `导航项: ${navItem.title}`,
|
||||
title: navItem.title
|
||||
})
|
||||
}
|
||||
|
||||
// 检查导航项中的子项
|
||||
if (navItem.items) {
|
||||
for (const subItem of navItem.items) {
|
||||
if (subItem.icon === resourcePath) {
|
||||
references[resourcePath].push({
|
||||
type: 'navigation',
|
||||
location: `导航子项: ${navItem.title} > ${subItem.title}`,
|
||||
title: subItem.title
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查子分类
|
||||
if (navItem.subCategories) {
|
||||
for (const subCategory of navItem.subCategories) {
|
||||
if (subCategory.icon === resourcePath) {
|
||||
references[resourcePath].push({
|
||||
type: 'navigation',
|
||||
location: `导航分类: ${navItem.title} > ${subCategory.title}`,
|
||||
title: subCategory.title
|
||||
})
|
||||
}
|
||||
|
||||
// 检查子分类中的项目
|
||||
if (subCategory.items) {
|
||||
for (const item of subCategory.items) {
|
||||
if (item.icon === resourcePath) {
|
||||
references[resourcePath].push({
|
||||
type: 'navigation',
|
||||
location: `导航项: ${navItem.title} > ${subCategory.title} > ${item.title}`,
|
||||
title: item.title
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查站点配置中的引用
|
||||
if (siteData?.appearance) {
|
||||
if (siteData.appearance.logo === resourcePath) {
|
||||
references[resourcePath].push({
|
||||
type: 'site',
|
||||
location: '站点Logo',
|
||||
title: '站点Logo'
|
||||
})
|
||||
}
|
||||
|
||||
if (siteData.appearance.favicon === resourcePath) {
|
||||
references[resourcePath].push({
|
||||
type: 'site',
|
||||
location: '站点图标',
|
||||
title: '站点图标'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ references })
|
||||
} catch (error) {
|
||||
console.error('Failed to check resource references:', error)
|
||||
return NextResponse.json({ error: 'Failed to check resource references' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
142
app/api/resource/route.ts
Normal file
142
app/api/resource/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { commitFile, getFileContent } from '@/lib/github'
|
||||
import type { ResourceMetadata } from '@/types/resource-metadata'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await getFileContent('navsphere/content/resource-metadata.json') as ResourceMetadata
|
||||
if (!data?.metadata || !Array.isArray(data.metadata)) {
|
||||
throw new Error('Invalid data structure');
|
||||
}
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch resource metadata:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch resource metadata' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const { image } = await request.json(); // Get the Base64 image
|
||||
const base64Data = image.split(",")[1]; // Extract the Base64 part
|
||||
const binaryData = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)); // Convert Base64 to binary
|
||||
|
||||
// 获取上传结果,包含路径和 commit hash
|
||||
const { path: imageUrl, commitHash } = await uploadImageToGitHub(binaryData, session.user.accessToken);
|
||||
|
||||
// Handle metadata
|
||||
const metadata = await getFileContent('navsphere/content/resource-metadata.json') as ResourceMetadata;
|
||||
metadata.metadata.unshift({
|
||||
commit: commitHash, // 使用实际的 commit hash
|
||||
hash: commitHash, // 使用相同的 hash 作为资源标识
|
||||
path: imageUrl
|
||||
});
|
||||
|
||||
await commitFile(
|
||||
'navsphere/content/resource-metadata.json',
|
||||
JSON.stringify(metadata, null, 2),
|
||||
'Update resource metadata',
|
||||
session.user.accessToken
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, imageUrl });
|
||||
} catch (error) {
|
||||
console.error('Failed to save resource metadata:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to save resource metadata' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to upload image to GitHub
|
||||
async function uploadImageToGitHub(binaryData: Uint8Array, token: string): Promise<{ path: string, commitHash: string }> {
|
||||
const owner = process.env.GITHUB_OWNER!;
|
||||
const repo = process.env.GITHUB_REPO!;
|
||||
const branch = process.env.GITHUB_BRANCH || 'main'
|
||||
const path = `/assets/img_${Date.now()}.png`; // Generate a unique path for the image
|
||||
const githubPath = 'public'+path;
|
||||
|
||||
// Convert Uint8Array to Base64
|
||||
const base64String = Buffer.from(binaryData).toString('base64'); // Use Buffer to convert to Base64
|
||||
const currentFileUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${githubPath}?ref=${branch}`
|
||||
// Use fetch to upload the file to GitHub
|
||||
const response = await fetch(currentFileUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: `Upload ${githubPath}`,
|
||||
content: base64String, // Send only the Base64 string
|
||||
branch: branch, // Explicitly specify the branch
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Failed to upload image to GitHub:', errorData);
|
||||
throw new Error(`Failed to upload image to GitHub: ${errorData.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const commitHash = responseData.commit.sha; // 获取 commit hash
|
||||
|
||||
return { path, commitHash }; // Return the URL of the uploaded image
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const { resourceHashes } = await request.json();
|
||||
|
||||
if (!Array.isArray(resourceHashes) || resourceHashes.length === 0) {
|
||||
return NextResponse.json({ error: 'Invalid resource hashes' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取当前的资源元数据
|
||||
const metadata = await getFileContent('navsphere/content/resource-metadata.json') as ResourceMetadata;
|
||||
|
||||
// 过滤掉要删除的资源
|
||||
const originalCount = metadata.metadata.length;
|
||||
metadata.metadata = metadata.metadata.filter(item => !resourceHashes.includes(item.hash));
|
||||
const deletedCount = originalCount - metadata.metadata.length;
|
||||
|
||||
// 更新资源元数据文件
|
||||
await commitFile(
|
||||
'navsphere/content/resource-metadata.json',
|
||||
JSON.stringify(metadata, null, 2),
|
||||
`Delete ${deletedCount} resource(s)`,
|
||||
session.user.accessToken
|
||||
);
|
||||
|
||||
// 注意:这里只是从元数据中删除了引用,实际的图片文件仍然存在于GitHub仓库中
|
||||
// 如果需要删除实际文件,需要额外的GitHub API调用
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedCount,
|
||||
message: `成功删除 ${deletedCount} 个资源`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete resources:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to delete resources' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
app/api/site-config/route.ts
Normal file
30
app/api/site-config/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { SiteConfig } from '@/types/site'
|
||||
export const runtime = 'edge'
|
||||
|
||||
const defaultConfig: SiteConfig = {
|
||||
basic: {
|
||||
title: 'NavSphere',
|
||||
description: 'A modern navigation platform',
|
||||
keywords: 'navigation, platform, web, management'
|
||||
},
|
||||
appearance: {
|
||||
logo: '/logo.png',
|
||||
favicon: '/favicon.ico',
|
||||
theme: 'system'
|
||||
},
|
||||
navigation: {
|
||||
linkTarget: '_blank'
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// TODO: 从数据库或文件系统获取配置
|
||||
// 现在先返回默认配置
|
||||
return NextResponse.json(defaultConfig)
|
||||
} catch (error) {
|
||||
console.error('Error fetching site config:', error)
|
||||
return NextResponse.json(defaultConfig)
|
||||
}
|
||||
}
|
||||
57
app/api/site/route.ts
Normal file
57
app/api/site/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { commitFile, getFileContent } from '@/lib/github'
|
||||
import type { SiteInfo } from '@/types/site'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await getFileContent('navsphere/content/site.json') as SiteInfo
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to read site data:', error)
|
||||
return NextResponse.json({
|
||||
basic: {
|
||||
title: '',
|
||||
description: '',
|
||||
keywords: ''
|
||||
},
|
||||
appearance: {
|
||||
logo: '',
|
||||
favicon: '',
|
||||
theme: 'system'
|
||||
},
|
||||
navigation: {
|
||||
linkTarget: '_blank'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const data: SiteInfo = await request.json()
|
||||
|
||||
// 提交到 GitHub
|
||||
await commitFile(
|
||||
'navsphere/content/site.json',
|
||||
JSON.stringify(data, null, 2),
|
||||
'Update site configuration',
|
||||
session.user.accessToken
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to save site data:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save site data' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
349
app/api/website-metadata/route.ts
Normal file
349
app/api/website-metadata/route.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
interface WebsiteMetadata {
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.accessToken) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const { url } = await request.json()
|
||||
|
||||
if (!url || !isValidUrl(url)) {
|
||||
return NextResponse.json({ error: '请提供有效的网站链接' }, { status: 400 })
|
||||
}
|
||||
|
||||
const metadata = await fetchWebsiteMetadata(url)
|
||||
|
||||
// 确保 metadata 对象包含所有必需的属性
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
throw new Error('Failed to fetch valid metadata')
|
||||
}
|
||||
|
||||
// 如果获取到了 favicon,下载并上传到 GitHub
|
||||
if (metadata.icon) {
|
||||
try {
|
||||
const iconUrl = await downloadAndUploadIcon(metadata.icon, session.user.accessToken)
|
||||
metadata.icon = iconUrl
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to download icon:', error)
|
||||
// 如果图标下载失败,尝试使用 Google favicon 服务
|
||||
try {
|
||||
const domain = new URL(url).hostname
|
||||
const fallbackIconUrl = await downloadGoogleFavicon(domain, session.user.accessToken)
|
||||
metadata.icon = fallbackIconUrl
|
||||
} catch (fallbackError) {
|
||||
console.warn('Failed to download Google favicon:', fallbackError)
|
||||
// 保持原始 URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(metadata)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch website metadata:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '获取网站信息失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function isValidUrl(string: string): boolean {
|
||||
try {
|
||||
new URL(string)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWebsiteMetadata(url: string): Promise<WebsiteMetadata> {
|
||||
try {
|
||||
const headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate, br, zstd',
|
||||
'Cache-Control': 'max-age=0',
|
||||
'Sec-Ch-Ua': '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"',
|
||||
'Sec-Ch-Ua-Mobile': '?0',
|
||||
'Sec-Ch-Ua-Platform': '"macOS"',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Upgrade-Insecure-Requests': '1'
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: headers,
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(1500)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const html = await response.text()
|
||||
return parseMetadataFromHtml(html, url)
|
||||
} else if (response.status === 403) {
|
||||
console.warn(`网站拒绝访问 (403): 该网站可能阻止了自动化访问`)
|
||||
return getFallbackMetadata(url)
|
||||
} else if (response.status === 404) {
|
||||
return getFallbackMetadata(url)
|
||||
} else if (response.status >= 500) {
|
||||
return getFallbackMetadata(url)
|
||||
} else {
|
||||
console.warn(`无法访问网站: ${response.status}`)
|
||||
return getFallbackMetadata(url)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
console.warn('请求超时,网站响应过慢')
|
||||
} else {
|
||||
console.warn('获取网站元数据失败:', error)
|
||||
}
|
||||
return getFallbackMetadata(url)
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackMetadata(url: string): WebsiteMetadata {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const hostname = urlObj.hostname
|
||||
|
||||
// 生成基本的网站信息
|
||||
const title = hostname.replace(/^www\./, '').split('.')[0]
|
||||
const capitalizedTitle = title.charAt(0).toUpperCase() + title.slice(1)
|
||||
|
||||
return {
|
||||
title: capitalizedTitle,
|
||||
description: `访问 ${hostname}`,
|
||||
icon: `https://www.google.com/s2/favicons?sz=128&domain=${hostname}`
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
title: '未知网站',
|
||||
description: '无法获取网站信息',
|
||||
icon: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseMetadataFromHtml(html: string, url: string): WebsiteMetadata {
|
||||
|
||||
// 解析 HTML 获取元数据
|
||||
const title = extractMetaContent(html, 'title') ||
|
||||
extractMetaContent(html, 'og:title') ||
|
||||
extractMetaContent(html, 'twitter:title') ||
|
||||
new URL(url).hostname
|
||||
|
||||
const description = extractMetaContent(html, 'description') ||
|
||||
extractMetaContent(html, 'og:description') ||
|
||||
extractMetaContent(html, 'twitter:description') ||
|
||||
''
|
||||
|
||||
// 获取 favicon
|
||||
let icon = extractFavicon(html, url)
|
||||
|
||||
return {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
icon: icon || ''
|
||||
}
|
||||
}
|
||||
|
||||
function extractMetaContent(html: string, name: string): string | null {
|
||||
// 匹配 title 标签
|
||||
if (name === 'title') {
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i)
|
||||
return titleMatch ? titleMatch[1] : null
|
||||
}
|
||||
|
||||
// 匹配 meta 标签
|
||||
const patterns = [
|
||||
new RegExp(`<meta[^>]*name=["']${name}["'][^>]*content=["']([^"']*)["']`, 'i'),
|
||||
new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*name=["']${name}["']`, 'i'),
|
||||
new RegExp(`<meta[^>]*property=["']${name}["'][^>]*content=["']([^"']*)["']`, 'i'),
|
||||
new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']${name}["']`, 'i')
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern)
|
||||
if (match) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractFavicon(html: string, baseUrl: string): string | null {
|
||||
const base = new URL(baseUrl)
|
||||
|
||||
// 尝试从 HTML 中提取 favicon
|
||||
const faviconPatterns = [
|
||||
/<link[^>]*rel=["']icon["'][^>]*href=["']([^"']*)["']/i,
|
||||
/<link[^>]*href=["']([^"']*)["'][^>]*rel=["']icon["']/i,
|
||||
/<link[^>]*rel=["']shortcut icon["'][^>]*href=["']([^"']*)["']/i,
|
||||
/<link[^>]*href=["']([^"']*)["'][^>]*rel=["']shortcut icon["']/i,
|
||||
/<link[^>]*rel=["']apple-touch-icon["'][^>]*href=["']([^"']*)["']/i,
|
||||
/<link[^>]*href=["']([^"']*)["'][^>]*rel=["']apple-touch-icon["']/i
|
||||
]
|
||||
|
||||
for (const pattern of faviconPatterns) {
|
||||
const match = html.match(pattern)
|
||||
if (match) {
|
||||
const href = match[1]
|
||||
if (href.startsWith('http')) {
|
||||
return href
|
||||
} else if (href.startsWith('//')) {
|
||||
return base.protocol + href
|
||||
} else if (href.startsWith('/')) {
|
||||
return base.origin + href
|
||||
} else {
|
||||
return base.origin + '/' + href
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到,使用 Google 的 favicon 服务作为备用
|
||||
return `https://www.google.com/s2/favicons?sz=128&domain=${base.hostname}`
|
||||
}
|
||||
|
||||
async function downloadGoogleFavicon(domain: string, token: string): Promise<string> {
|
||||
const googleFaviconUrl = `https://www.google.com/s2/favicons?sz=128&domain=${domain}`
|
||||
|
||||
try {
|
||||
const response = await fetch(googleFaviconUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
'Accept': 'image/*,*/*'
|
||||
},
|
||||
signal: AbortSignal.timeout(10000)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const binaryData = new Uint8Array(arrayBuffer)
|
||||
const { path } = await uploadImageToGitHub(binaryData, token, 'png')
|
||||
return path
|
||||
} else {
|
||||
throw new Error(`Failed to download Google favicon: ${response.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Google favicon download failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndUploadIcon(iconUrl: string, token: string): Promise<string> {
|
||||
// 多种策略尝试下载favicon
|
||||
const strategies: Array<{ headers: HeadersInit; delay?: number }> = [
|
||||
// 策略1: 完整浏览器模拟
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate, br, zstd',
|
||||
'Referer': new URL(iconUrl).origin + '/',
|
||||
'Sec-Ch-Ua': '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"',
|
||||
'Sec-Ch-Ua-Mobile': '?0',
|
||||
'Sec-Ch-Ua-Platform': '"macOS"',
|
||||
'Sec-Fetch-Dest': 'image',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Site': 'same-origin'
|
||||
},
|
||||
delay: 1000
|
||||
}
|
||||
]
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (const strategy of strategies) {
|
||||
try {
|
||||
const response = await fetch(iconUrl, {
|
||||
headers: strategy.headers,
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(15000)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const binaryData = new Uint8Array(arrayBuffer)
|
||||
|
||||
// 上传到 GitHub
|
||||
const { path } = await uploadImageToGitHub(binaryData, token, getFileExtension(iconUrl))
|
||||
return path
|
||||
} else {
|
||||
lastError = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
console.warn(`Strategy failed with status ${response.status}, trying next strategy...`)
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
console.warn(`Strategy failed with error:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有策略都失败了,抛出最后一个错误
|
||||
throw lastError || new Error('All download strategies failed')
|
||||
}
|
||||
|
||||
function getFileExtension(url: string): string {
|
||||
try {
|
||||
const pathname = new URL(url).pathname
|
||||
const extension = pathname.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (extension && ['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico'].includes(extension)) {
|
||||
return extension
|
||||
}
|
||||
return 'png' // 默认扩展名
|
||||
} catch {
|
||||
return 'png'
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadImageToGitHub(binaryData: Uint8Array, token: string, extension: string = 'png'): Promise<{ path: string, commitHash: string }> {
|
||||
const owner = process.env.GITHUB_OWNER!
|
||||
const repo = process.env.GITHUB_REPO!
|
||||
const branch = process.env.GITHUB_BRANCH || 'main'
|
||||
const path = `/assets/favicon_${Date.now()}.${extension}`
|
||||
const githubPath = 'public' + path
|
||||
|
||||
// Convert Uint8Array to Base64
|
||||
const base64String = Buffer.from(binaryData).toString('base64')
|
||||
const currentFileUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${githubPath}?ref=${branch}`
|
||||
|
||||
const response = await fetch(currentFileUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: `Upload favicon ${githubPath}`,
|
||||
content: base64String,
|
||||
branch: branch,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
console.error('Failed to upload image to GitHub:', errorData)
|
||||
throw new Error(`Failed to upload image to GitHub: ${errorData.message || 'Unknown error'}`)
|
||||
}
|
||||
|
||||
const responseData = await response.json()
|
||||
const commitHash = responseData.commit.sha
|
||||
|
||||
return { path, commitHash }
|
||||
}
|
||||
Reference in New Issue
Block a user