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:
2025-08-10 01:28:16 -06:00
parent d3d0811a9f
commit bf7413b45f
5 changed files with 88 additions and 46 deletions

View File

@@ -29,6 +29,7 @@
:is-active="currentTrack?.name === track.name"
:is-playing="currentTrack?.name === track.name && isPlaying"
:is-loading="loadingTrack === track.name"
:has-error="failedTracks.has(track.name)"
@click="handleTrackClick(track, index)"
class="track-item-wrapper animate-fade-in-up"
/>
@@ -38,7 +39,6 @@
</template>
<script setup>
import { computed } from 'vue'
import { Music } from 'lucide-vue-next'
import TrackListItem from './TrackListItem.client.vue'
@@ -66,6 +66,10 @@ const props = defineProps({
loadingTrack: {
type: String,
default: null
},
failedTracks: {
type: Object, // Set object
default: () => new Set()
}
})

View File

@@ -6,6 +6,7 @@
{
'active': isActive,
'loading': isLoading,
'has-error': hasError,
'animate-pulse-glow': isActive && isPlaying
}
]"
@@ -21,7 +22,8 @@
<div class="track-status">
<div v-if="isLoading" class="loading-spinner"></div>
<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" />
<Music v-else class="icon idle" :size="18" />
</div>
@@ -36,7 +38,7 @@
<script setup>
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({
track: {
@@ -54,6 +56,10 @@ const props = defineProps({
isLoading: {
type: Boolean,
default: false
},
hasError: {
type: Boolean,
default: false
}
})
@@ -133,6 +139,26 @@ const formatTime = (seconds) => {
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 {
flex: 1;
}
@@ -184,6 +210,11 @@ const formatTime = (seconds) => {
opacity: 0.7;
}
.icon.error {
color: #ef4444;
opacity: 0.9;
}
.loading-spinner {
width: 20px;
height: 20px;

View File

@@ -21,6 +21,7 @@
:is-playing="isPlaying"
:loading="loading"
:loading-track="loadingTrack"
:failed-tracks="failedTracks"
@track-selected="handleTrackSelected"
/>
</MainContainer>
@@ -76,6 +77,7 @@ const duration = ref(0)
const volume = ref(0.7)
const loading = ref(true)
const loadingTrack = ref(null)
const failedTracks = ref(new Set()) // Track failed tracks
// Theme (handled by ThemeToggle component now)
const isDark = useLocalStorage('theme-dark', false)
@@ -139,6 +141,11 @@ const playTrack = async (track, index) => {
// Fetch and preload entire song into memory
const encodedName = encodeURIComponent(track.name)
const response = await fetch(`/api/music/${encodedName}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const blob = await response.blob()
const audioUrl = URL.createObjectURL(blob)
@@ -155,7 +162,12 @@ const playTrack = async (track, index) => {
}, { once: true })
} catch (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)
audioPlayer.value.src = `/api/music/${encodedName}`
audioPlayer.value.load()
@@ -270,8 +282,19 @@ const onLoadedMetadata = () => {
const onAudioError = (error) => {
console.error('Audio error:', error)
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
nextTrack()
setTimeout(() => {
nextTrack()
}, 1000) // Small delay before trying next track
}
const onTimeUpdate = () => {
@@ -291,7 +314,7 @@ const onCanPlay = () => {
// Watchers
watch(isDark, (newValue) => {
if (process.client) {
if (import.meta.client) {
document.documentElement.setAttribute('data-theme', newValue ? 'dark' : 'light')
}
}, { immediate: true })
@@ -313,7 +336,7 @@ const cleanupAudio = () => {
// Handle mobile browser UI bars
const handleViewportChange = () => {
if (!process.client) return
if (!import.meta.client) return
const vh = window.innerHeight
const dvh = window.visualViewport ? window.visualViewport.height : vh
@@ -336,7 +359,7 @@ const handleViewportChange = () => {
// Lifecycle
onMounted(() => {
loadTracks()
if (process.client) {
if (import.meta.client) {
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
// Handle mobile browser UI changes
@@ -354,7 +377,7 @@ onMounted(() => {
onUnmounted(() => {
cleanupAudio()
if (process.client) {
if (import.meta.client) {
window.removeEventListener('beforeunload', cleanupAudio)
window.removeEventListener('pagehide', cleanupAudio)
window.removeEventListener('resize', handleViewportChange)

View File

@@ -13,24 +13,6 @@ export default defineNuxtPlugin(() => {
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
if ('__NUXT__' in window) {
console.log('[PROXY] Nuxt app loaded through proxy successfully')

View File

@@ -1,5 +1,5 @@
import { promises as fs } from 'fs'
import { join } from 'path'
import { join, resolve, sep } from 'path'
import { createReadStream } from 'fs'
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 realIP = headers['x-real-ip'] || headers['x-forwarded-for'] || 'unknown'
console.log(`[MUSIC API] Request from ${realIP} for file: ${filename}`)
console.log('Original filename bytes:', [...filename].map(c => c.charCodeAt(0)))
// Decode the filename
try {
filename = decodeURIComponent(filename)
console.log('Decoded filename:', filename)
console.log('Decoded filename bytes:', [...filename].map(c => c.charCodeAt(0)))
} catch (error) {
console.error('Error decoding filename:', error)
// If decoding fails, use original filename
@@ -30,13 +27,27 @@ export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig()
const defaultPublicPath = config.public?.musicPath || '/music'
const publicRel = defaultPublicPath.replace(/^\//, '')
const musicDir = process.env.MUSIC_DIR || join(process.cwd(), 'public', publicRel)
const filePath = join(musicDir, filename)
// 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
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({
statusCode: 403,
statusMessage: 'Access denied'
@@ -46,16 +57,7 @@ export default defineEventHandler(async (event) => {
// Check if file exists
try {
await fs.access(filePath)
console.log('File found successfully:', filePath)
} 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({
statusCode: 404,
statusMessage: 'File not found'