admin/app/api/download-upload/route.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

94 lines
2.6 KiB
TypeScript

import { type NextRequest, NextResponse } from 'next/server'
const guessFileName = (imageUrl: string) => {
try {
const pathname = new URL(imageUrl).pathname
const base = pathname.split('/').filter(Boolean).at(-1)
if (base && /\.[a-z0-9]{2,5}$/i.test(base)) return decodeURIComponent(base)
} catch {
// ignore
}
return `chat-image-${Date.now()}.jpg`
}
const resolveAllowedUploadOrigins = (): string[] => {
const origins = new Set<string>()
const fileServer = process.env.NEXT_PUBLIC_FILE_SERVER_URL?.trim()
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim()
const apiUrl = process.env.NEXT_PUBLIC_API_URL?.trim()
for (const value of [fileServer, siteUrl, apiUrl]) {
if (!value) continue
try {
origins.add(new URL(value).origin)
} catch {
// ignore invalid env URLs
}
}
origins.add('https://ghabilee.ir')
origins.add('https://www.ghabilee.ir')
origins.add('https://dev.ghabilee.ir')
return [...origins]
}
/**
* Same-origin download proxy for chat/upload images.
* Needed when the page origin (e.g. localhost:3008) differs from the file host
* (ghabilee.ir) and the CDN has not yet emitted Access-Control-Allow-Origin.
*/
export async function GET(request: NextRequest) {
const rawUrl = request.nextUrl.searchParams.get('url')?.trim()
if (!rawUrl) {
return NextResponse.json({ message: 'url is required' }, { status: 400 })
}
let target: URL
try {
target = new URL(rawUrl)
} catch {
return NextResponse.json({ message: 'invalid url' }, { status: 400 })
}
if (target.protocol !== 'https:' && target.protocol !== 'http:') {
return NextResponse.json({ message: 'unsupported protocol' }, { status: 400 })
}
const allowed = resolveAllowedUploadOrigins()
if (!allowed.includes(target.origin)) {
return NextResponse.json({ message: 'url host is not allowed' }, { status: 403 })
}
if (!target.pathname.startsWith('/uploads/')) {
return NextResponse.json({ message: 'only /uploads paths are allowed' }, { status: 403 })
}
const upstream = await fetch(target.toString(), {
headers: { Accept: 'image/*,*/*' },
redirect: 'error',
})
if (!upstream.ok) {
return NextResponse.json({ message: 'upstream fetch failed' }, { status: 502 })
}
const contentType = upstream.headers.get('content-type') || 'application/octet-stream'
const fileName = guessFileName(target.toString())
const bytes = await upstream.arrayBuffer()
return new NextResponse(bytes, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${fileName}"`,
'Cache-Control': 'private, no-store',
},
})
}