[FIX] Fix some securiry issues
This commit is contained in:
11
partitions/.env.example
Normal file
11
partitions/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Frontend Environment Variables
|
||||
# Copy this file to .env and update the values
|
||||
|
||||
# API Configuration
|
||||
# Use HTTPS in production!
|
||||
VITE_API_URL=http://localhost:8000
|
||||
|
||||
# Security Notes:
|
||||
# - Always use HTTPS in production
|
||||
# - Never commit the .env file with real secrets
|
||||
# - The JWT token is stored in localStorage (consider migrating to httpOnly cookies for better security)
|
||||
@@ -3,7 +3,10 @@ import { auth } from '$lib/stores/auth';
|
||||
import { browser } from '$app/environment';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const API_BASE_URL = browser ? 'http://localhost:8000' : 'http://localhost:8000';
|
||||
// Use environment variable or default to localhost
|
||||
const API_BASE_URL = browser
|
||||
? (import.meta.env.VITE_API_URL || 'http://localhost:8000')
|
||||
: (import.meta.env.VITE_API_URL || 'http://localhost:8000');
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -107,15 +110,21 @@ export const apiService = {
|
||||
},
|
||||
|
||||
getDownloadUrl(path: string): string {
|
||||
let token = '';
|
||||
auth.subscribe((state) => {
|
||||
token = state.token || '';
|
||||
})();
|
||||
return `${API_BASE_URL}/download/${path}?token=${token}`;
|
||||
// Security: Token is now passed via Authorization header, not URL
|
||||
// The backend will read the token from the header in the request
|
||||
return `${API_BASE_URL}/download/${path}`;
|
||||
},
|
||||
|
||||
async createScore(name: string, compositor: string): Promise<{ success: boolean; score?: any; error?: string }> {
|
||||
const response = await api.post('/admin/scores', { name, compositor });
|
||||
// New method to download with proper auth header
|
||||
async downloadFileWithAuth(path: string): Promise<Blob> {
|
||||
const response = await api.get(`/download/${path}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createScore(name: string, compositor: string, pieces: { number: number; name: string }[] = []): Promise<{ success: boolean; score?: any; error?: string }> {
|
||||
const response = await api.post('/admin/scores', { name, compositor, pieces });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -146,5 +155,17 @@ export const apiService = {
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getScoreFiles(scoreId: string): Promise<{ success: boolean; files: any[]; error?: string }> {
|
||||
const response = await api.get(`/admin/scores/${scoreId}/files`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteScoreFile(scoreId: string, filePath: string): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await api.delete(`/admin/scores/${scoreId}/files`, {
|
||||
params: { path: filePath }
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,9 +11,34 @@ interface AuthState {
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth Store
|
||||
*
|
||||
* SECURITY NOTE: Token is stored in localStorage which is vulnerable to XSS attacks.
|
||||
* The CSP (Content Security Policy) helps mitigate XSS risks.
|
||||
* For production with higher security requirements, consider migrating to httpOnly cookies.
|
||||
*/
|
||||
function createAuthStore() {
|
||||
// SECURITY: Validate stored data before parsing to prevent XSS via localStorage
|
||||
const stored = browser ? localStorage.getItem('auth') : null;
|
||||
const initial: AuthState = stored ? JSON.parse(stored) : { token: null, user: null };
|
||||
let initial: AuthState = { token: null, user: null };
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Validate structure to prevent injection
|
||||
if (parsed && typeof parsed === 'object' &&
|
||||
(parsed.token === null || typeof parsed.token === 'string') &&
|
||||
(parsed.user === null || (typeof parsed.user === 'object' && parsed.user.username && parsed.user.role))) {
|
||||
initial = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid stored data, clear it
|
||||
if (browser) {
|
||||
localStorage.removeItem('auth');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { subscribe, set, update } = writable<AuthState>(initial);
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
let showForm = $state(false);
|
||||
let newName = $state('');
|
||||
let newCompositor = $state('');
|
||||
let newPieceCount = $state(1);
|
||||
let newPieceNames = $state<string[]>(['']);
|
||||
let saving = $state(false);
|
||||
|
||||
// Upload form
|
||||
@@ -50,12 +52,19 @@
|
||||
let uploadError = $state('');
|
||||
let uploadSuccess = $state('');
|
||||
let selectedScorePieceCount = $state(1);
|
||||
let selectedScorePieces = $state<{id: string; name: string}[]>([]);
|
||||
let showAdvanced = $state(false);
|
||||
let deleteConfirmId = $state<string | null>(null);
|
||||
let uploadKey = $state('');
|
||||
let uploadClef = $state('');
|
||||
let uploadVariant = $state('');
|
||||
let uploadPart = $state('1');
|
||||
|
||||
function handlePieceCountChange(count: number) {
|
||||
newPieceCount = count;
|
||||
newPieceNames = Array(count).fill('').map((_, i) => newPieceNames[i] || '');
|
||||
}
|
||||
|
||||
// When score is selected, get piece count
|
||||
async function handleScoreSelect(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
@@ -64,12 +73,17 @@
|
||||
try {
|
||||
const pieces = await apiService.getPieces(uploadScoreId);
|
||||
selectedScorePieceCount = pieces.length;
|
||||
selectedScorePieces = pieces.map((p: any) => ({
|
||||
id: String(p.id),
|
||||
name: p.name || `Partie ${p.id}`
|
||||
}));
|
||||
if (selectedScorePieceCount === 1) {
|
||||
uploadPiece = '1';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
selectedScorePieceCount = 1;
|
||||
selectedScorePieces = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,11 +135,17 @@
|
||||
if (!newName.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
const result = await apiService.createScore(newName, newCompositor);
|
||||
const pieces = newPieceNames.filter(n => n.trim()).map((name, i) => ({
|
||||
number: i + 1,
|
||||
name: name.trim() || `Partie ${i + 1}`
|
||||
}));
|
||||
const result = await apiService.createScore(newName, newCompositor, pieces);
|
||||
if (result.success) {
|
||||
showForm = false;
|
||||
newName = '';
|
||||
newCompositor = '';
|
||||
newPieceCount = 1;
|
||||
newPieceNames = [''];
|
||||
await loadScores();
|
||||
} else {
|
||||
error = result.error || 'Erreur';
|
||||
@@ -139,7 +159,6 @@
|
||||
}
|
||||
|
||||
async function deleteScore(id: string) {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cette partition?')) return;
|
||||
try {
|
||||
const result = await apiService.deleteScore(id);
|
||||
if (result.success) {
|
||||
@@ -230,7 +249,7 @@
|
||||
<!-- Create new score -->
|
||||
<div class="mb-8">
|
||||
<button
|
||||
onclick={() => showForm = !showForm}
|
||||
onclick={() => { showForm = !showForm; if (!showForm) { newName = ''; newCompositor = ''; newPieceCount = 1; newPieceNames = ['']; } }}
|
||||
class="bg-ohmj-primary text-white px-4 py-2 rounded hover:bg-ohmj-secondary transition-colors"
|
||||
>
|
||||
{showForm ? 'Annuler' : '+ Nouvelle partition'}
|
||||
@@ -258,6 +277,31 @@
|
||||
placeholder="Compositeur (optionnel)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Nombre de parties</label>
|
||||
<select
|
||||
value={newPieceCount}
|
||||
onchange={(e) => handlePieceCountChange(parseInt((e.target as HTMLSelectElement).value))}
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
|
||||
>
|
||||
{#each [1,2,3,4,5,6,7,8,9,10] as num}
|
||||
<option value={num}>{num}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#if newPieceCount > 1}
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700">Nom des parties</label>
|
||||
{#each newPieceNames as pieceName, i}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPieceNames[i]}
|
||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-ohmj-primary focus:border-ohmj-primary"
|
||||
placeholder="Partie {i + 1}"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
@@ -270,168 +314,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Upload PDF -->
|
||||
<div class="mb-8 p-4 bg-gray-50 rounded-lg">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">Uploader un PDF</h2>
|
||||
<form onsubmit={(e) => { e.preventDefault(); uploadPdf(); }} class="space-y-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Score</label>
|
||||
<select
|
||||
value={uploadScoreId}
|
||||
onchange={handleScoreSelect}
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
{#each scores as score}
|
||||
<option value={score.id}>{score.id} - {score.name.substring(0, 20)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Instrument</label>
|
||||
<select
|
||||
bind:value={uploadInstrument}
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
{#each INSTRUMENTS as inst}
|
||||
<option value={inst.code}>{inst.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showAdvanced = !showAdvanced;
|
||||
if (showAdvanced && !uploadKey) {
|
||||
const inst = INSTRUMENTS.find(i => i.code === uploadInstrument);
|
||||
if (inst?.defaultKey) {
|
||||
uploadKey = inst.defaultKey;
|
||||
}
|
||||
}
|
||||
}}
|
||||
class="mt-6 text-sm text-ohmj-primary hover:underline"
|
||||
>
|
||||
{showAdvanced ? '▼ Masquer' : '▶ Options avancées'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showAdvanced}
|
||||
<div class="grid grid-cols-2 md:grid-cols-7 gap-4 p-3 bg-gray-100 rounded">
|
||||
{#if selectedScorePieceCount > 1}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Pièce</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={uploadPiece}
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Version</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={uploadVersion}
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Tonalité</label>
|
||||
<select bind:value={uploadKey} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="">-</option>
|
||||
<option value="sib">Si♭ (sib)</option>
|
||||
<option value="mib">Mi♭ (mib)</option>
|
||||
<option value="fa">Fa (fa)</option>
|
||||
<option value="do">Do (ut)</option>
|
||||
<option value="sol">Sol</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Clé</label>
|
||||
<select bind:value={uploadClef} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="">-</option>
|
||||
<option value="clesol">Sol</option>
|
||||
<option value="clefa">Fa</option>
|
||||
<option value="cleut">Ut</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Partie</label>
|
||||
<select bind:value={uploadPart} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Variante</label>
|
||||
<select bind:value={uploadVariant} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="">-</option>
|
||||
<option value="solo">Solo</option>
|
||||
<option value="default">Default (substitut)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => showAdvanced = false}
|
||||
class="mt-1 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Masquer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 p-3 bg-blue-50 rounded border border-blue-200">
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Nom attendu:</strong> <code class="bg-white px-2 py-1 rounded">{expectedFilename || 'Sélectionnez un instrument'}</code>
|
||||
</p>
|
||||
<p class="text-xs text-blue-600 mt-1">
|
||||
Le fichier sera renommé automatiquement lors de l'upload
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-medium text-gray-700">Fichier PDF</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onchange={handleFileSelect}
|
||||
required
|
||||
class="mt-1 block w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
{#if uploadError}
|
||||
<p class="text-red-600 text-sm">{uploadError}</p>
|
||||
{/if}
|
||||
{#if uploadSuccess}
|
||||
<p class="text-green-600 text-sm">{uploadSuccess}</p>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
class="bg-ohmj-primary text-white px-4 py-2 rounded hover:bg-ohmj-secondary disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Upload...' : 'Uploader'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- List of scores -->
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full">
|
||||
@@ -452,13 +334,13 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<div class="flex justify-end gap-3">
|
||||
<a
|
||||
href="/scores/{score.id}"
|
||||
href="/admin/{score.id}"
|
||||
class="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Voir
|
||||
</a>
|
||||
<button
|
||||
onclick={() => deleteScore(score.id)}
|
||||
onclick={() => deleteConfirmId = score.id}
|
||||
class="text-red-600 hover:text-red-800"
|
||||
>
|
||||
Supprimer
|
||||
@@ -471,4 +353,33 @@
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if deleteConfirmId}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onclick={() => deleteConfirmId = null}>
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4" onclick={(e) => e.stopPropagation()}>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Confirmer la suppression</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Êtes-vous sûr de vouloir supprimer cette partition ? Cette action est irréversible et supprimera également tous les fichiers associés.
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
onclick={() => deleteConfirmId = null}
|
||||
class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onclick={async () => {
|
||||
const id = deleteConfirmId;
|
||||
deleteConfirmId = null;
|
||||
if (id) await deleteScore(id);
|
||||
}}
|
||||
class="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded transition-colors"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
461
partitions/src/routes/admin/[id]/+page.svelte
Normal file
461
partitions/src/routes/admin/[id]/+page.svelte
Normal file
@@ -0,0 +1,461 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { apiService } from '$lib/api';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
const INSTRUMENTS = [
|
||||
{ code: 'dir', name: 'Direction', defaultKey: '' },
|
||||
{ code: 'pic', name: 'Piccolo', defaultKey: 'do' },
|
||||
{ code: 'flu', name: 'Flûte', defaultKey: 'do' },
|
||||
{ code: 'cla', name: 'Clarinette', defaultKey: 'sib' },
|
||||
{ code: 'clb', name: 'Clarinette Basse', defaultKey: 'sib' },
|
||||
{ code: 'sax', name: 'Saxophone Alto', defaultKey: 'mib' },
|
||||
{ code: 'sat', name: 'Saxophone Ténor', defaultKey: 'sib' },
|
||||
{ code: 'sab', name: 'Saxophone Baryton', defaultKey: 'mib' },
|
||||
{ code: 'coa', name: 'Cor Anglais', defaultKey: 'fa' },
|
||||
{ code: 'htb', name: 'Hautbois', defaultKey: 'do' },
|
||||
{ code: 'bas', name: 'Basson', defaultKey: 'do' },
|
||||
{ code: 'cor', name: 'Cor', defaultKey: 'fa' },
|
||||
{ code: 'trp', name: 'Trompette', defaultKey: 'sib' },
|
||||
{ code: 'crn', name: 'Cornet', defaultKey: 'sib' },
|
||||
{ code: 'trb', name: 'Trombone', defaultKey: 'do' },
|
||||
{ code: 'eup', name: 'Euphonium', defaultKey: 'sib' },
|
||||
{ code: 'tub', name: 'Tuba', defaultKey: 'do' },
|
||||
{ code: 'cba', name: 'Contrebasse', defaultKey: 'sib' },
|
||||
{ code: 'per', name: 'Percussions', defaultKey: '' },
|
||||
{ code: 'pia', name: 'Piano', defaultKey: '' },
|
||||
{ code: 'har', name: 'Harpe', defaultKey: '' }
|
||||
];
|
||||
|
||||
let scoreId = $derived($page.params.id || '');
|
||||
let scoreName = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let userRole = $state('');
|
||||
let isAdmin = $derived(userRole === 'admin');
|
||||
|
||||
// Upload form
|
||||
let uploadPiece = $state('1');
|
||||
let uploadInstrument = $state('');
|
||||
let uploadVersion = $state('1');
|
||||
let uploadFile: File | null = $state(null);
|
||||
let uploading = $state(false);
|
||||
let uploadError = $state('');
|
||||
let uploadSuccess = $state('');
|
||||
let selectedScorePieceCount = $state(1);
|
||||
let selectedScorePieces = $state<{id: string; name: string}[]>([]);
|
||||
let showAdvanced = $state(false);
|
||||
let uploadKey = $state('');
|
||||
let uploadClef = $state('');
|
||||
let uploadVariant = $state('');
|
||||
let uploadPart = $state('1');
|
||||
|
||||
// File tree
|
||||
let files: any[] = $state([]);
|
||||
|
||||
// Delete modal
|
||||
let deleteFilePath = $state<string | null>(null);
|
||||
let deletingFile = $state(false);
|
||||
|
||||
async function loadScoreInfo() {
|
||||
try {
|
||||
const score = await apiService.getScore(scoreId);
|
||||
scoreName = score.name;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
error = 'Partition non trouvée';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const result = await apiService.getScoreFiles(scoreId);
|
||||
if (result.success) {
|
||||
files = result.files;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScoreSelect() {
|
||||
if (scoreId) {
|
||||
try {
|
||||
const pieces = await apiService.getPieces(scoreId);
|
||||
selectedScorePieceCount = pieces.length;
|
||||
selectedScorePieces = pieces.map((p: any) => ({
|
||||
id: String(p.id),
|
||||
name: p.name || `Partie ${p.id}`
|
||||
}));
|
||||
if (selectedScorePieceCount === 1) {
|
||||
uploadPiece = '1';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
selectedScorePieceCount = 1;
|
||||
selectedScorePieces = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getExpectedFilename(): string {
|
||||
if (!uploadInstrument) return '';
|
||||
const inst = INSTRUMENTS.find(i => i.code === uploadInstrument);
|
||||
const instName = inst ? inst.name.toLowerCase().replace(/ /g, '_') : uploadInstrument;
|
||||
const key = uploadKey || inst?.defaultKey || '';
|
||||
const keyStr = key ? `_${key}` : '';
|
||||
const clef = uploadClef ? `_${uploadClef}` : '';
|
||||
const variant = uploadVariant ? `_${uploadVariant}` : '';
|
||||
return `${instName}${variant}${keyStr}${clef}_${uploadPart}.pdf`;
|
||||
}
|
||||
|
||||
let expectedFilename = $derived(getExpectedFilename());
|
||||
|
||||
onMount(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const unsubscribe = auth.subscribe((state) => {
|
||||
userRole = state.user?.role || '';
|
||||
});
|
||||
|
||||
if (userRole !== 'admin') {
|
||||
unsubscribe();
|
||||
goto('/scores');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadScoreInfo();
|
||||
await handleScoreSelect();
|
||||
await loadFiles();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function fileExists(tree: any[], targetPath: string): boolean {
|
||||
const parts = targetPath.split('/');
|
||||
let current = tree;
|
||||
|
||||
for (const part of parts) {
|
||||
const found = current.find((item: any) => item.name === part);
|
||||
if (!found) return false;
|
||||
if (found.type === 'file') {
|
||||
return true; // Found the file
|
||||
}
|
||||
current = found.children || [];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function uploadPdf() {
|
||||
if (!uploadFile || !uploadInstrument) {
|
||||
uploadError = 'Veuillez remplir tous les champs';
|
||||
return;
|
||||
}
|
||||
|
||||
uploadError = '';
|
||||
uploadSuccess = '';
|
||||
|
||||
// Check if file already exists
|
||||
const targetPath = `${uploadPiece}/${uploadInstrument}/${uploadVersion}/${expectedFilename}`;
|
||||
if (fileExists(files, targetPath)) {
|
||||
uploadError = `Le fichier existe déjà. Supprimez-le d'abord pour le remplacer.`;
|
||||
return;
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
try {
|
||||
const result = await apiService.uploadPdf(
|
||||
scoreId,
|
||||
uploadFile,
|
||||
uploadPiece,
|
||||
uploadInstrument,
|
||||
uploadVersion,
|
||||
uploadKey,
|
||||
uploadClef,
|
||||
uploadVariant,
|
||||
uploadPart
|
||||
);
|
||||
if (result.success) {
|
||||
uploadSuccess = 'Fichier uploadé avec succès';
|
||||
uploadFile = null;
|
||||
(document.getElementById('file-input') as HTMLInputElement).value = '';
|
||||
await loadFiles();
|
||||
} else {
|
||||
uploadError = result.error || 'Erreur';
|
||||
}
|
||||
} catch (err) {
|
||||
uploadError = 'Erreur lors de l\'upload';
|
||||
console.error(err);
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile() {
|
||||
if (!deleteFilePath) return;
|
||||
deletingFile = true;
|
||||
try {
|
||||
const result = await apiService.deleteScoreFile(scoreId, deleteFilePath);
|
||||
if (result.success) {
|
||||
await loadFiles();
|
||||
} else {
|
||||
error = result.error || 'Erreur lors de la suppression';
|
||||
}
|
||||
} catch (err) {
|
||||
error = 'Erreur lors de la suppression';
|
||||
console.error(err);
|
||||
} finally {
|
||||
deleteFilePath = null;
|
||||
deletingFile = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold text-ohmj-primary">
|
||||
{scoreName || 'Chargement...'}
|
||||
</h1>
|
||||
<a href="/admin" class="text-ohmj-primary hover:underline">← Retour à l'admin</a>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isAdmin}
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
Accès refusé. Vous devez être administrateur.
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Upload PDF -->
|
||||
<div class="mb-8 p-4 bg-gray-50 rounded-lg">
|
||||
<h2 class="text-xl font-semibold text-gray-700 mb-4">Uploader un PDF</h2>
|
||||
<form onsubmit={(e) => { e.preventDefault(); uploadPdf(); }} class="space-y-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Instrument</label>
|
||||
<select
|
||||
bind:value={uploadInstrument}
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
{#each INSTRUMENTS as inst}
|
||||
<option value={inst.code}>{inst.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showAdvanced = !showAdvanced; if (showAdvanced && !uploadKey) { uploadKey = INSTRUMENTS.find(i => i.code === uploadInstrument)?.defaultKey || ''; } }}
|
||||
class="text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
{showAdvanced ? '▼ Masquer' : '▶ Options avancées'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAdvanced}
|
||||
<div class="grid grid-cols-2 md:grid-cols-7 gap-4 p-3 bg-gray-100 rounded">
|
||||
{#if selectedScorePieceCount > 1}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Pièce</label>
|
||||
<select
|
||||
bind:value={uploadPiece}
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white"
|
||||
>
|
||||
{#each selectedScorePieces as piece}
|
||||
<option value={piece.id}>{piece.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Version</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={uploadVersion}
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Tonalité</label>
|
||||
<select bind:value={uploadKey} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="">-</option>
|
||||
<option value="sib">Si♭ (sib)</option>
|
||||
<option value="mib">Mi♭ (mif)</option>
|
||||
<option value="fa">Fa (fa)</option>
|
||||
<option value="do">Do (ut)</option>
|
||||
<option value="sol">Sol</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Clé</label>
|
||||
<select bind:value={uploadClef} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="">-</option>
|
||||
<option value="clesol">Sol</option>
|
||||
<option value="clefa">Fa</option>
|
||||
<option value="cleut">Ut</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Partie</label>
|
||||
<select bind:value={uploadPart} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Variante</label>
|
||||
<select bind:value={uploadVariant} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white">
|
||||
<option value="">-</option>
|
||||
<option value="solo">Solo</option>
|
||||
<option value="default">Default</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => showAdvanced = false}
|
||||
class="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Masquer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Fichier PDF</label>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onchange={(e) => uploadFile = (e.target as HTMLInputElement).files?.[0] || null}
|
||||
required
|
||||
class="mt-1 block w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
{#if expectedFilename}
|
||||
<p class="text-sm text-gray-500">Nom attendu: <code class="bg-gray-100 px-1 rounded">{expectedFilename}</code></p>
|
||||
{/if}
|
||||
{#if uploadError}
|
||||
<p class="text-red-600 text-sm">{uploadError}</p>
|
||||
{/if}
|
||||
{#if uploadSuccess}
|
||||
<p class="text-green-600 text-sm">{uploadSuccess}</p>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
class="bg-ohmj-primary text-white px-4 py-2 rounded hover:bg-ohmj-secondary disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Upload...' : 'Uploader'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- File Tree -->
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="p-4 border-b bg-gray-50">
|
||||
<h2 class="text-lg font-semibold text-gray-700">Fichiers</h2>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{#if files.length === 0}
|
||||
<p class="text-gray-500 text-center py-8">Aucun fichier uploadé</p>
|
||||
{:else}
|
||||
<div class="font-mono text-sm">
|
||||
{#each files as piece}
|
||||
<div class="mb-2">
|
||||
<span class="text-gray-700 font-semibold">📁 {piece.name}/</span>
|
||||
{#if piece.children}
|
||||
{#each piece.children as instrument}
|
||||
<div class="ml-4">
|
||||
<span class="text-gray-600">├─ 📁 {instrument.name}/</span>
|
||||
{#if instrument.children}
|
||||
{#each instrument.children as version}
|
||||
<div class="ml-4">
|
||||
<span class="text-gray-600">├─ 📁 {version.name}/</span>
|
||||
{#if version.children}
|
||||
{#each version.children as file, fileIndex}
|
||||
{@const isLast = fileIndex === version.children.length - 1}
|
||||
<div class="ml-4">
|
||||
|
||||
{#if isLast}
|
||||
<span class="text-gray-500">└─ 📄 {file.name}</span>
|
||||
<button
|
||||
onclick={() => deleteFilePath = file.path}
|
||||
class="ml-2 text-red-500 hover:text-red-700"
|
||||
title="Supprimer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-gray-500">├─ 📄 {file.name}</span>
|
||||
<button
|
||||
onclick={() => deleteFilePath = file.path}
|
||||
class="ml-2 text-red-500 hover:text-red-700"
|
||||
title="Supprimer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if deleteFilePath}
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onclick={() => deleteFilePath = null}>
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4" onclick={(e) => e.stopPropagation()}>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Confirmer la suppression</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mb-6 font-mono bg-gray-100 p-2 rounded">
|
||||
{deleteFilePath}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
onclick={() => deleteFilePath = null}
|
||||
class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onclick={deleteFile}
|
||||
disabled={deletingFile}
|
||||
class="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deletingFile ? 'Suppression...' : 'Supprimer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -14,6 +14,21 @@ const config = {
|
||||
}),
|
||||
alias: {
|
||||
$lib: './src/lib'
|
||||
},
|
||||
// Security: Content Security Policy
|
||||
csp: {
|
||||
directives: {
|
||||
'default-src': ['self'],
|
||||
'script-src': ['self', 'unsafe-inline'],
|
||||
'style-src': ['self', 'unsafe-inline'],
|
||||
'img-src': ['self', 'data:', 'blob:'],
|
||||
'connect-src': ['self', 'http://localhost:8000', 'https://*.ohmj.fr'],
|
||||
'font-src': ['self'],
|
||||
'object-src': ['none'],
|
||||
'frame-ancestors': ['none'],
|
||||
'base-uri': ['self'],
|
||||
'form-action': ['self']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user