This commit is contained in:
eric
2025-11-13 05:05:53 +08:00
parent cbdc65f56e
commit 891af61146
691 changed files with 83952 additions and 0 deletions

View 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 }
)
}
}

View 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

View 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
}
}
}

View File

@@ -0,0 +1,4 @@
import { GET, POST } from '@/lib/auth'
export const runtime = 'edge'
export { GET, POST }

View 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 })
}
}

View 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
View 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
View 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()
})
}

View 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'
}
}
)
}
}

View 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 }
)
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 }
)
}
}

View 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 })
}
}

View 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
View 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 }
)
}
}

View 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
View 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 }
);
}
}

View 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
View 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 }
)
}
}

View 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 }
}