Refatoring : Working state

This commit is contained in:
julien
2026-03-16 15:37:57 +01:00
parent aa00bec846
commit a5ca0df375
16 changed files with 251 additions and 568 deletions

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/**
* Migration 001 — Création du schéma principal de l'application.
*
* Cette baseline remplace l'historique détaillé des migrations tant que
* le projet est encore en développement et qu'aucune donnée n'est à conserver.
*/
return [
'up' => "
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL DEFAULT '',
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_posts_author_id ON posts(author_id);
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
url TEXT NOT NULL,
hash TEXT NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id);
CREATE INDEX IF NOT EXISTS idx_media_hash_user_id ON media(hash, user_id);
CREATE TABLE IF NOT EXISTS password_resets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT NOT NULL PRIMARY KEY,
attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT DEFAULT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
",
'down' => "
DROP TABLE IF EXISTS login_attempts;
DROP TABLE IF EXISTS password_resets;
DROP INDEX IF EXISTS idx_media_hash_user_id;
DROP INDEX IF EXISTS idx_media_user_id;
DROP TABLE IF EXISTS media;
DROP INDEX IF EXISTS idx_posts_author_id;
DROP TABLE IF EXISTS posts;
DROP TABLE IF EXISTS categories;
DROP TABLE IF EXISTS users;
",
];

View File

@@ -1,19 +0,0 @@
<?php
/**
* Migration 001 — Création de la table des utilisateurs.
*/
return [
'up' => "
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
",
'down' => 'DROP TABLE IF EXISTS users',
];

View File

@@ -1,23 +0,0 @@
<?php
/**
* Migration 002 — Création de la table des catégories.
*
* category_id est une clé étrangère nullable vers category(id).
* SET NULL garantit que les articles sont conservés si une catégorie est supprimée.
*
* SQLite ne supportant pas l'ajout de contrainte FK via ALTER TABLE,
* la référence est déclarée inline dans le ADD COLUMN — SQLite l'enregistre
* dans le schéma sans l'appliquer strictement à moins que PRAGMA foreign_keys = ON.
*/
return [
'up' => "
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL
);
",
'down' => 'DROP TABLE IF EXISTS categories',
];

View File

@@ -1,25 +1,9 @@
<?php
declare(strict_types=1);
/**
* Migration 006 Création de l'index FTS5 pour la recherche plein texte.
*
* Crée une table virtuelle FTS5 `posts_fts` indexant le titre, le contenu
* et le nom de l'auteur de chaque article. Le rowid de la table FTS correspond
* à l'id de l'article dans `posts`.
*
* Trois triggers maintiennent la synchronisation automatique entre `posts`
* et `posts_fts` à chaque INSERT, UPDATE et DELETE.
*
* La colonne `author_username` est résolue par sous-requête lors de l'écriture
* dans le trigger, ce qui évite de stocker une valeur dénormalisée en dehors
* de l'index FTS (dont le seul rôle est la recherche).
*
* Le contenu HTML est passé par `strip_tags()` (fonction PHP enregistrée sur la
* connexion PDO via `sqliteCreateFunction`) avant indexation, afin d'éviter que
* les balises et attributs HTML ne polluent l'index et ne génèrent du bruit.
*
* Tokenizer utilisé : unicode61 (défaut FTS5) gère les diacritiques et les
* caractères non-ASCII, insensible à la casse.
* Migration 002 Création de l'index de recherche FTS5 et de ses triggers.
*/
return [
'up' => "
@@ -57,12 +41,27 @@ return [
AFTER DELETE ON posts BEGIN
DELETE FROM posts_fts WHERE rowid = OLD.id;
END;
CREATE TRIGGER IF NOT EXISTS posts_fts_users_update
AFTER UPDATE OF username ON users BEGIN
DELETE FROM posts_fts
WHERE rowid IN (SELECT id FROM posts WHERE author_id = NEW.id);
INSERT INTO posts_fts(rowid, title, content, author_username)
SELECT p.id,
p.title,
COALESCE(strip_tags(p.content), ''),
NEW.username
FROM posts p
WHERE p.author_id = NEW.id;
END;
",
'down' => "
DROP TRIGGER IF EXISTS posts_fts_users_update;
DROP TRIGGER IF EXISTS posts_fts_delete;
DROP TRIGGER IF EXISTS posts_fts_update;
DROP TRIGGER IF EXISTS posts_fts_insert;
DROP TABLE IF EXISTS posts_fts;
DROP TABLE IF EXISTS posts_fts;
",
];

View File

@@ -1,31 +0,0 @@
<?php
/**
* Migration 003 — Création de la table des articles.
*
* author_id est une clé étrangère nullable vers users(id).
* SET NULL garantit que les articles sont conservés si l'auteur est supprimé.
*
* Index explicite sur author_id : SQLite n'indexe pas automatiquement les FK.
* Utilisé par findByUserId() (liste admin) et par les filtres de recherche FTS.
*/
return [
'up' => "
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL DEFAULT '',
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_posts_author_id ON posts(author_id);
",
'down' => "
DROP INDEX IF EXISTS idx_posts_author_id;
DROP TABLE IF EXISTS posts;
",
];

View File

@@ -1,22 +0,0 @@
<?php
return [
'up' => "
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
url TEXT NOT NULL,
hash TEXT NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id);
CREATE INDEX IF NOT EXISTS idx_media_hash_user_id ON media(hash, user_id);
",
'down' => "
DROP INDEX IF EXISTS idx_media_hash_user_id;
DROP INDEX IF EXISTS idx_media_user_id;
DROP TABLE IF EXISTS media;
",
];

