- Fix path traversal security check by using absolute paths - Remove problematic fetch override that forced JSON headers on all API requests - Add error tracking and visual indicators for failed tracks - Correct music directory resolution for both relative and absolute paths The main issue was the security validation comparing relative paths incorrectly, causing legitimate music file requests to be rejected with 403 errors.
120 lines
3.6 KiB
TypeScript
120 lines
3.6 KiB
TypeScript
import { promises as fs } from 'fs'
|
|
import { join, resolve, sep } from 'path'
|
|
import { createReadStream } from 'fs'
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
let filename = getRouterParam(event, 'filename')
|
|
|
|
if (!filename) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
statusMessage: 'Filename is required'
|
|
})
|
|
}
|
|
|
|
// Log incoming request
|
|
const headers = getHeaders(event)
|
|
const realIP = headers['x-real-ip'] || headers['x-forwarded-for'] || 'unknown'
|
|
console.log(`[MUSIC API] Request from ${realIP} for file: ${filename}`)
|
|
|
|
// Decode the filename
|
|
try {
|
|
filename = decodeURIComponent(filename)
|
|
} catch (error) {
|
|
console.error('Error decoding filename:', error)
|
|
// If decoding fails, use original filename
|
|
}
|
|
|
|
try {
|
|
const config = useRuntimeConfig()
|
|
|
|
// Determine the music directory path
|
|
let musicDir: string
|
|
if (process.env.MUSIC_DIR) {
|
|
// If MUSIC_DIR is set, resolve it (handles both absolute and relative paths)
|
|
musicDir = resolve(process.cwd(), process.env.MUSIC_DIR)
|
|
} else {
|
|
// Fallback to public/music
|
|
const defaultPublicPath = config.public?.musicPath || '/music'
|
|
const publicRel = defaultPublicPath.replace(/^\//, '')
|
|
musicDir = resolve(process.cwd(), 'public', publicRel)
|
|
}
|
|
|
|
// Resolve the full file path
|
|
const filePath = resolve(musicDir, filename)
|
|
|
|
// Security check: ensure the file is within the music directory
|
|
// Normalize both paths and add separator to ensure exact directory match
|
|
const normalizedMusicDir = musicDir.endsWith(sep) ? musicDir : musicDir + sep
|
|
|
|
if (!filePath.startsWith(normalizedMusicDir)) {
|
|
throw createError({
|
|
statusCode: 403,
|
|
statusMessage: 'Access denied'
|
|
})
|
|
}
|
|
|
|
// Check if file exists
|
|
try {
|
|
await fs.access(filePath)
|
|
} catch (error) {
|
|
throw createError({
|
|
statusCode: 404,
|
|
statusMessage: 'File not found'
|
|
})
|
|
}
|
|
|
|
// Get file stats
|
|
const stats = await fs.stat(filePath)
|
|
|
|
// Set appropriate headers
|
|
const headers = getHeaders(event)
|
|
|
|
// Handle range requests for audio streaming
|
|
const range = headers.range
|
|
|
|
if (range) {
|
|
const parts = range.replace(/bytes=/, "").split("-")
|
|
const start = parseInt(parts[0], 10)
|
|
const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1
|
|
const chunksize = (end - start) + 1
|
|
|
|
setHeader(event, 'Content-Range', `bytes ${start}-${end}/${stats.size}`)
|
|
setHeader(event, 'Accept-Ranges', 'bytes')
|
|
setHeader(event, 'Content-Length', chunksize.toString())
|
|
setResponseStatus(event, 206)
|
|
|
|
return sendStream(event, createReadStream(filePath, { start, end }))
|
|
} else {
|
|
// Send entire file
|
|
setHeader(event, 'Content-Length', stats.size.toString())
|
|
setHeader(event, 'Accept-Ranges', 'bytes')
|
|
|
|
// Set content type based on file extension
|
|
const ext = filename.toLowerCase().split('.').pop()
|
|
const contentTypes = {
|
|
'mp3': 'audio/mpeg',
|
|
'wav': 'audio/wav',
|
|
'flac': 'audio/flac',
|
|
'm4a': 'audio/mp4',
|
|
'ogg': 'audio/ogg',
|
|
'aac': 'audio/aac'
|
|
}
|
|
|
|
setHeader(event, 'Content-Type', contentTypes[ext] || 'audio/mpeg')
|
|
|
|
return sendStream(event, createReadStream(filePath))
|
|
}
|
|
} catch (error) {
|
|
if (error.statusCode) {
|
|
throw error
|
|
}
|
|
|
|
console.error('Error serving music file:', error)
|
|
throw createError({
|
|
statusCode: 500,
|
|
statusMessage: 'Failed to serve music file'
|
|
})
|
|
}
|
|
})
|