# Plan de Refactoring — API Backend

## 🔴 Priorité haute — Sécurité

### 1. Fixer les injections SQL dans `api/index.php`

**Problème** : Variables concaténées directement dans le SQL.

```php
// ❌ Actuel
$count_sql .= " AND p.postId = '$postId'";
$count_sql .= " AND (a.name LIKE '%$search%' OR p.text LIKE '%$search%')";
```

**Solution** : Migrer vers PDO avec prepared statements et paramètres bindés.

```php
// ✅ Cible
$params = [];
if ($postId !== null) {
    $sql .= " AND p.postId = :postId";
    $params[':postId'] = $postId;
}
if ($search !== '') {
    $sql .= " AND (a.name LIKE :search OR p.text LIKE :search)";
    $params[':search'] = "%$search%";
}
```

**Fichiers concernés** :

- [x] `api/index.php` — endpoint principal (le plus critique)

---

### 2. Fixer les injections SQL dans `admin/`

**Problème** : Mêmes vulnérabilités dans les pages d'administration.

```php
// ❌ Actuel (admin/tags.php)
$mysqli->query("UPDATE tags SET name = '$name' WHERE id = $id");

// ❌ Actuel (admin/index.php)
$mysqli->query("UPDATE artists SET country='$country', summary='$summary' WHERE id=$id");
```

**Solution** : Prepared statements pour toutes les actions POST.

**Fichiers concernés** :

- [ ] `admin/tags.php` — toggle_hide, update_name
- [ ] `admin/index.php` — update_artist, update_tags, clear_bc_location, copy_bc_summary, validate_artist

---

### 3. ~~Sécuriser `delete/delete.php`~~ — SUPPRIMÉ

Fichier supprimé — non référencé dans le code, risque de sécurité sans bénéfice.

---

### 4. Protéger les endpoints admin

**Problème** : `admin/` et `delete/` sont accessibles publiquement sans contrôle.

**Solution** : Ajouter un `.htaccess` avec authentification basique.

- [ ] Créer `admin/.htaccess`
- [ ] Créer `delete/.htaccess`
- [ ] Créer le fichier `.htpasswd` correspondant (hors repo)

```apache
# admin/.htaccess
AuthType Basic
AuthName "Administration"
AuthUserFile /home/sc2hudo0166/.htpasswd
Require valid-user
```

---

### 5. Sortir les secrets du code

**Problème** : Credentials en dur dans des fichiers versionnés.

| Fichier            | Secret                             |
| ------------------ | ---------------------------------- |
| `settings.php`     | DB user/pass, Bluesky app password |
| `scrape/index.php` | Clé API Last.fm                    |

**Solution** :

- [ ] Créer `.env` (non versionné)
- [ ] Créer `.env.example` (template versionné)
- [ ] Créer `config.php` qui lit `.env`
- [ ] Ajouter `.env` au `.gitignore`

```ini
# .env
DB_HOST=localhost
DB_USER=sc2hudo0166_unmute
DB_PASS=xxxxx
DB_NAME=sc2hudo0166_unmute

BLUESKY_IDENTIFIER=unmute.fr
BLUESKY_APP_PASSWORD=xxxxx

LASTFM_API_KEY=xxxxx
```

```php
// config.php
<?php
$envFile = __DIR__ . '/.env';
if (file_exists($envFile)) {
    $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    foreach ($lines as $line) {
        if (str_starts_with(trim($line), '#')) continue;
        putenv(trim($line));
    }
}

return [
    'db' => [
        'host' => getenv('DB_HOST') ?: 'localhost',
        'user' => getenv('DB_USER') ?: 'root',
        'pass' => getenv('DB_PASS') ?: 'root',
        'name' => getenv('DB_NAME') ?: 'adhavetfm',
    ],
    'bluesky' => [
        'identifier' => getenv('BLUESKY_IDENTIFIER'),
        'app_password' => getenv('BLUESKY_APP_PASSWORD'),
    ],
    'lastfm_api_key' => getenv('LASTFM_API_KEY'),
    'pagination' => 100,
];
```

