[FEAT] First functional version.
This commit is contained in:
386
api/README.md
Normal file
386
api/README.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# OHMJ API Documentation
|
||||
|
||||
API REST pour la gestion des partitions de l'Harmonie de Montpellier-Jacou.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
## Authentification
|
||||
|
||||
L'API utilise JWT (JSON Web Token) pour l'authentification.
|
||||
|
||||
### Login
|
||||
|
||||
```http
|
||||
POST /login
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Corps de la requête :**
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "password"
|
||||
}
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"username": "admin",
|
||||
"role": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rôles disponibles :**
|
||||
- `admin` - Accès complet
|
||||
- `user` - Accès lecture seule
|
||||
|
||||
### Utilisation du token
|
||||
|
||||
Inclure le token dans l'en-tête `Authorization` :
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /scores
|
||||
|
||||
Liste toutes les partitions.
|
||||
|
||||
```http
|
||||
GET /scores
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"scores": [
|
||||
{
|
||||
"id": "102",
|
||||
"name": "A Legend from Yao",
|
||||
"compositor": "Yeh Shu-Han"
|
||||
},
|
||||
{
|
||||
"id": "390",
|
||||
"name": "La part d Euterpe",
|
||||
"compositor": "Michael CUVILLON"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /scores/:id
|
||||
|
||||
Détails d'une partition. Pour les partitions multi-morceaux (ex: 390), retourne tous les instruments de toutes les pièces.
|
||||
|
||||
```http
|
||||
GET /scores/102
|
||||
GET /scores/390
|
||||
```
|
||||
|
||||
**Réponse (score simple) :**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"score": {
|
||||
"id": "102",
|
||||
"name": "A Legend from Yao",
|
||||
"compositor": "Yeh Shu-Han",
|
||||
"instruments": [
|
||||
{
|
||||
"id": "cla",
|
||||
"title": "Clarinette",
|
||||
"piece": "1",
|
||||
"parts": [
|
||||
{
|
||||
"id": "1",
|
||||
"files": [
|
||||
{
|
||||
"name": "clarinette-sib-1",
|
||||
"filename": "clarinette-sib-1.pdf",
|
||||
"path": "102/1/cla/1/clarinette-sib-1.pdf",
|
||||
"part": "1",
|
||||
"key": "sib",
|
||||
"clef": null,
|
||||
"variant": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Réponse (partition multi-morceaux comme 390) :**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"score": {
|
||||
"id": "390",
|
||||
"name": "La part d Euterpe",
|
||||
"compositor": "Michael CUVILLON",
|
||||
"instruments": [
|
||||
{
|
||||
"id": "cla",
|
||||
"title": "Clarinette",
|
||||
"piece": "1",
|
||||
"parts": [...]
|
||||
},
|
||||
{
|
||||
"id": "sax",
|
||||
"title": "Sax Alto",
|
||||
"piece": "2",
|
||||
"parts": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /scores/:id/instruments
|
||||
|
||||
Liste les instruments d'une partition. Pour les partitions multi-morceaux (ex: 390), peut filtrer par pièce.
|
||||
|
||||
```http
|
||||
GET /scores/102/instruments
|
||||
GET /scores/390/instruments?piece=1
|
||||
```
|
||||
|
||||
**Paramètre optionnel :**
|
||||
- `piece` - Numéro de la pièce (pour partitions multi-morceaux)
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"instruments": [
|
||||
{
|
||||
"id": "cla",
|
||||
"title": "Clarinette",
|
||||
"piece": "1",
|
||||
"parts": [
|
||||
{
|
||||
"id": "1",
|
||||
"files": [
|
||||
{
|
||||
"name": "clarinette-sib-1",
|
||||
"filename": "clarinette-sib-1.pdf",
|
||||
"path": "102/1/cla/1/clarinette-sib-1.pdf",
|
||||
"part": "1",
|
||||
"key": "sib",
|
||||
"clef": null,
|
||||
"variant": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Champs des fichiers :**
|
||||
- `name` - Nom du fichier sans extension
|
||||
- `filename` - Nom du fichier avec extension
|
||||
- `path` - Chemin relatif pour download
|
||||
- `part` - Numéro de partie (ex: "1", "2_3")
|
||||
- `key` - Tonalité (ex: "sib", "mib", "fa")
|
||||
- `clef` - Clé (ex: "clesol", "clefa")
|
||||
- `variant` - Variante (ex: "solo", "default")
|
||||
|
||||
---
|
||||
|
||||
### GET /pieces/:scoreId
|
||||
|
||||
Liste les pièces d'une partition.
|
||||
|
||||
```http
|
||||
GET /pieces/102
|
||||
```
|
||||
|
||||
**Réponse :**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"pieces": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Pièce 1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /download/:path
|
||||
|
||||
Télécharge un fichier PDF.
|
||||
|
||||
```http
|
||||
GET /download/102/1/cla/1/clarinette-sib-1.pdf
|
||||
```
|
||||
|
||||
**Réponse :** Fichier PDF (Content-Type: application/pdf)
|
||||
|
||||
**Structure des chemins :**
|
||||
```
|
||||
NUM/PIECE/INSTRUMENT/VERSION/PARTIE.pdf
|
||||
```
|
||||
|
||||
**Instruments disponibles :**
|
||||
| Code | Instrument |
|
||||
|------|------------|
|
||||
| dir | Direction |
|
||||
| pic | Piccolo |
|
||||
| flu | Flûte |
|
||||
| cla | Clarinette |
|
||||
| clb | Clarinette basse |
|
||||
| sax | Saxophone alto |
|
||||
| sab | Saxophone baryton |
|
||||
| sat | Saxophone ténor |
|
||||
| cba | Contrebasse |
|
||||
| cor | Cor |
|
||||
| trp | Trompette |
|
||||
| trb | Trombone |
|
||||
| tub | Tuba |
|
||||
| htb | Hautbois |
|
||||
| bas | Basson |
|
||||
| per | Percussion |
|
||||
| crn | Cornet |
|
||||
| eup | Euphonium |
|
||||
|
||||
---
|
||||
|
||||
## Routes Admin
|
||||
|
||||
### POST /admin/scores
|
||||
|
||||
Créer une nouvelle partition.
|
||||
|
||||
```http
|
||||
POST /admin/scores
|
||||
Authorization: Bearer <token_admin>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Corps de la requête :**
|
||||
```json
|
||||
{
|
||||
"id": "200",
|
||||
"name": "Nouvelle partition",
|
||||
"compositor": "Compositeur"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PUT /admin/scores/:id
|
||||
|
||||
Mettre à jour une partition.
|
||||
|
||||
```http
|
||||
PUT /admin/scores/102
|
||||
Authorization: Bearer <token_admin>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Corps de la requête :**
|
||||
```json
|
||||
{
|
||||
"name": "Nouveau nom",
|
||||
"compositor": "Nouveau compositeur"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE /admin/scores/:id
|
||||
|
||||
Supprimer une partition.
|
||||
|
||||
```http
|
||||
DELETE /admin/scores/102
|
||||
Authorization: Bearer <token_admin>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /admin/upload
|
||||
|
||||
Uploader un fichier PDF.
|
||||
|
||||
```http
|
||||
POST /admin/upload
|
||||
Authorization: Bearer <token_admin>
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Corps de la requête :**
|
||||
```
|
||||
file: <fichier_pdf>
|
||||
scoreId: 102
|
||||
pieceId: 1
|
||||
instrument: cla
|
||||
version: 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Codes d'erreur
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| 200 | Succès |
|
||||
| 401 | Non authentifié |
|
||||
| 403 | Accès interdit (rôle insufficient) |
|
||||
| 404 | Ressource non trouvée |
|
||||
| 500 | Erreur serveur |
|
||||
|
||||
---
|
||||
|
||||
## Lancer le serveur
|
||||
|
||||
```bash
|
||||
cd api
|
||||
php -S localhost:8000 router.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Structure des fichiers
|
||||
|
||||
```
|
||||
legacy/Scores/
|
||||
├── 102/
|
||||
│ ├── 1/
|
||||
│ │ ├── cla/
|
||||
│ │ │ ├── 1/
|
||||
│ │ │ │ ├── clarinette-sib-1.pdf
|
||||
│ │ │ │ ├── clarinette-sib-2.pdf
|
||||
│ │ │ │ └── clarinette-mib-1.pdf
|
||||
│ │ │ └── 2/
|
||||
│ │ │ └── clarinette-sib-1.pdf
|
||||
│ │ ├── flu/
|
||||
│ │ └── sax/
|
||||
│ └── 2/
|
||||
└── score.ini
|
||||
```
|
||||
14
api/config/users.json
Normal file
14
api/config/users.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi",
|
||||
"role": "admin"
|
||||
},
|
||||
{
|
||||
"username": "user",
|
||||
"password": "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi",
|
||||
"role": "user"
|
||||
}
|
||||
]
|
||||
}
|
||||
245
api/index.php
Normal file
245
api/index.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Authorization, Content-Type');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/lib/Auth.php';
|
||||
require_once __DIR__ . '/lib/ScoreScanner.php';
|
||||
|
||||
$auth = new Auth();
|
||||
$scanner = new ScoreScanner('/home/jbnadal/sources/jb/ohmj/ohmj2/legacy/Scores/');
|
||||
|
||||
// Get Authorization header
|
||||
$token = null;
|
||||
$headers = getallheaders();
|
||||
if (isset($headers['Authorization'])) {
|
||||
$authHeader = $headers['Authorization'];
|
||||
if (preg_match('/Bearer\s+(.+)/i', $authHeader, $matches)) {
|
||||
$token = $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Parse request
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$path = trim($uri, '/');
|
||||
|
||||
// GET /download/:path - Download PDF (BEFORE auth check)
|
||||
if (preg_match('#^download/([^?]+)#', $path, $matches) && $method === 'GET') {
|
||||
$filePath = urldecode($matches[1]);
|
||||
|
||||
// Check token from header or query parameter
|
||||
$downloadToken = $_GET['token'] ?? $token;
|
||||
$downloadToken = urldecode($downloadToken ?? '');
|
||||
|
||||
if ($downloadToken === null || $downloadToken === '') {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Authorization required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = $auth->verifyToken($downloadToken);
|
||||
if ($user === null) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Invalid token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$fullPath = '/home/jbnadal/sources/jb/ohmj/ohmj2/legacy/Scores/' . $filePath;
|
||||
|
||||
if (!file_exists($fullPath) || !is_file($fullPath)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'File not found', 'path' => $fullPath]);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Length: ' . filesize($fullPath));
|
||||
header('Content-Disposition: attachment; filename="' . basename($fullPath) . '"');
|
||||
readfile($fullPath);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Route matching
|
||||
if ($path === 'login' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$username = $input['username'] ?? '';
|
||||
$password = $input['password'] ?? '';
|
||||
|
||||
$result = $auth->login($username, $password);
|
||||
|
||||
if ($result['success']) {
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'token' => $result['token'],
|
||||
'user' => $result['user']
|
||||
]);
|
||||
} else {
|
||||
http_response_code(401);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => $result['error']
|
||||
]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Protected routes - require auth
|
||||
if ($token === null) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Authorization required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$user = $auth->verifyToken($token);
|
||||
if ($user === null) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Invalid token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// GET /scores - List all scores
|
||||
if ($path === 'scores' && $method === 'GET') {
|
||||
$scores = $scanner->listScores();
|
||||
echo json_encode(['success' => true, 'scores' => $scores]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// GET /scores/:id - Get score details
|
||||
if (preg_match('#^scores/(\d+)$#', $path, $matches) && $method === 'GET') {
|
||||
$scoreId = $matches[1];
|
||||
$score = $scanner->getScore($scoreId);
|
||||
|
||||
if ($score === null) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Score not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'score' => $score]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// GET /scores/:id/instruments - Get instruments for a score
|
||||
// GET /scores/:id/instruments?piece=1 - Get instruments for a specific piece
|
||||
if (preg_match('#^scores/(\d+)/instruments$#', $path, $matches) && $method === 'GET') {
|
||||
$scoreId = $matches[1];
|
||||
$pieceId = $_GET['piece'] ?? null;
|
||||
$instruments = $scanner->getInstruments($scoreId, $pieceId);
|
||||
|
||||
echo json_encode(['success' => true, 'instruments' => $instruments]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// GET /pieces/:scoreId - Get pieces for a score
|
||||
if (preg_match('#^pieces/(\d+)$#', $path, $matches) && $method === 'GET') {
|
||||
$scoreId = $matches[1];
|
||||
$pieces = $scanner->getPieces($scoreId);
|
||||
|
||||
echo json_encode(['success' => true, 'pieces' => $pieces]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ADMIN ROUTES - require admin role
|
||||
if ($user['role'] !== 'admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Admin access required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// POST /admin/scores - Create new score
|
||||
if ($path === 'admin/scores' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$id = $input['id'] ?? null;
|
||||
$name = $input['name'] ?? '';
|
||||
$compositor = $input['compositor'] ?? '';
|
||||
|
||||
if (empty($id) || empty($name)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'ID and name required']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = $scanner->createScore($id, $name, $compositor);
|
||||
|
||||
if ($result['success']) {
|
||||
echo json_encode(['success' => true, 'score' => $result['score']]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $result['error']]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// PUT /admin/scores/:id - Update score
|
||||
if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'PUT') {
|
||||
$scoreId = $matches[1];
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$name = $input['name'] ?? null;
|
||||
$compositor = $input['compositor'] ?? null;
|
||||
|
||||
$result = $scanner->updateScore($scoreId, $name, $compositor);
|
||||
|
||||
if ($result['success']) {
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $result['error']]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// DELETE /admin/scores/:id - Delete score
|
||||
if (preg_match('#^admin/scores/(\d+)$#', $path, $matches) && $method === 'DELETE') {
|
||||
$scoreId = $matches[1];
|
||||
|
||||
$result = $scanner->deleteScore($scoreId);
|
||||
|
||||
if ($result['success']) {
|
||||
echo json_encode(['success' => true]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $result['error']]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// POST /admin/scores/:id/upload - Upload PDF
|
||||
if (preg_match('#^admin/scores/(\d+)/upload$#', $path, $matches) && $method === 'POST') {
|
||||
$scoreId = $matches[1];
|
||||
|
||||
if (!isset($_FILES['file'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'No file uploaded']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = $_FILES['file'];
|
||||
$piece = $_POST['piece'] ?? '1';
|
||||
$instrument = $_POST['instrument'] ?? '';
|
||||
$version = $_POST['version'] ?? '1';
|
||||
$filename = $_POST['filename'] ?? '';
|
||||
|
||||
$result = $scanner->uploadPdf($scoreId, $file, $piece, $instrument, $version, $filename);
|
||||
|
||||
if ($result['success']) {
|
||||
echo json_encode(['success' => true, 'path' => $result['path']]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $result['error']]);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// 404 Not Found
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Not found']);
|
||||
122
api/lib/Auth.php
Normal file
122
api/lib/Auth.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
class Auth {
|
||||
private const JWT_SECRET = 'ohmj_secret_key_change_in_production';
|
||||
private const JWT_ALGO = 'HS256';
|
||||
private const JWT_EXPIRY = 3600; // 1 hour
|
||||
|
||||
private string $usersFile;
|
||||
|
||||
public function __construct(string $usersFile = null) {
|
||||
$this->usersFile = $usersFile ?? __DIR__ . '/../config/users.json';
|
||||
}
|
||||
|
||||
public function login(string $username, string $password): array {
|
||||
$users = $this->loadUsers();
|
||||
|
||||
foreach ($users['users'] as $user) {
|
||||
if ($user['username'] === $username) {
|
||||
if (password_verify($password, $user['password'])) {
|
||||
$token = $this->generateToken($user);
|
||||
return [
|
||||
'success' => true,
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role']
|
||||
]
|
||||
];
|
||||
}
|
||||
return ['success' => false, 'error' => 'Invalid password'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => false, 'error' => 'User not found'];
|
||||
}
|
||||
|
||||
public function verifyToken(string $token): ?array {
|
||||
$parts = explode('.', $token);
|
||||
|
||||
if (count($parts) !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
list($header, $payload, $signature) = $parts;
|
||||
|
||||
// Verify signature
|
||||
$expectedSignature = base64_encode(
|
||||
hash_hmac('sha256', "$header.$payload", self::JWT_SECRET, true)
|
||||
);
|
||||
|
||||
if (!hash_equals($expectedSignature, $signature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
$payloadData = json_decode(base64_decode($payload), true);
|
||||
|
||||
// Check expiry
|
||||
if (isset($payloadData['exp']) && $payloadData['exp'] < time()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $payloadData;
|
||||
}
|
||||
|
||||
public function requireAuth(string $token): array {
|
||||
$payload = $this->verifyToken($token);
|
||||
|
||||
if ($payload === null) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function requireAdmin(string $token): array {
|
||||
$payload = $this->requireAuth($token);
|
||||
|
||||
if ($payload['role'] !== 'admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Forbidden']);
|
||||
exit;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function generateToken(array $user): string {
|
||||
$header = base64_encode(json_encode([
|
||||
'alg' => self::JWT_ALGO,
|
||||
'typ' => 'JWT'
|
||||
]));
|
||||
|
||||
$payload = base64_encode(json_encode([
|
||||
'username' => $user['username'],
|
||||
'role' => $user['role'],
|
||||
'iat' => time(),
|
||||
'exp' => time() + self::JWT_EXPIRY
|
||||
]));
|
||||
|
||||
$signature = base64_encode(
|
||||
hash_hmac('sha256', "$header.$payload", self::JWT_SECRET, true)
|
||||
);
|
||||
|
||||
return "$header.$payload.$signature";
|
||||
}
|
||||
|
||||
private function loadUsers(): array {
|
||||
if (!file_exists($this->usersFile)) {
|
||||
return ['users' => []];
|
||||
}
|
||||
|
||||
$content = file_get_contents($this->usersFile);
|
||||
return json_decode($content, true) ?? ['users' => []];
|
||||
}
|
||||
|
||||
public function hashPassword(string $password): string {
|
||||
return password_hash($password, PASSWORD_BCRYPT);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ class ScoreScanner {
|
||||
foreach ($directories as $dir) {
|
||||
if ($dir === '.' || $dir === '..') continue;
|
||||
|
||||
// Ignorer les fichiers cachés et non-numériques
|
||||
if (strpos($dir, '.') === 0) continue;
|
||||
if (!is_numeric($dir)) continue;
|
||||
|
||||
$scorePath = $this->scoresPath . $dir;
|
||||
if (!is_dir($scorePath)) continue;
|
||||
|
||||
@@ -38,12 +42,16 @@ class ScoreScanner {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ini = parse_ini_file($iniFile);
|
||||
$ini = @parse_ini_file($iniFile, true);
|
||||
if ($ini === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => $ini['name'] ?? 'Inconnu',
|
||||
'compositor' => $ini['compositor'] ?? 'Inconnu'
|
||||
'name' => $ini['info']['name'] ?? 'Inconnu',
|
||||
'compositor' => $ini['info']['compositor'] ?? 'Inconnu',
|
||||
'ressource' => $ini['info']['ressource'] ?? null
|
||||
];
|
||||
}
|
||||
|
||||
@@ -60,17 +68,28 @@ class ScoreScanner {
|
||||
}
|
||||
|
||||
$instruments = [];
|
||||
$entries = scandir($scoreDir);
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || $entry === 'score.ini') continue;
|
||||
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
||||
// First get all piece directories
|
||||
$pieceDirs = scandir($scoreDir);
|
||||
|
||||
foreach ($pieceDirs as $pieceDir) {
|
||||
if ($pieceDir === '.' || $pieceDir === '..' || $pieceDir === 'score.ini') continue;
|
||||
if (strpos($pieceDir, '.') === 0) continue;
|
||||
if (!is_dir($scoreDir . '/' . $pieceDir)) continue;
|
||||
|
||||
$instrumentPath = $scoreDir . '/' . $entry;
|
||||
if (!is_dir($instrumentPath)) continue;
|
||||
// Then get instrument directories
|
||||
$instrumentDirs = scandir($scoreDir . '/' . $pieceDir);
|
||||
|
||||
$instrument = $this->getInstrumentInfo($id, $entry);
|
||||
if ($instrument) {
|
||||
$instruments[] = $instrument;
|
||||
foreach ($instrumentDirs as $instrumentId) {
|
||||
if ($instrumentId === '.' || $instrumentId === '..') continue;
|
||||
if (strpos($instrumentId, '.') === 0) continue;
|
||||
if (!is_dir($scoreDir . '/' . $pieceDir . '/' . $instrumentId)) continue;
|
||||
|
||||
$instrument = $this->getInstrumentInfo($id, $instrumentId, $pieceDir);
|
||||
if ($instrument) {
|
||||
$instruments[] = $instrument;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +101,13 @@ class ScoreScanner {
|
||||
return array_merge($basicInfo, ['instruments' => $instruments]);
|
||||
}
|
||||
|
||||
private function getInstrumentInfo($scoreId, $instrumentId) {
|
||||
$instrumentPath = $this->scoresPath . $scoreId . '/' . $instrumentId;
|
||||
private function getInstrumentInfo($scoreId, $instrumentId, $pieceId = null) {
|
||||
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
||||
if ($pieceId !== null) {
|
||||
$instrumentPath = $this->scoresPath . $scoreId . '/' . $pieceId . '/' . $instrumentId;
|
||||
} else {
|
||||
$instrumentPath = $this->scoresPath . $scoreId . '/' . $instrumentId;
|
||||
}
|
||||
|
||||
if (!is_dir($instrumentPath)) {
|
||||
return null;
|
||||
@@ -95,11 +119,12 @@ class ScoreScanner {
|
||||
$entries = scandir($instrumentPath);
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
if (strpos($entry, '.') === 0) continue;
|
||||
|
||||
$partPath = $instrumentPath . '/' . $entry;
|
||||
if (!is_dir($partPath)) continue;
|
||||
|
||||
$part = $this->getPartInfo($scoreId, $instrumentId, $entry);
|
||||
$part = $this->getPartInfo($scoreId, $instrumentId, $entry, $pieceId);
|
||||
if ($part) {
|
||||
$parts[] = $part;
|
||||
}
|
||||
@@ -117,12 +142,18 @@ class ScoreScanner {
|
||||
return [
|
||||
'id' => $instrumentId,
|
||||
'title' => $title,
|
||||
'piece' => $pieceId,
|
||||
'parts' => $parts
|
||||
];
|
||||
}
|
||||
|
||||
private function getPartInfo($scoreId, $instrumentId, $partId) {
|
||||
$partPath = $this->scoresPath . $scoreId . '/' . $instrumentId . '/' . $partId;
|
||||
private function getPartInfo($scoreId, $instrumentId, $partId, $pieceId = null) {
|
||||
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
||||
if ($pieceId !== null) {
|
||||
$partPath = $this->scoresPath . $scoreId . '/' . $pieceId . '/' . $instrumentId . '/' . $partId;
|
||||
} else {
|
||||
$partPath = $this->scoresPath . $scoreId . '/' . $instrumentId . '/' . $partId;
|
||||
}
|
||||
|
||||
if (!is_dir($partPath)) {
|
||||
return null;
|
||||
@@ -133,6 +164,7 @@ class ScoreScanner {
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
if (strpos($entry, '.') === 0) continue;
|
||||
|
||||
$filePath = $partPath . '/' . $entry;
|
||||
if (!is_file($filePath)) continue;
|
||||
@@ -140,10 +172,20 @@ class ScoreScanner {
|
||||
// Vérifier que c'est un PDF
|
||||
if (strtolower(pathinfo($entry, PATHINFO_EXTENSION)) !== 'pdf') continue;
|
||||
|
||||
$relativePath = $pieceId !== null
|
||||
? "$scoreId/$pieceId/$instrumentId/$partId/$entry"
|
||||
: "$scoreId/$instrumentId/$partId/$entry";
|
||||
|
||||
$parsed = $this->parseFilename(pathinfo($entry, PATHINFO_FILENAME));
|
||||
|
||||
$files[] = [
|
||||
'name' => pathinfo($entry, PATHINFO_FILENAME),
|
||||
'filename' => $entry,
|
||||
'path' => $scoreId . '/' . $instrumentId . '/' . $partId . '/' . $entry
|
||||
'path' => $relativePath,
|
||||
'part' => $parsed['part'],
|
||||
'key' => $parsed['key'],
|
||||
'clef' => $parsed['clef'],
|
||||
'variant' => $parsed['variant']
|
||||
];
|
||||
}
|
||||
|
||||
@@ -191,4 +233,272 @@ class ScoreScanner {
|
||||
|
||||
return $names[$code] ?? $code;
|
||||
}
|
||||
|
||||
private function parseFilename(string $filename): array {
|
||||
$result = [
|
||||
'part' => null,
|
||||
'key' => null,
|
||||
'clef' => null,
|
||||
'variant' => null
|
||||
];
|
||||
|
||||
// Normalize: replace - by _ and lowercase
|
||||
$name = strtolower(str_replace('-', '_', $filename));
|
||||
|
||||
// Extract clef (clesol, clefa)
|
||||
if (preg_match('/(clesol|clefa)$/', $name, $m)) {
|
||||
$result['clef'] = $m[1];
|
||||
$name = preg_replace('/_(clesol|clefa)$/', '', $name);
|
||||
}
|
||||
|
||||
// Extract variant (solo, etc.)
|
||||
$variants = ['solo', 'default'];
|
||||
foreach ($variants as $variant) {
|
||||
if (strpos($name, $variant) !== false) {
|
||||
$result['variant'] = $variant;
|
||||
$name = str_replace('_' . $variant, '', $name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract key (tonalités: sib, mib, fa, do, etc.)
|
||||
$keys = ['sib', 'mib', 'reb', 'dob', 'solb', 'lab', 'mibemol', 'sibemol'];
|
||||
foreach ($keys as $key) {
|
||||
if (preg_match('/_' . $key . '/', $name)) {
|
||||
$result['key'] = $key;
|
||||
$name = preg_replace('/_' . $key . '/', '', $name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no compound keys found, try single keys
|
||||
if ($result['key'] === null) {
|
||||
$singleKeys = ['fa', 'do', 'ut', 'sol', 're', 'mi', 'si'];
|
||||
foreach ($singleKeys as $key) {
|
||||
if (preg_match('/_' . $key . '(?:_|$)/', $name)) {
|
||||
$result['key'] = $key;
|
||||
$name = preg_replace('/_' . $key . '/', '', $name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract part number (1, 2, 1_et_2, etc.) - should be last
|
||||
// Handle formats: 1, 2, 1_et_2
|
||||
if (preg_match('/_(\d+(?:_\w+)*)$/', $name, $m)) {
|
||||
$result['part'] = $m[1];
|
||||
$name = preg_replace('/_\d+(?:_\w+)*$/', '', $name);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// API Methods
|
||||
|
||||
public function listScores(): array {
|
||||
return $this->getAllScores();
|
||||
}
|
||||
|
||||
public function getScore(string $id): ?array {
|
||||
return $this->getScoreDetail($id);
|
||||
}
|
||||
|
||||
public function getPieces(string $scoreId): array {
|
||||
$scoreDir = $this->scoresPath . $scoreId;
|
||||
$iniFile = $scoreDir . '/score.ini';
|
||||
|
||||
if (!file_exists($iniFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ini = @parse_ini_file($iniFile, true);
|
||||
if ($ini === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pieces = [];
|
||||
|
||||
// Check if [pieces] section exists
|
||||
if (isset($ini['pieces'])) {
|
||||
$piecesSection = $ini['pieces'];
|
||||
$count = intval($piecesSection['count'] ?? 1);
|
||||
|
||||
for ($i = 1; $i <= $count; $i++) {
|
||||
$pieces[] = [
|
||||
'id' => $i,
|
||||
'name' => $piecesSection[$i] ?? "Pièce $i"
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $pieces;
|
||||
}
|
||||
|
||||
public function getInstruments(string $scoreId, ?string $pieceId = null): array {
|
||||
$scoreDir = $this->scoresPath . $scoreId;
|
||||
|
||||
if (!is_dir($scoreDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$instruments = [];
|
||||
|
||||
// New structure: NUM/PIECE/INSTRUMENT/VERSION/
|
||||
$pieceDirs = scandir($scoreDir);
|
||||
|
||||
foreach ($pieceDirs as $pieceDir) {
|
||||
if ($pieceDir === '.' || $pieceDir === '..' || $pieceDir === 'score.ini') continue;
|
||||
if (strpos($pieceDir, '.') === 0) continue;
|
||||
if (!is_dir($scoreDir . '/' . $pieceDir)) continue;
|
||||
|
||||
// Filter by piece if specified
|
||||
if ($pieceId !== null && $pieceDir !== $pieceId) continue;
|
||||
|
||||
$instrumentDirs = scandir($scoreDir . '/' . $pieceDir);
|
||||
|
||||
foreach ($instrumentDirs as $instrumentId) {
|
||||
if ($instrumentId === '.' || $instrumentId === '..') continue;
|
||||
if (strpos($instrumentId, '.') === 0) continue;
|
||||
if (!is_dir($scoreDir . '/' . $pieceDir . '/' . $instrumentId)) continue;
|
||||
|
||||
$instrument = $this->getInstrumentInfo($scoreId, $instrumentId, $pieceDir);
|
||||
if ($instrument) {
|
||||
$instruments[] = $instrument;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by title
|
||||
usort($instruments, function($a, $b) {
|
||||
return strcmp($a['title'], $b['title']);
|
||||
});
|
||||
|
||||
return $instruments;
|
||||
}
|
||||
|
||||
public function createScore(string $id, string $name, string $compositor): array {
|
||||
$scoreDir = $this->scoresPath . $id;
|
||||
|
||||
if (is_dir($scoreDir)) {
|
||||
return ['success' => false, 'error' => 'Score already exists'];
|
||||
}
|
||||
|
||||
if (!mkdir($scoreDir, 0755, true)) {
|
||||
return ['success' => false, 'error' => 'Failed to create directory'];
|
||||
}
|
||||
|
||||
$iniContent = "[info]\nname = $name\ncompositor = $compositor\n\n[pieces]\ncount = 1\n";
|
||||
|
||||
if (file_put_contents($scoreDir . '/score.ini', $iniContent) === false) {
|
||||
return ['success' => false, 'error' => 'Failed to create score.ini'];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'score' => [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'compositor' => $compositor
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function updateScore(string $scoreId, ?string $name, ?string $compositor): array {
|
||||
$scoreDir = $this->scoresPath . $scoreId;
|
||||
$iniFile = $scoreDir . '/score.ini';
|
||||
|
||||
if (!file_exists($iniFile)) {
|
||||
return ['success' => false, 'error' => 'Score not found'];
|
||||
}
|
||||
|
||||
$ini = @parse_ini_file($iniFile, true);
|
||||
if ($ini === false) {
|
||||
return ['success' => false, 'error' => 'Failed to parse score.ini'];
|
||||
}
|
||||
|
||||
// Update values
|
||||
if ($name !== null) {
|
||||
$ini['info']['name'] = $name;
|
||||
}
|
||||
if ($compositor !== null) {
|
||||
$ini['info']['compositor'] = $compositor;
|
||||
}
|
||||
|
||||
// Rebuild ini content
|
||||
$content = "[info]\n";
|
||||
$content .= "name = " . ($ini['info']['name'] ?? '') . "\n";
|
||||
$content .= "compositor = " . ($ini['info']['compositor'] ?? '') . "\n\n";
|
||||
$content .= "[pieces]\n";
|
||||
|
||||
if (isset($ini['pieces'])) {
|
||||
foreach ($ini['pieces'] as $key => $value) {
|
||||
$content .= "$key = $value\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (file_put_contents($iniFile, $content) === false) {
|
||||
return ['success' => false, 'error' => 'Failed to write score.ini'];
|
||||
}
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
public function deleteScore(string $scoreId): array {
|
||||
$scoreDir = $this->scoresPath . $scoreId;
|
||||
|
||||
if (!is_dir($scoreDir)) {
|
||||
return ['success' => false, 'error' => 'Score not found'];
|
||||
}
|
||||
|
||||
// Recursive delete
|
||||
$this->deleteDirectory($scoreDir);
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
private function deleteDirectory(string $dir): void {
|
||||
if (!is_dir($dir)) return;
|
||||
|
||||
$items = scandir($dir);
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') continue;
|
||||
|
||||
$path = $dir . '/' . $item;
|
||||
if (is_dir($path)) {
|
||||
$this->deleteDirectory($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
public function uploadPdf(string $scoreId, array $file, string $piece, string $instrument, string $version, string $filename): array {
|
||||
$scoreDir = $this->scoresPath . $scoreId;
|
||||
|
||||
if (!is_dir($scoreDir)) {
|
||||
return ['success' => false, 'error' => 'Score not found'];
|
||||
}
|
||||
|
||||
// Create directory structure: scoreId/piece/instrument/version
|
||||
$targetDir = $scoreDir . '/' . $piece . '/' . $instrument . '/' . $version;
|
||||
|
||||
if (!mkdir($targetDir, 0755, true)) {
|
||||
return ['success' => false, 'error' => 'Failed to create directory'];
|
||||
}
|
||||
|
||||
// Determine filename
|
||||
if (empty($filename)) {
|
||||
$filename = $file['name'];
|
||||
}
|
||||
|
||||
$targetPath = $targetDir . '/' . $filename;
|
||||
|
||||
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||
return ['success' => true, 'path' => "$scoreId/$piece/$instrument/$version/$filename"];
|
||||
}
|
||||
|
||||
return ['success' => false, 'error' => 'Failed to move uploaded file'];
|
||||
}
|
||||
}
|
||||
|
||||
12
api/router.php
Normal file
12
api/router.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
// Router script for PHP built-in server
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
|
||||
// Serve existing files directly
|
||||
if ($uri !== '/' && file_exists(__DIR__ . $uri)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Route everything through index.php
|
||||
require __DIR__ . '/index.php';
|
||||
Reference in New Issue
Block a user