View File

@@ -1,26 +0,0 @@
<?php
/**
* Migration 005 — Création de la table de réinitialisation de mot de passe.
*
* token_hash : hash SHA-256 du token envoyé par email — le token brut
* n'est jamais stocké en base pour limiter l'impact d'une fuite.
* expires_at : timestamp d'expiration (1 heure après création).
* used_at : timestamp de consommation — le token est invalidé après usage
* sans être supprimé immédiatement (traçabilité).
* user_id : CASCADE — supprime les tokens si l'utilisateur est supprimé.
*/
return [
'up' => "
CREATE TABLE IF NOT EXISTS password_resets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
",
'down' => 'DROP TABLE IF EXISTS password_resets',
];

View File

@@ -1,27 +0,0 @@
<?php
/**
* Migration 007 — Table de protection contre le brute-force.
*
* Stocke les tentatives de connexion échouées par adresse IP.
* Le champ `locked_until` est NULL tant que le seuil n'est pas atteint,
* puis contient la date/heure ISO 8601 de fin de verrouillage.
*
* Le nettoyage des entrées expirées est effectué par LoginAttemptRepository
* à chaque tentative pour éviter l'accumulation de lignes obsolètes,
* sans nécessiter de tâche planifiée.
*/
return [
'up' => "
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT NOT NULL PRIMARY KEY,
attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT DEFAULT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
",
'down' => "
DROP TABLE IF EXISTS login_attempts;
",
];

View File

@@ -1,25 +0,0 @@
<?php
return [
'up' => "
DROP TRIGGER IF EXISTS posts_fts_users_delete;
DROP TRIGGER IF EXISTS posts_fts_users_update;
CREATE TRIGGER IF NOT EXISTS posts_fts_users_update
AFTER UPDATE OF username ON users BEGIN
DELETE FROM posts_fts
WHERE rowid IN (SELECT id FROM posts WHERE author_id = NEW.id);
INSERT INTO posts_fts(rowid, title, content, author_username)
SELECT p.id,
p.title,
COALESCE(strip_tags(p.content), ''),
NEW.username
FROM posts p
WHERE p.author_id = NEW.id;
END;
",
'down' => "
DROP TRIGGER IF EXISTS posts_fts_users_update;
",
];