---

## 🟡 Priorité moyenne — Architecture

### 6. Créer `core/bootstrap.php`

**Problème** : Chaque fichier répète connexion DB + headers CORS + display_errors.

**Solution** : Un fichier inclus par tous les services.

- [x] Créer `core/Database.php`
- [x] Créer `core/Response.php`
- [x] Créer `core/bootstrap.php`

```php
// core/Database.php
<?php
class Database {
    private static ?PDO $instance = null;

    public static function get(): PDO {
        if (self::$instance === null) {
            $config = require __DIR__ . '/../config.php';
            $db = $config['db'];
            self::$instance = new PDO(
                "mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4",
                $db['user'],
                $db['pass'],
                [
                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                ]
            );
        }
        return self::$instance;
    }
}
```

```php
// core/Response.php
<?php
class Response {
    public static function json($data, int $code = 200): void {
        http_response_code($code);
        echo json_encode($data, JSON_UNESCAPED_UNICODE);
        exit;
    }

    public static function error(string $message, int $code = 400): void {
        self::json(['error' => $message], $code);
    }

    public static function success($data = null): void {
        self::json($data ?? ['success' => true]);
    }
}
```

```php
// core/bootstrap.php
<?php
// Error handling
$config = require __DIR__ . '/../config.php';
$env = $config['db']['host'] === 'localhost' && $config['db']['user'] === 'root' ? 'dev' : 'prod';

if ($env === 'dev') {
    ini_set('display_errors', 1);
    error_reporting(E_ALL);
} else {
    ini_set('display_errors', 0);
    error_reporting(0);
}

// CORS
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, Origin, Accept, X-Requested-With');
header('Content-Type: application/json; charset=UTF-8');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(200);
    exit;
}

// Autoload core classes
require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/Response.php';
```

---

### 7. Migrer les services vers le bootstrap

Chaque fichier passe de ~20 lignes de boilerplate à 2 lignes :

```php
// Avant (ex: count/index.php)
<?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
include '../settings.php';
$mysqli = new mysqli($server, $user, $pass, $db);
$mysqli->set_charset("utf8mb4");
if ($mysqli->connect_errno) { ... }
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST');
header("Access-Control-Allow-Headers: X-Requested-With");
header('Content-Type: application/json');
// ... logique
```

```php
// Après
<?php
require_once __DIR__ . '/../core/bootstrap.php';
$pdo = Database::get();
// ... logique uniquement
```

**Fichiers à migrer** (par ordre de simplicité) :

- [x] `count/index.php`
- [x] `random/index.php`
- [x] `bookmark/index.php`
- [x] `view/index.php`
- [x] `tags/index.php`
- [x] `calendar/index.php`
- [ ] `stats/index.php`
- [x] `recommend/index.php`
- [x] `api/index.php`
- [x] `user/create/index.php`
- [x] `user/login/index.php`
- [x] `user/update/index.php`

---

### 8. Extraire `UserPostRelation` pour bookmark/view

**Problème** : `bookmark/index.php` et `view/index.php` sont identiques à 95%.

**Solution** :

- [ ] Créer `core/UserPostRelation.php`
- [ ] Simplifier `bookmark/index.php`
- [ ] Simplifier `view/index.php`

```php
// core/UserPostRelation.php
<?php
class UserPostRelation {
    private PDO $pdo;
    private string $table;

    public function __construct(PDO $pdo, string $table) {
        $this->pdo = $pdo;
        $this->table = $table;
    }

    public function list(int $userId): array {
        $stmt = $this->pdo->prepare("
            SELECT p.postId, p.source, r.created_at
            FROM {$this->table} r
            INNER JOIN posts p ON p.postId = r.postId
            WHERE r.userId = ?
            ORDER BY r.created_at DESC
        ");
        $stmt->execute([$userId]);
        return $stmt->fetchAll();
    }

    public function add(int $userId, string $postId): void {
        $stmt = $this->pdo->prepare("
            INSERT INTO {$this->table} (userId, postId)
            VALUES (?, ?)
            ON DUPLICATE KEY UPDATE created_at = CURRENT_TIMESTAMP
        ");
        $stmt->execute([$userId, $postId]);
    }

    public function remove(int $userId, string $postId): void {
        $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE userId = ? AND postId = ?");
        $stmt->execute([$userId, $postId]);
    }
}
```

