mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-07-26 20:09:57 +00:00
158 lines
5.7 KiB
TypeScript
Executable File
158 lines
5.7 KiB
TypeScript
Executable File
import { NextRequest, NextResponse } from 'next/server'
|
|
import { existsSync, statSync, createReadStream } from 'fs'
|
|
import { readFile } from 'fs/promises'
|
|
import { join, resolve } from 'path'
|
|
import { getCoursesRootPath } from '@/lib/scanner'
|
|
import { convertSrtToVtt } from '@/lib/subtitles'
|
|
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ path: string[] }> }
|
|
) {
|
|
try {
|
|
const { path: pathSegments } = await params
|
|
const filePath = pathSegments.join('/')
|
|
|
|
// Security: resolve and validate path is within COURSES_ROOT
|
|
const coursesRoot = resolve(await getCoursesRootPath())
|
|
const requestedPath = resolve(join(coursesRoot, filePath))
|
|
|
|
// Prevent directory traversal
|
|
if (!requestedPath.startsWith(coursesRoot)) {
|
|
return new NextResponse('Forbidden', { status: 403 })
|
|
}
|
|
|
|
// Helper: try to find file with alternative extensions (e.g., .svg vs .png)
|
|
const findActualFile = async (basePath: string): Promise<string | null> => {
|
|
const extensions = ['.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp']
|
|
for (const ext of extensions) {
|
|
const tryPath = basePath.replace(/\.[^.]+$/, '') + ext
|
|
if (existsSync(tryPath)) return tryPath
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Check if file exists
|
|
let actualPath = requestedPath
|
|
if (!existsSync(actualPath)) {
|
|
// Try alternative extensions (for thumbnails with wrong extension requests)
|
|
const foundPath = await findActualFile(requestedPath)
|
|
if (foundPath) {
|
|
actualPath = foundPath
|
|
} else {
|
|
return new NextResponse('Not Found', { status: 404 })
|
|
}
|
|
}
|
|
|
|
const stats = statSync(actualPath)
|
|
|
|
// Don't serve directories
|
|
if (stats.isDirectory()) {
|
|
return new NextResponse('Forbidden', { status: 403 })
|
|
}
|
|
|
|
const fileSize = stats.size
|
|
const range = request.headers.get('range')
|
|
|
|
// Determine content type from ACTUAL file extension
|
|
const actualExt = actualPath.split('.').pop()?.toLowerCase()
|
|
const mimeTypes: Record<string, string> = {
|
|
mp4: 'video/mp4',
|
|
mkv: 'video/x-matroska',
|
|
webm: 'video/webm',
|
|
mov: 'video/quicktime',
|
|
avi: 'video/x-msvideo',
|
|
mp3: 'audio/mpeg',
|
|
wav: 'audio/wav',
|
|
m4a: 'audio/mp4',
|
|
pdf: 'application/pdf',
|
|
md: 'text/markdown; charset=utf-8',
|
|
html: 'text/html; charset=utf-8',
|
|
htm: 'text/html; charset=utf-8',
|
|
json: 'application/json; charset=utf-8',
|
|
jpg: 'image/jpeg',
|
|
jpeg: 'image/jpeg',
|
|
png: 'image/png',
|
|
gif: 'image/gif',
|
|
webp: 'image/webp',
|
|
srt: 'text/plain; charset=utf-8',
|
|
vtt: 'text/vtt; charset=utf-8',
|
|
txt: 'text/plain; charset=utf-8',
|
|
}
|
|
const contentType = mimeTypes[actualExt || ''] || 'application/octet-stream'
|
|
|
|
// Browsers expect WebVTT for <track>. Serve .srt subtitles as converted WebVTT.
|
|
if (actualExt === 'srt') {
|
|
const srtContent = await readFile(actualPath, 'utf8')
|
|
const vttContent = convertSrtToVtt(srtContent)
|
|
return new NextResponse(vttContent, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Length': Buffer.byteLength(vttContent, 'utf8').toString(),
|
|
'Content-Type': 'text/vtt; charset=utf-8',
|
|
'Cache-Control': 'no-cache',
|
|
'Content-Disposition': `inline; filename="${actualPath.split('/').pop()?.replace(/\.srt$/i, '.vtt')}"`,
|
|
'X-Content-Type-Options': 'nosniff',
|
|
},
|
|
})
|
|
}
|
|
|
|
// Handle range requests (video seeking)
|
|
if (range) {
|
|
const parts = range.replace(/bytes=/, '').split('-')
|
|
const start = parseInt(parts[0], 10)
|
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
|
|
const chunkSize = end - start + 1
|
|
|
|
if (start >= fileSize || end >= fileSize) {
|
|
return new NextResponse(null, {
|
|
status: 416,
|
|
headers: { 'Content-Range': `bytes */${fileSize}` },
|
|
})
|
|
}
|
|
|
|
const stream = createReadStream(actualPath, { start, end })
|
|
|
|
// Set Content-Disposition to inline for previewable file types
|
|
const previewableTypes = ['pdf', 'markdown', 'html', 'plain', 'json', 'vtt', 'srt']
|
|
const isPreviewable = previewableTypes.some(t => contentType.includes(t))
|
|
const contentDisposition = isPreviewable ? 'inline' : 'attachment'
|
|
|
|
return new NextResponse(stream as any, {
|
|
status: 206,
|
|
headers: {
|
|
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
'Accept-Ranges': 'bytes',
|
|
'Content-Length': chunkSize.toString(),
|
|
'Content-Type': contentType,
|
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
'Content-Disposition': `${contentDisposition}; filename="${actualPath.split('/').pop()}"`,
|
|
'X-Content-Type-Options': 'nosniff',
|
|
},
|
|
})
|
|
}
|
|
|
|
// Full file response
|
|
const stream = createReadStream(actualPath)
|
|
|
|
// Set Content-Disposition to inline for previewable file types
|
|
const previewableTypes = ['pdf', 'markdown', 'html', 'plain', 'json', 'vtt', 'srt']
|
|
const isPreviewable = previewableTypes.some(t => contentType.includes(t))
|
|
const contentDisposition = isPreviewable ? 'inline' : 'attachment'
|
|
|
|
return new NextResponse(stream as any, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Length': fileSize.toString(),
|
|
'Content-Type': contentType,
|
|
'Accept-Ranges': 'bytes',
|
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
'Content-Disposition': `${contentDisposition}; filename="${actualPath.split('/').pop()}"`,
|
|
'X-Content-Type-Options': 'nosniff',
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('File serve error:', error)
|
|
return new NextResponse('Internal Server Error', { status: 500 })
|
|
}
|
|
} |