Fix 403 error when loading music files
- 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.
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
:is-active="currentTrack?.name === track.name"
|
:is-active="currentTrack?.name === track.name"
|
||||||
:is-playing="currentTrack?.name === track.name && isPlaying"
|
:is-playing="currentTrack?.name === track.name && isPlaying"
|
||||||
:is-loading="loadingTrack === track.name"
|
:is-loading="loadingTrack === track.name"
|
||||||
|
:has-error="failedTracks.has(track.name)"
|
||||||
@click="handleTrackClick(track, index)"
|
@click="handleTrackClick(track, index)"
|
||||||
class="track-item-wrapper animate-fade-in-up"
|
class="track-item-wrapper animate-fade-in-up"
|
||||||
/>
|
/>
|
||||||
@@ -38,7 +39,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
|
||||||
import { Music } from 'lucide-vue-next'
|
import { Music } from 'lucide-vue-next'
|
||||||
import TrackListItem from './TrackListItem.client.vue'
|
import TrackListItem from './TrackListItem.client.vue'
|
||||||
|
|
||||||
@@ -66,6 +66,10 @@ const props = defineProps({
|
|||||||
loadingTrack: {
|
loadingTrack: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
failedTracks: {
|
||||||
|
type: Object, // Set object
|
||||||
|
default: () => new Set()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
{
|
{
|
||||||
'active': isActive,
|
'active': isActive,
|
||||||
'loading': isLoading,
|
'loading': isLoading,
|
||||||
|
'has-error': hasError,
|
||||||
'animate-pulse-glow': isActive && isPlaying
|
'animate-pulse-glow': isActive && isPlaying
|
||||||
}
|
}
|
||||||
]"
|
]"
|
||||||
@@ -21,7 +22,8 @@
|
|||||||
<div class="track-status">
|
<div class="track-status">
|
||||||
<div v-if="isLoading" class="loading-spinner"></div>
|
<div v-if="isLoading" class="loading-spinner"></div>
|
||||||
<div v-else class="status-icon">
|
<div v-else class="status-icon">
|
||||||
<Pause v-if="isActive && !isPlaying" class="icon paused" :size="18" />
|
<AlertCircle v-if="hasError" class="icon error" :size="18" />
|
||||||
|
<Pause v-else-if="isActive && !isPlaying" class="icon paused" :size="18" />
|
||||||
<Play v-else-if="isActive && isPlaying" class="icon playing" :size="18" />
|
<Play v-else-if="isActive && isPlaying" class="icon playing" :size="18" />
|
||||||
<Music v-else class="icon idle" :size="18" />
|
<Music v-else class="icon idle" :size="18" />
|
||||||
</div>
|
</div>
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { Play, Pause, Music } from 'lucide-vue-next'
|
import { Play, Pause, Music, AlertCircle } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
track: {
|
track: {
|
||||||
@@ -54,6 +56,10 @@ const props = defineProps({
|
|||||||
isLoading: {
|
isLoading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
hasError: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -133,6 +139,26 @@ const formatTime = (seconds) => {
|
|||||||
inset 0 -1px 0 rgba(0, 0, 0, 0.15);
|
inset 0 -1px 0 rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Error state styles */
|
||||||
|
.track-item.has-error {
|
||||||
|
opacity: 0.7;
|
||||||
|
background: rgba(239, 68, 68, 0.05);
|
||||||
|
border-color: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.has-error:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(239, 68, 68, 0.15),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.has-error .track-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.track-info {
|
.track-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -184,6 +210,11 @@ const formatTime = (seconds) => {
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon.error {
|
||||||
|
color: #ef4444;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
:is-playing="isPlaying"
|
:is-playing="isPlaying"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:loading-track="loadingTrack"
|
:loading-track="loadingTrack"
|
||||||
|
:failed-tracks="failedTracks"
|
||||||
@track-selected="handleTrackSelected"
|
@track-selected="handleTrackSelected"
|
||||||
/>
|
/>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
@@ -76,6 +77,7 @@ const duration = ref(0)
|
|||||||
const volume = ref(0.7)
|
const volume = ref(0.7)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadingTrack = ref(null)
|
const loadingTrack = ref(null)
|
||||||
|
const failedTracks = ref(new Set()) // Track failed tracks
|
||||||
|
|
||||||
// Theme (handled by ThemeToggle component now)
|
// Theme (handled by ThemeToggle component now)
|
||||||
const isDark = useLocalStorage('theme-dark', false)
|
const isDark = useLocalStorage('theme-dark', false)
|
||||||
@@ -139,6 +141,11 @@ const playTrack = async (track, index) => {
|
|||||||
// Fetch and preload entire song into memory
|
// Fetch and preload entire song into memory
|
||||||
const encodedName = encodeURIComponent(track.name)
|
const encodedName = encodeURIComponent(track.name)
|
||||||
const response = await fetch(`/api/music/${encodedName}`)
|
const response = await fetch(`/api/music/${encodedName}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const audioUrl = URL.createObjectURL(blob)
|
const audioUrl = URL.createObjectURL(blob)
|
||||||
|
|
||||||
@@ -155,7 +162,12 @@ const playTrack = async (track, index) => {
|
|||||||
}, { once: true })
|
}, { once: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to preload track:', error)
|
console.error('Failed to preload track:', error)
|
||||||
// Fallback to streaming
|
|
||||||
|
// Mark track as failed
|
||||||
|
failedTracks.value.add(track.name)
|
||||||
|
loadingTrack.value = null
|
||||||
|
|
||||||
|
// Try fallback to streaming
|
||||||
const encodedName = encodeURIComponent(track.name)
|
const encodedName = encodeURIComponent(track.name)
|
||||||
audioPlayer.value.src = `/api/music/${encodedName}`
|
audioPlayer.value.src = `/api/music/${encodedName}`
|
||||||
audioPlayer.value.load()
|
audioPlayer.value.load()
|
||||||
@@ -270,8 +282,19 @@ const onLoadedMetadata = () => {
|
|||||||
const onAudioError = (error) => {
|
const onAudioError = (error) => {
|
||||||
console.error('Audio error:', error)
|
console.error('Audio error:', error)
|
||||||
console.error('Failed to load:', currentTrack.value?.name)
|
console.error('Failed to load:', currentTrack.value?.name)
|
||||||
|
|
||||||
|
// Mark track as failed
|
||||||
|
if (currentTrack.value) {
|
||||||
|
failedTracks.value.add(currentTrack.value.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear loading state
|
||||||
|
loadingTrack.value = null
|
||||||
|
|
||||||
// Try next track if current fails
|
// Try next track if current fails
|
||||||
nextTrack()
|
setTimeout(() => {
|
||||||
|
nextTrack()
|
||||||
|
}, 1000) // Small delay before trying next track
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTimeUpdate = () => {
|
const onTimeUpdate = () => {
|
||||||
@@ -291,7 +314,7 @@ const onCanPlay = () => {
|
|||||||
|
|
||||||
// Watchers
|
// Watchers
|
||||||
watch(isDark, (newValue) => {
|
watch(isDark, (newValue) => {
|
||||||
if (process.client) {
|
if (import.meta.client) {
|
||||||
document.documentElement.setAttribute('data-theme', newValue ? 'dark' : 'light')
|
document.documentElement.setAttribute('data-theme', newValue ? 'dark' : 'light')
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
@@ -313,7 +336,7 @@ const cleanupAudio = () => {
|
|||||||
|
|
||||||
// Handle mobile browser UI bars
|
// Handle mobile browser UI bars
|
||||||
const handleViewportChange = () => {
|
const handleViewportChange = () => {
|
||||||
if (!process.client) return
|
if (!import.meta.client) return
|
||||||
|
|
||||||
const vh = window.innerHeight
|
const vh = window.innerHeight
|
||||||
const dvh = window.visualViewport ? window.visualViewport.height : vh
|
const dvh = window.visualViewport ? window.visualViewport.height : vh
|
||||||
@@ -336,7 +359,7 @@ const handleViewportChange = () => {
|
|||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadTracks()
|
loadTracks()
|
||||||
if (process.client) {
|
if (import.meta.client) {
|
||||||
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
|
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
|
||||||
|
|
||||||
// Handle mobile browser UI changes
|
// Handle mobile browser UI changes
|
||||||
@@ -354,7 +377,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cleanupAudio()
|
cleanupAudio()
|
||||||
if (process.client) {
|
if (import.meta.client) {
|
||||||
window.removeEventListener('beforeunload', cleanupAudio)
|
window.removeEventListener('beforeunload', cleanupAudio)
|
||||||
window.removeEventListener('pagehide', cleanupAudio)
|
window.removeEventListener('pagehide', cleanupAudio)
|
||||||
window.removeEventListener('resize', handleViewportChange)
|
window.removeEventListener('resize', handleViewportChange)
|
||||||
|
|||||||
@@ -13,24 +13,6 @@ export default defineNuxtPlugin(() => {
|
|||||||
originalConsoleError.apply(console, args)
|
originalConsoleError.apply(console, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override fetch to handle proxy correctly
|
|
||||||
const originalFetch = window.fetch
|
|
||||||
window.fetch = function(input: RequestInfo | URL, init?: RequestInit) {
|
|
||||||
// Ensure all API calls use relative URLs to work with proxy
|
|
||||||
if (typeof input === 'string' && input.startsWith('/api/')) {
|
|
||||||
// Add proper headers for proxy compatibility
|
|
||||||
return originalFetch(input, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
...init?.headers
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return originalFetch(input, init)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Nuxt/Vite specific issues with proxy
|
// Handle Nuxt/Vite specific issues with proxy
|
||||||
if ('__NUXT__' in window) {
|
if ('__NUXT__' in window) {
|
||||||
console.log('[PROXY] Nuxt app loaded through proxy successfully')
|
console.log('[PROXY] Nuxt app loaded through proxy successfully')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import { join } from 'path'
|
import { join, resolve, sep } from 'path'
|
||||||
import { createReadStream } from 'fs'
|
import { createReadStream } from 'fs'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -12,17 +12,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log incoming request for debugging proxy issues
|
// Log incoming request
|
||||||
const headers = getHeaders(event)
|
const headers = getHeaders(event)
|
||||||
const realIP = headers['x-real-ip'] || headers['x-forwarded-for'] || 'unknown'
|
const realIP = headers['x-real-ip'] || headers['x-forwarded-for'] || 'unknown'
|
||||||
console.log(`[MUSIC API] Request from ${realIP} for file: ${filename}`)
|
console.log(`[MUSIC API] Request from ${realIP} for file: ${filename}`)
|
||||||
console.log('Original filename bytes:', [...filename].map(c => c.charCodeAt(0)))
|
|
||||||
|
|
||||||
// Decode the filename
|
// Decode the filename
|
||||||
try {
|
try {
|
||||||
filename = decodeURIComponent(filename)
|
filename = decodeURIComponent(filename)
|
||||||
console.log('Decoded filename:', filename)
|
|
||||||
console.log('Decoded filename bytes:', [...filename].map(c => c.charCodeAt(0)))
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error decoding filename:', error)
|
console.error('Error decoding filename:', error)
|
||||||
// If decoding fails, use original filename
|
// If decoding fails, use original filename
|
||||||
@@ -30,13 +27,27 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const defaultPublicPath = config.public?.musicPath || '/music'
|
|
||||||
const publicRel = defaultPublicPath.replace(/^\//, '')
|
// Determine the music directory path
|
||||||
const musicDir = process.env.MUSIC_DIR || join(process.cwd(), 'public', publicRel)
|
let musicDir: string
|
||||||
const filePath = join(musicDir, filename)
|
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
|
// Security check: ensure the file is within the music directory
|
||||||
if (!filePath.startsWith(musicDir)) {
|
// 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({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: 'Access denied'
|
statusMessage: 'Access denied'
|
||||||
@@ -46,16 +57,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Check if file exists
|
// Check if file exists
|
||||||
try {
|
try {
|
||||||
await fs.access(filePath)
|
await fs.access(filePath)
|
||||||
console.log('File found successfully:', filePath)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('File NOT found:', filePath)
|
|
||||||
console.log('Directory contents:')
|
|
||||||
try {
|
|
||||||
const files = await fs.readdir(musicDir)
|
|
||||||
files.forEach(file => console.log(' -', file))
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Cannot read directory:', e)
|
|
||||||
}
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
statusMessage: 'File not found'
|
statusMessage: 'File not found'
|
||||||
|
|||||||
Reference in New Issue
Block a user