```php
// bookmark/index.php (après refacto)
<?php
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../core/UserPostRelation.php';

$pdo = Database::get();
$bookmarks = new UserPostRelation($pdo, 'bookmarks');
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents("php://input"), true);
$userId = isset($_GET['userId']) ? intval($_GET['userId']) : ($input['userId'] ?? null);
$postId = $input['postId'] ?? null;

if (!$userId) Response::error("Missing userId");

match ($method) {
    'GET' => Response::json($bookmarks->list($userId)),
    'POST' => $postId ? ($bookmarks->add($userId, $postId) || Response::success()) : Response::error("Missing postId"),
    'DELETE' => $postId ? ($bookmarks->remove($userId, $postId) || Response::success()) : Response::error("Missing postId"),
    default => Response::error("Method not allowed", 405),
};
```

---

## 🟢 Priorité basse — Optimisations

### 9. Ajouter un cache pour `stats/`

**Problème** : 70+ requêtes SQL à chaque appel (8 par source × 9 sources).

**Solution** : Cache fichier JSON avec TTL.

- [ ] Créer un mécanisme de cache dans `stats/index.php`

```php
$cacheFile = __DIR__ . '/../cache/stats.json';
$cacheTTL = 300; // 5 minutes

if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTTL) {
    header('X-Cache: HIT');
    readfile($cacheFile);
    exit;
}

// ... calcul normal ...

// Sauvegarder en cache
if (!is_dir(dirname($cacheFile))) mkdir(dirname($cacheFile), 0755, true);
file_put_contents($cacheFile, json_encode($data));
```

---

### 10. Refactorer `scrape/index.php`

**Problème** : Fichier monolithique (~400 lignes). La fonction `importBlueskyHashtagPosts` gère tout.

**Solution** : Extraire en classes.

- [ ] `scrape/src/BlueskyClient.php` — Auth + fetch feed
- [ ] `scrape/src/PostParser.php` — Extraction texte, liens, artistes
- [ ] `scrape/src/ImageProcessor.php` — Download, conversion WebP, resize
- [ ] `scrape/src/PostImporter.php` — Logique d'insertion DB

---

### 11. Standardiser sur PDO

**Fichiers utilisant encore mysqli** :

- [ ] `api/index.php`
- [ ] `calendar/index.php`
- [ ] `count/index.php`
- [ ] `random/index.php`
- [ ] `tags/index.php`
- [ ] `stats/index.php`
- [ ] `recommend/index.php`
- [ ] `admin/index.php`
- [ ] `admin/tags.php`
- [ ] `delete/delete.php`
- [ ] `scrape/index.php` (utilise les deux)

---

## Résumé — Effort estimé

| Bloc                | Tâches  | Effort | Impact               |
| ------------------- | ------- | ------ | -------------------- |
| Injections SQL      | 1, 2, 3 | 3-4h   | 🔴 Sécurité critique |
| Auth admin          | 4       | 30min  | 🔴 Sécurité          |
| Secrets             | 5       | 1h     | 🔴 Sécurité          |
| Bootstrap           | 6, 7    | 3h     | 🟡 Maintenabilité    |
| Factorisation       | 8       | 30min  | 🟡 DRY               |
| Cache stats         | 9       | 1h     | 🟢 Performance       |
| Refacto scrape      | 10      | 3-4h   | 🟢 Maintenabilité    |
| Standardisation PDO | 11      | 2-3h   | 🟢 Cohérence         |

**Total estimé : ~15h de travail, découpable en sessions indépendantes.**
