From 75f808bee4bd78bb890f2bc8f67bcdbef5dec4f6 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Fri, 17 Apr 2026 11:44:08 +0200 Subject: [PATCH] feat: extract MediaController, wire into Dispatcher, delete media.php --- DEV.md | 22 --- README.md | 58 +++--- TODO.md | 64 +++++-- {config => app}/bootstrap.php | 10 +- {public => app/public}/admin/.htaccess | 0 {public => app/public}/admin/README.md | 0 .../public}/admin/acces-etudiante.php | 4 +- {public => app/public}/admin/account.php | 23 ++- .../public}/admin/actions/acces-etudiante.php | 2 +- .../public}/admin/actions/account.php | 64 ++----- .../public}/admin/actions/apropos.php | 2 +- .../public}/admin/actions/delete.php | 2 +- {public => app/public}/admin/actions/edit.php | 4 +- app/public/admin/actions/export-csv.php | 36 ++++ app/public/admin/actions/export-db.php | 29 +++ .../public}/admin/actions/formulaire.php | 4 +- .../public}/admin/actions/maintenance.php | 2 +- .../public}/admin/actions/publish.php | 2 +- .../public}/admin/actions/settings.php | 19 +- {public => app/public}/admin/actions/tag.php | 2 +- .../public}/admin/actions/visibility.php | 2 +- {public => app/public}/admin/add.php | 6 +- .../public}/admin/contenus-edit.php | 2 +- {public => app/public}/admin/contenus.php | 4 +- {public => app/public}/admin/edit.php | 6 +- {public => app/public}/admin/import.php | 0 {public => app/public}/admin/index.php | 11 +- {public => app/public}/admin/login.php | 6 +- {public => app/public}/admin/logout.php | 2 +- {public => app/public}/admin/logs.php | 0 {public => app/public}/admin/parametres.php | 139 ++++++++++++-- {public => app/public}/admin/status.php | 0 .../public}/admin/system-fragment.php | 4 +- {public => app/public}/admin/system.php | 4 +- {public => app/public}/admin/tags.php | 4 +- {public => app/public}/admin/thanks.php | 4 +- {public => app/public}/apropos.php | 2 +- {public => app/public}/assets/css/README.md | 0 {public => app/public}/assets/css/admin.css | 162 ++++++++++++++-- {public => app/public}/assets/css/apropos.css | 0 {public => app/public}/assets/css/common.css | 0 {public => app/public}/assets/css/main.css | 0 .../assets/css/modern-normalize.min.css | 0 {public => app/public}/assets/css/search.css | 0 {public => app/public}/assets/css/system.css | 0 {public => app/public}/assets/css/tfe.css | 0 .../public}/assets/css/variables.css | 0 {public => app/public}/assets/favicon.svg | 0 .../assets/favicon/android-chrome-192x192.png | Bin .../assets/favicon/android-chrome-512x512.png | Bin .../favicon/apple-touch-icon-152x152.png | Bin .../favicon/apple-touch-icon-167x167.png | Bin .../favicon/apple-touch-icon-180x180.png | Bin .../assets/favicon/favicon-128x128.png | Bin .../public}/assets/favicon/favicon-16x16.png | Bin .../public}/assets/favicon/favicon-32x32.png | Bin .../public}/assets/favicon/favicon-48x48.png | Bin .../public}/assets/favicon/favicon-64x64.png | Bin .../public}/assets/favicon/favicon-96x96.png | Bin .../public}/assets/favicon/favicon.ico | Bin .../public}/assets/favicon/favicon_html.txt | 0 .../public}/assets/favicon/site.webmanifest | 0 .../public}/assets/fonts/BBBDMSans-Bold.otf | Bin .../public}/assets/fonts/BBBDMSans-Light.otf | Bin .../public}/assets/fonts/BBBDMSans-Medium.otf | Bin .../assets/fonts/BBBDMSans-Regular.otf | Bin .../public}/assets/fonts/DuctusRegular.otf | Bin {public => app/public}/assets/js/htmx.min.js | 0 .../public}/assets/js/overtype.min.js | 0 {public => app/public}/index.php | 4 +- {public => app/public}/licence.php | 2 +- {public => app/public}/live-reload.php | 0 {public => app/public}/maintenance.php | 0 {public => app/public}/partage/.htaccess | 0 {public => app/public}/partage/index.php | 6 +- {public => app/public}/partage/thanks.php | 2 +- {public => app/public}/repertoire.php | 4 +- {public => app/public}/search.php | 4 +- {public => app/public}/tfe.php | 4 +- {config => app}/router.php | 4 +- {src => app/src}/AdminAuth.php | 101 +++++++--- {src => app/src}/App.php | 0 app/src/Controllers/AboutController.php | 43 +++++ app/src/Controllers/ExportController.php | 166 ++++++++++++++++ .../src/Controllers}/HomeController.php | 0 app/src/Controllers/LicenceController.php | 36 ++++ app/src/Controllers/LiveReloadController.php | 57 ++++++ app/src/Controllers/MediaController.php | 114 +++++++++++ .../src/Controllers}/SearchController.php | 0 .../src/Controllers}/SystemController.php | 0 .../src/Controllers}/TfeController.php | 0 .../Controllers}/ThesisCreateController.php | 0 .../src/Controllers}/ThesisEditController.php | 0 {src => app/src}/Database.php | 95 ++++++++- app/src/Dispatcher.php | 151 +++++++++++++++ {src => app/src}/Parsedown.php | 0 {src => app/src}/RateLimit.php | 0 {src => app/src}/ShareLink.php | 0 app/src/SmtpRelay.php | 181 ++++++++++++++++++ {src => app/src}/SystemCache.php | 0 {storage => app/storage}/.gitkeep | 0 .../storage}/Database_TFE_test.csv | 0 {storage => app/storage}/README.md | 0 .../db.sqlite => app/storage/cache/.gitkeep | 0 .../ad921d60486366258809553a3db49a4a.json | 1 + .../storage}/fixtures/CreateTestDatabase.php | 0 .../001_rename_keywords_to_tags.sql | 0 .../migrations/002_add_visibility.sql | 0 .../migrations/003_seed_license_types.sql | 0 .../storage}/migrations/004_jury_roles.sql | 0 .../storage}/migrations/005_add_banner.sql | 0 .../migrations/006_add_composite_index.sql | 0 .../storage}/migrations/007_system_cache.sql | 0 .../migrations/008_formulaire_settings.sql | 0 .../storage}/migrations/009_share_links.sql | 0 .../migrations/010_apropos_contents.sql | 0 .../migrations/011_apropos_entries.sql | 0 app/storage/migrations/012_smtp_settings.sql | 22 +++ app/storage/migrations/013_admin_password.sql | 11 ++ {storage => app/storage}/posterg.db | Bin 262144 -> 270336 bytes {storage => app/storage}/schema.sql | 22 ++- app/storage/test.db | Bin 0 -> 569344 bytes app/templates/admin/footer.php | 39 ++++ {templates => app/templates}/footer.php | 0 {templates => app/templates}/head.php | 0 {templates => app/templates}/header.php | 2 +- app/templates/partials/flash-messages.php | 21 ++ .../partials/form/checkbox-list.php | 0 .../templates}/partials/form/file-field.php | 0 .../partials/form/jury-fieldset.php | 0 .../templates}/partials/form/select-field.php | 0 .../templates}/partials/form/text-field.php | 0 .../templates}/partials/pagination.php | 0 .../templates}/partials/repertoire-index.php | 0 .../templates}/partials/status-badge.php | 0 app/templates/public/home.php | 49 +++++ app/templates/public/licence.php | 9 + {templates => app/templates}/search-bar.php | 0 .../tests}/Integration/SearchTest.php | 0 {tests => app/tests}/README.md | 0 .../tests}/Security/SecurityTest.php | 0 {tests => app/tests}/Unit/DatabaseTest.php | 0 {tests => app/tests}/Unit/RateLimitTest.php | 0 {tests => app/tests}/run-tests.php | 0 config/admin_credentials.example.php | 13 -- {src => config}/config.php | 0 docs/ANALYSIS_STRUCTURE_REORG.md | 58 ++++++ SETUP.md => docs/SETUP.md | 0 SPECS.md => docs/SPECS.md | 0 justfile | 50 +++-- nginx/posterg.conf | 21 +- public/media.php | 121 ------------ scripts/migrate.sh | 18 +- storage/thesis.db | 0 templates/admin/footer.php | 9 - templates/partials/flash-messages.php | 18 -- test.db | 0 157 files changed, 1713 insertions(+), 452 deletions(-) delete mode 100644 DEV.md rename {config => app}/bootstrap.php (79%) rename {public => app/public}/admin/.htaccess (100%) rename {public => app/public}/admin/README.md (100%) rename {public => app/public}/admin/acces-etudiante.php (98%) rename {public => app/public}/admin/account.php (79%) rename {public => app/public}/admin/actions/acces-etudiante.php (97%) rename {public => app/public}/admin/actions/account.php (54%) rename {public => app/public}/admin/actions/apropos.php (97%) rename {public => app/public}/admin/actions/delete.php (96%) rename {public => app/public}/admin/actions/edit.php (92%) create mode 100644 app/public/admin/actions/export-csv.php create mode 100644 app/public/admin/actions/export-db.php rename {public => app/public}/admin/actions/formulaire.php (90%) rename {public => app/public}/admin/actions/maintenance.php (94%) rename {public => app/public}/admin/actions/publish.php (97%) rename {public => app/public}/admin/actions/settings.php (56%) rename {public => app/public}/admin/actions/tag.php (96%) rename {public => app/public}/admin/actions/visibility.php (96%) rename {public => app/public}/admin/add.php (97%) rename {public => app/public}/admin/contenus-edit.php (99%) rename {public => app/public}/admin/contenus.php (95%) rename {public => app/public}/admin/edit.php (97%) rename {public => app/public}/admin/import.php (100%) rename {public => app/public}/admin/index.php (98%) rename {public => app/public}/admin/login.php (88%) rename {public => app/public}/admin/logout.php (69%) rename {public => app/public}/admin/logs.php (100%) rename {public => app/public}/admin/parametres.php (58%) rename {public => app/public}/admin/status.php (100%) rename {public => app/public}/admin/system-fragment.php (98%) rename {public => app/public}/admin/system.php (99%) rename {public => app/public}/admin/tags.php (97%) rename {public => app/public}/admin/thanks.php (98%) rename {public => app/public}/apropos.php (99%) rename {public => app/public}/assets/css/README.md (100%) rename {public => app/public}/assets/css/admin.css (91%) rename {public => app/public}/assets/css/apropos.css (100%) rename {public => app/public}/assets/css/common.css (100%) rename {public => app/public}/assets/css/main.css (100%) rename {public => app/public}/assets/css/modern-normalize.min.css (100%) rename {public => app/public}/assets/css/search.css (100%) rename {public => app/public}/assets/css/system.css (100%) rename {public => app/public}/assets/css/tfe.css (100%) rename {public => app/public}/assets/css/variables.css (100%) rename {public => app/public}/assets/favicon.svg (100%) rename {public => app/public}/assets/favicon/android-chrome-192x192.png (100%) rename {public => app/public}/assets/favicon/android-chrome-512x512.png (100%) rename {public => app/public}/assets/favicon/apple-touch-icon-152x152.png (100%) rename {public => app/public}/assets/favicon/apple-touch-icon-167x167.png (100%) rename {public => app/public}/assets/favicon/apple-touch-icon-180x180.png (100%) rename {public => app/public}/assets/favicon/favicon-128x128.png (100%) rename {public => app/public}/assets/favicon/favicon-16x16.png (100%) rename {public => app/public}/assets/favicon/favicon-32x32.png (100%) rename {public => app/public}/assets/favicon/favicon-48x48.png (100%) rename {public => app/public}/assets/favicon/favicon-64x64.png (100%) rename {public => app/public}/assets/favicon/favicon-96x96.png (100%) rename {public => app/public}/assets/favicon/favicon.ico (100%) rename {public => app/public}/assets/favicon/favicon_html.txt (100%) rename {public => app/public}/assets/favicon/site.webmanifest (100%) rename {public => app/public}/assets/fonts/BBBDMSans-Bold.otf (100%) rename {public => app/public}/assets/fonts/BBBDMSans-Light.otf (100%) rename {public => app/public}/assets/fonts/BBBDMSans-Medium.otf (100%) rename {public => app/public}/assets/fonts/BBBDMSans-Regular.otf (100%) rename {public => app/public}/assets/fonts/DuctusRegular.otf (100%) rename {public => app/public}/assets/js/htmx.min.js (100%) rename {public => app/public}/assets/js/overtype.min.js (100%) rename {public => app/public}/index.php (96%) rename {public => app/public}/licence.php (96%) rename {public => app/public}/live-reload.php (100%) rename {public => app/public}/maintenance.php (100%) rename {public => app/public}/partage/.htaccess (100%) rename {public => app/public}/partage/index.php (99%) rename {public => app/public}/partage/thanks.php (97%) rename {public => app/public}/repertoire.php (86%) rename {public => app/public}/search.php (97%) rename {public => app/public}/tfe.php (99%) rename {config => app}/router.php (86%) rename {src => app/src}/AdminAuth.php (50%) rename {src => app/src}/App.php (100%) create mode 100644 app/src/Controllers/AboutController.php create mode 100644 app/src/Controllers/ExportController.php rename {src => app/src/Controllers}/HomeController.php (100%) create mode 100644 app/src/Controllers/LicenceController.php create mode 100644 app/src/Controllers/LiveReloadController.php create mode 100644 app/src/Controllers/MediaController.php rename {src => app/src/Controllers}/SearchController.php (100%) rename {src => app/src/Controllers}/SystemController.php (100%) rename {src => app/src/Controllers}/TfeController.php (100%) rename {src => app/src/Controllers}/ThesisCreateController.php (100%) rename {src => app/src/Controllers}/ThesisEditController.php (100%) rename {src => app/src}/Database.php (95%) create mode 100644 app/src/Dispatcher.php rename {src => app/src}/Parsedown.php (100%) rename {src => app/src}/RateLimit.php (100%) rename {src => app/src}/ShareLink.php (100%) create mode 100644 app/src/SmtpRelay.php rename {src => app/src}/SystemCache.php (100%) rename {storage => app/storage}/.gitkeep (100%) rename {storage => app/storage}/Database_TFE_test.csv (100%) rename {storage => app/storage}/README.md (100%) rename storage/db.sqlite => app/storage/cache/.gitkeep (100%) create mode 100644 app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json rename {storage => app/storage}/fixtures/CreateTestDatabase.php (100%) rename {storage => app/storage}/migrations/001_rename_keywords_to_tags.sql (100%) rename {storage => app/storage}/migrations/002_add_visibility.sql (100%) rename {storage => app/storage}/migrations/003_seed_license_types.sql (100%) rename {storage => app/storage}/migrations/004_jury_roles.sql (100%) rename {storage => app/storage}/migrations/005_add_banner.sql (100%) rename {storage => app/storage}/migrations/006_add_composite_index.sql (100%) rename {storage => app/storage}/migrations/007_system_cache.sql (100%) rename {storage => app/storage}/migrations/008_formulaire_settings.sql (100%) rename {storage => app/storage}/migrations/009_share_links.sql (100%) rename {storage => app/storage}/migrations/010_apropos_contents.sql (100%) rename {storage => app/storage}/migrations/011_apropos_entries.sql (100%) create mode 100644 app/storage/migrations/012_smtp_settings.sql create mode 100644 app/storage/migrations/013_admin_password.sql rename {storage => app/storage}/posterg.db (96%) rename {storage => app/storage}/schema.sql (95%) create mode 100644 app/storage/test.db create mode 100644 app/templates/admin/footer.php rename {templates => app/templates}/footer.php (100%) rename {templates => app/templates}/head.php (100%) rename {templates => app/templates}/header.php (98%) create mode 100644 app/templates/partials/flash-messages.php rename {templates => app/templates}/partials/form/checkbox-list.php (100%) rename {templates => app/templates}/partials/form/file-field.php (100%) rename {templates => app/templates}/partials/form/jury-fieldset.php (100%) rename {templates => app/templates}/partials/form/select-field.php (100%) rename {templates => app/templates}/partials/form/text-field.php (100%) rename {templates => app/templates}/partials/pagination.php (100%) rename {templates => app/templates}/partials/repertoire-index.php (100%) rename {templates => app/templates}/partials/status-badge.php (100%) create mode 100644 app/templates/public/home.php create mode 100644 app/templates/public/licence.php rename {templates => app/templates}/search-bar.php (100%) rename {tests => app/tests}/Integration/SearchTest.php (100%) rename {tests => app/tests}/README.md (100%) rename {tests => app/tests}/Security/SecurityTest.php (100%) rename {tests => app/tests}/Unit/DatabaseTest.php (100%) rename {tests => app/tests}/Unit/RateLimitTest.php (100%) rename {tests => app/tests}/run-tests.php (100%) delete mode 100644 config/admin_credentials.example.php rename {src => config}/config.php (100%) create mode 100644 docs/ANALYSIS_STRUCTURE_REORG.md rename SETUP.md => docs/SETUP.md (100%) rename SPECS.md => docs/SPECS.md (100%) delete mode 100644 public/media.php delete mode 100644 storage/thesis.db delete mode 100644 templates/admin/footer.php delete mode 100644 templates/partials/flash-messages.php delete mode 100644 test.db diff --git a/DEV.md b/DEV.md deleted file mode 100644 index 4ea08ff..0000000 --- a/DEV.md +++ /dev/null @@ -1,22 +0,0 @@ -# Mise en place Dev - - -## MacOS - -Logiciels: - -- un IDE pour éditer → VSCode -- git (ou une interface graphique) pour partager les modifications → git-gui (officiel) ou Github Desktop -- un server web avec PHP pour visualiser le project dans le navigateur → MAMP - - -## Workflow - -1. Faire un changement dans ton IDE -2. Démarrer le site via MAMP, en sélectionnant le dossier `public` -3. Vérifier que ça marche sur le site en local, depuis ton navigateur -4. Une fois qu'un changement spécifique est fait, `commit` les changements sur les fichiers qui sont relatif à ce changement -5. Vérifier que vous avez syncroniser avec le `remote` → `pull` + `rebase` ! pas merge -6. `push` les changements vers le remote - - diff --git a/README.md b/README.md index b092d75..eecf3a5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# posterg +# XAMXAM + +(Anciennement *Posterg*) Répertoire des travaux de fin d'études de l'[ERG](https://erg.be) (École de Recherche Graphique). @@ -8,39 +10,6 @@ Répertoire des travaux de fin d'études de l'[ERG](https://erg.be) (École de R - SQLite3 (`php8.4-sqlite3`) - nginx (production) -## Project structure - -``` -posterg/ -├── public/ # DocumentRoot — web-accessible only -│ ├── admin/ # Admin panel (session-authenticated) -│ ├── assets/ # CSS, fonts, icons -│ ├── media.php # Controlled file serving (covers, PDFs) -│ └── *.php # Public pages (index, search, tfe, apropos) -├── src/ # PHP classes (not web-accessible) -│ ├── AdminAuth.php -│ ├── Database.php -│ ├── RateLimit.php -│ └── config.php -├── templates/ # Shared PHP template partials -├── config/ # Bootstrap and credentials (not web-accessible) -├── storage/ # Database and uploaded files (not web-accessible) -│ ├── schema.sql -│ ├── test.db -│ └── fixtures/ -├── tests/ -├── scripts/ # Dev and server management scripts -│ ├── setup-dev.sh -│ ├── deploy-server.sh # Run on server with sudo to apply nginx config -│ └── manage-admin-users.sh # Run on server with sudo to manage htpasswd -└── nginx/ # nginx config and reference files - ├── posterg.conf - └── docs/ # Documentation -``` - -Uploaded files (PDFs, covers) live in `storage/` — outside the webroot — and are -served exclusively through `public/media.php`, which validates paths and MIME types. - ## Development ```bash @@ -95,3 +64,24 @@ ssh posterg "sudo bash /tmp/manage-admin-users.sh" - Uploads stored outside webroot, served via controlled `media.php` - Rate limiting on public search (`src/RateLimit.php`) - See `nginx/docs/SECURITY_HEADERS.md` for security headers reference + +## Mise en place Dev + + +### MacOS + +Logiciels: + +- un IDE pour éditer → VSCode +- git (ou une interface graphique) pour partager les modifications → git-gui (officiel) ou Github Desktop +- un server web avec PHP pour visualiser le project dans le navigateur → MAMP + + +### Workflow + +0. Faire un changement dans ton IDE +1. Démarrer le site via MAMP, en sélectionnant le dossier `public` +2. Vérifier que ça marche sur le site en local, depuis ton navigateur +3. Une fois qu'un changement spécifique est fait, `commit` les changements sur les fichiers qui sont relatif à ce changement +4. Vérifier que vous avez syncroniser avec le `remote` → `pull` + `rebase` ! pas merge +5. `push` les changements vers le remote diff --git a/TODO.md b/TODO.md index c3ac07f..6d5c080 100644 --- a/TODO.md +++ b/TODO.md @@ -1,15 +1,53 @@ # TODO -- [x] Create migration 010_apropos_contents.sql (apropos_contents table, seed defaults) -- [x] Add apropos CRUD methods to Database.php -- [x] Create admin/contenus.php (replaces pages.php) -- [x] Create admin/contenus-edit.php (edit pages + apropos contacts/credits/erg_url) -- [x] Create admin/actions/apropos.php - save handler for apropos contents -- [x] Update templates/header.php: rename "Pages statiques" → "Contenus", update nav links -- [x] Update public/apropos.php: read contacts/credits/erg_url from DB instead of config -- [x] Delete config/apropos.php -- [x] Delete public/admin/pages.php -- [x] Delete public/admin/pages-edit.php -- [x] Delete public/admin/actions/page.php -- [x] Update storage/schema.sql with apropos_contents table + trigger -- [x] Rework system.php/system.js: replace custom fetch() JS with HTMX, inline onclick for copy + collapse +- [x] Replace inline alert CSS in admin.css with floating bottom-center toast styles (fixed, z-index, animation) +- [x] Update flash-messages.php partial to output `.toast` markup in hidden container for footer JS +- [x] Add toast container HTML + JS to admin footer.php (centralised, 4s auto-dismiss with fade-out) +- [x] Remove redundant flash-messages.php includes from all admin pages (8 files) +- [x] Convert hardcoded alerts in login.php, thanks.php, index.php import to `.toast` class +- [x] Update admin.css dialog rule from `[role=alert/status]` to `.toast` +- [x] Commit with jj + +- [x] Move DB export from admin/index.php to admin/parametres.php (maintenance section) + +- [x] Reorganize src/ - move 7 controllers to src/Controllers/ + - [x] Create Controllers directory + - [x] Move controller files (Home, Tfe, Search, ThesisCreate, ThesisEdit, Export, System) + - [x] Update all require_once paths across codebase + +- [x] Move stray test.db from root to storage/ + +- [x] Store admin password hash in DB (site_settings) instead of config file + - [x] Create migration 013 + - [x] Update AdminAuth to read hash from DB + - [x] Update bootstrap.php — remove credential file loading + - [x] Update parametres.php — status check from DB + - [x] Update actions/account.php — write hash to DB + - [x] Update login.php — dev-mode check + - [x] Update header.php — dev check + - [x] Delete config/admin_credentials.example.php + +## Now: Single Entry Point Routing + +### Phase 1: Dispatcher refinement +- [x] MediaController: extract media.php logic into MediaController class + - [x] Create src/Controllers/MediaController.php + - [x] Move path validation + storage jail + MIME check + streaming + - [x] Wire into Dispatcher for /media route + - [x] Delete app/public/media.php +- [ ] Update Dispatcher to handle all routes directly (no require APP_ROOT/public/*.php) + +### Phase 2: Single entry point +- [ ] Create app/public/index.php as front controller + - [ ] Bootstrap + Dispatcher invocation +- [ ] Remove direct-access public/*.php (index.php, search.php, tfe.php, apropos.php, licence.php) +- [ ] Rename old entry points so they can't be hit directly (e.g., prefix with underscore or delete) + +### Phase 3: Server config +- [ ] Update router.php — route all PHP requests to Dispatcher +- [ ] Update nginx config — point all public routes to index.php via try_files + - [ ] Replace per-file `location ~ \.php$` with front-controller pattern + +### Phase 4: Cleanup +- [ ] Delete app/public/live-reload.php (already handled by LiveReloadController) +- [ ] Test all routes (/, search.php, tfe, repertoire, apropos, licence, media, live-reload) diff --git a/config/bootstrap.php b/app/bootstrap.php similarity index 79% rename from config/bootstrap.php rename to app/bootstrap.php index 09444bd..4211882 100644 --- a/config/bootstrap.php +++ b/app/bootstrap.php @@ -5,11 +5,11 @@ */ // Define application root -define('APP_ROOT', dirname(__DIR__)); +define('APP_ROOT', __DIR__); // Storage directory for uploaded files — intentionally outside the webroot // so no uploaded content is ever directly web-accessible (items #3 & #4). -// Files are served through public/media.php which validates paths and MIME types. +// Files are served through MediaController which validates paths and MIME types. define('STORAGE_ROOT', '/var/www/posterg/storage'); // Error reporting @@ -24,10 +24,8 @@ if (php_sapi_name() === 'cli-server') { ini_set('log_errors', '1'); } -// Load admin credentials if available (defines ADMIN_PASSWORD_HASH for AdminAuth) -if (file_exists(APP_ROOT . '/config/admin_credentials.php')) { - require_once APP_ROOT . '/config/admin_credentials.php'; -} +// Admin password hash is stored in site_settings (DB). +// AdminAuth reads it on demand — no static config file needed. // Central application helper (boot, auth guard, CSRF, flash, render) require_once APP_ROOT . '/src/App.php'; diff --git a/public/admin/.htaccess b/app/public/admin/.htaccess similarity index 100% rename from public/admin/.htaccess rename to app/public/admin/.htaccess diff --git a/public/admin/README.md b/app/public/admin/README.md similarity index 100% rename from public/admin/README.md rename to app/public/admin/README.md diff --git a/public/admin/acces-etudiante.php b/app/public/admin/acces-etudiante.php similarity index 98% rename from public/admin/acces-etudiante.php rename to app/public/admin/acces-etudiante.php index af4c91a..d26eb81 100644 --- a/public/admin/acces-etudiante.php +++ b/app/public/admin/acces-etudiante.php @@ -1,5 +1,5 @@
- +

Accès étudiant·e

diff --git a/public/admin/account.php b/app/public/admin/account.php similarity index 79% rename from public/admin/account.php rename to app/public/admin/account.php index efc3e7d..4d27531 100644 --- a/public/admin/account.php +++ b/app/public/admin/account.php @@ -1,12 +1,11 @@

Compte administrateur

- +
@@ -91,16 +90,16 @@ if (empty($_SESSION['csrf_token'])) {

Supprimer la configuration du mot de passe PHP
- Supprime config/admin_credentials.php. L'accès admin + Supprime le hash de la base de données. L'accès admin dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée.

+ onsubmit="return confirm('Supprimer le mot de passe PHP ? L\'accès admin ne sera protégé que par nginx Basic Auth.')"> - +
diff --git a/public/admin/actions/acces-etudiante.php b/app/public/admin/actions/acces-etudiante.php similarity index 97% rename from public/admin/actions/acces-etudiante.php rename to app/public/admin/actions/acces-etudiante.php index 0323ff3..498e3d4 100644 --- a/public/admin/actions/acces-etudiante.php +++ b/app/public/admin/actions/acces-etudiante.php @@ -2,7 +2,7 @@ /** * Student-access link actions (create, toggle, set_password, delete). */ -require_once __DIR__ . '/../../../config/bootstrap.php'; +require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../src/AdminAuth.php'; require_once __DIR__ . '/../../../src/ShareLink.php'; diff --git a/public/admin/actions/account.php b/app/public/admin/actions/account.php similarity index 54% rename from public/admin/actions/account.php rename to app/public/admin/actions/account.php index 628bcf4..87f8814 100644 --- a/public/admin/actions/account.php +++ b/app/public/admin/actions/account.php @@ -1,14 +1,16 @@ 12]);"' . "\n" - . ' */' . "\n" - . "\n" - . 'define(\'ADMIN_PASSWORD_HASH\', ' . var_export($hash, true) . ');' . "\n"; - -// Write atomically via a temp file. -$tmpFile = $credentialsFile . '.tmp.' . bin2hex(random_bytes(6)); -if (file_put_contents($tmpFile, $configContent, LOCK_EX) === false) { - @unlink($tmpFile); - App::flash('error', 'Impossible d\'écrire le fichier de configuration. Vérifiez les permissions sur config/.'); - header('Location: ' . $backUrl); - exit; -} -if (!rename($tmpFile, $credentialsFile)) { - @unlink($tmpFile); - App::flash('error', 'Impossible de mettre à jour le fichier de configuration.'); - header('Location: ' . $backUrl); - exit; -} +// 4. Store hash in DB. +AdminAuth::setPasswordHash($hash); // 5. Regenerate session (password changed — invalidate old sessions). session_regenerate_id(true); diff --git a/public/admin/actions/apropos.php b/app/public/admin/actions/apropos.php similarity index 97% rename from public/admin/actions/apropos.php rename to app/public/admin/actions/apropos.php index 11623db..10ba380 100644 --- a/public/admin/actions/apropos.php +++ b/app/public/admin/actions/apropos.php @@ -3,7 +3,7 @@ * Save handler for apropos contents (contacts, credits). * Structure: groups[] with label/role, each having entries[] of {text, url, email}. */ -require_once __DIR__ . "/../../../config/bootstrap.php"; +require_once __DIR__ . "/../../../bootstrap.php"; require_once __DIR__ . '/../../../src/AdminAuth.php'; AdminAuth::requireLogin(); diff --git a/public/admin/actions/delete.php b/app/public/admin/actions/delete.php similarity index 96% rename from public/admin/actions/delete.php rename to app/public/admin/actions/delete.php index 2c8c237..eda2a87 100644 --- a/public/admin/actions/delete.php +++ b/app/public/admin/actions/delete.php @@ -1,5 +1,5 @@ exportAllTheses(); +foreach ($rows as $csvLine) { + fputcsv($out, $csvLine, ',', '"', ''); +} + +fclose($out); +exit; diff --git a/app/public/admin/actions/export-db.php b/app/public/admin/actions/export-db.php new file mode 100644 index 0000000..50b6fee --- /dev/null +++ b/app/public/admin/actions/export-db.php @@ -0,0 +1,29 @@ +getDatabasePath(); + +if (!file_exists($dbPath)) { + http_response_code(500); + exit('Base de données introuvable.'); +} + +$filename = 'posterg-db-' . date('Y-m-d') . '.sqlite'; + +header('Content-Type: application/octet-stream'); +header('Content-Disposition: attachment; filename="' . $filename . '"'); +header('Content-Length: ' . filesize($dbPath)); +header('Cache-Control: no-cache, must-revalidate'); + +readfile($dbPath); +exit; diff --git a/public/admin/actions/formulaire.php b/app/public/admin/actions/formulaire.php similarity index 90% rename from public/admin/actions/formulaire.php rename to app/public/admin/actions/formulaire.php index 9b77247..954fca6 100644 --- a/public/admin/actions/formulaire.php +++ b/app/public/admin/actions/formulaire.php @@ -1,6 +1,6 @@ setSetting($key, $value); } App::flash('success', "Paramètres du formulaire mis à jour."); +} elseif ($section === 'smtp') { + $smtpData = [ + 'host' => $_POST['smtp_host'] ?? '', + 'port' => $_POST['smtp_port'] ?? 587, + 'encryption' => $_POST['smtp_encryption'] ?? 'tls', + 'username' => $_POST['smtp_username'] ?? '', + 'from_email' => $_POST['smtp_from_email'] ?? '', + 'from_name' => $_POST['smtp_from_name'] ?? 'Post-ERG', + ]; + // Only update password when user actually typed something. + $pwd = $_POST['smtp_password'] ?? ''; + if ($pwd !== '') { + $smtpData['password'] = $pwd; + } + SmtpRelay::updateSettings($db, $smtpData); + App::flash('success', "Paramètres SMTP mis à jour."); } else { App::flash('error', "Section inconnue."); } diff --git a/public/admin/actions/tag.php b/app/public/admin/actions/tag.php similarity index 96% rename from public/admin/actions/tag.php rename to app/public/admin/actions/tag.php index 8f34bef..66a752f 100644 --- a/public/admin/actions/tag.php +++ b/app/public/admin/actions/tag.php @@ -1,5 +1,5 @@ Ajouter un TFE - +
"> diff --git a/public/admin/contenus-edit.php b/app/public/admin/contenus-edit.php similarity index 99% rename from public/admin/contenus-edit.php rename to app/public/admin/contenus-edit.php index 3a3ece3..2f8a351 100644 --- a/public/admin/contenus-edit.php +++ b/app/public/admin/contenus-edit.php @@ -1,5 +1,5 @@

Contenus

- +

Pages statiques

diff --git a/public/admin/edit.php b/app/public/admin/edit.php similarity index 97% rename from public/admin/edit.php rename to app/public/admin/edit.php index 7772418..ed2919a 100644 --- a/public/admin/edit.php +++ b/app/public/admin/edit.php @@ -1,6 +1,6 @@

Modifier un TFE

- + diff --git a/public/admin/import.php b/app/public/admin/import.php similarity index 100% rename from public/admin/import.php rename to app/public/admin/import.php diff --git a/public/admin/index.php b/app/public/admin/index.php similarity index 98% rename from public/admin/index.php rename to app/public/admin/index.php index f5d13a7..aa986a1 100644 --- a/public/admin/index.php +++ b/app/public/admin/index.php @@ -1,5 +1,5 @@ {
- -

Liste des TFE

@@ -357,6 +355,9 @@ document.addEventListener('DOMContentLoaded', () => { onclick="document.getElementById('import-dialog').showModal()"> Importer un CSV + + Exporter CSV +
@@ -503,7 +504,7 @@ document.addEventListener('DOMContentLoaded', () => {
- diff --git a/public/admin/login.php b/app/public/admin/login.php similarity index 88% rename from public/admin/login.php rename to app/public/admin/login.php index af84d52..9e45bbe 100644 --- a/public/admin/login.php +++ b/app/public/admin/login.php @@ -1,8 +1,8 @@

Administration

-

+
diff --git a/public/admin/logout.php b/app/public/admin/logout.php similarity index 69% rename from public/admin/logout.php rename to app/public/admin/logout.php index 001e0ab..5b3f944 100644 --- a/public/admin/logout.php +++ b/app/public/admin/logout.php @@ -1,5 +1,5 @@ getAllSettings(); $stats = $db->getThesesStats(); +$smtpSettings = SmtpRelay::getSettings($db); +$smtpConfigured = SmtpRelay::isConfigured($db); if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); @@ -24,7 +26,7 @@ if (empty($_SESSION['csrf_token'])) {

Paramètres

- + +
+ Exporter la base de données +

Télécharger une copie complète de la base de données SQLite. + Cela inclut tous les TFE, auteurs, jury, mots-clés, paramètres, etc.

+ +
+
Supprimer tous les TFE @@ -120,6 +133,88 @@ if (empty($_SESSION['csrf_token'])) { + +
+

Relay SMTP

+

+ Identifiants du serveur SMTP utilisé pour l'envoi d'e-mails + (notifications, partage de TFE, etc.). +

+
+ + ✓ Configuré + : () + + ✗ Non configuré + +
+ +
+ + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ Expéditeur par défaut +
+
+ + +
+
+ + +
+
+
+ + +
+
+ @@ -132,18 +227,18 @@ if (empty($_SESSION['csrf_token'])) {
-
Fichier de configuration
+
Stockage du hash
- config/admin_credentials.php - + site_settings (DB) +

- Aucun mot de passe PHP configuré. Le formulaire ci-dessous créera - config/admin_credentials.php avec un hash bcrypt. + Aucun mot de passe PHP configuré. Le formulaire ci-dessous stockera + un hash bcrypt dans la base de données.

@@ -183,20 +278,40 @@ if (empty($_SESSION['csrf_token'])) {
Supprimer la configuration du mot de passe PHP

- Supprime config/admin_credentials.php. L'accès admin + Supprime le hash de la base de données. L'accès admin dépendra uniquement de l'authentification nginx Basic Auth si elle est configurée.

+ onsubmit="return confirm('Supprimer le mot de passe PHP ? L\'accès admin ne sera protégé que par nginx Basic Auth.')">> - +
+ + + +
+

Exporter la base de données

+ +
+ +

Télécharger une copie complète de la base de données SQLite. + Cela inclut tous les TFE, auteurs, jury, mots-clés, paramètres, etc.

+ + +
diff --git a/public/admin/status.php b/app/public/admin/status.php similarity index 100% rename from public/admin/status.php rename to app/public/admin/status.php diff --git a/public/admin/system-fragment.php b/app/public/admin/system-fragment.php similarity index 98% rename from public/admin/system-fragment.php rename to app/public/admin/system-fragment.php index 131a5fc..eaf3d7a 100644 --- a/public/admin/system-fragment.php +++ b/app/public/admin/system-fragment.php @@ -9,11 +9,11 @@ * Response: text/html fragment (no // wrapper). * On any auth failure or bad request: 403 / 400 with a plain-text body. */ -require_once __DIR__ . "/../../config/bootstrap.php"; +require_once __DIR__ . "/../../bootstrap.php"; require_once __DIR__ . '/../../src/AdminAuth.php'; require_once APP_ROOT . '/src/Database.php'; require_once APP_ROOT . '/src/SystemCache.php'; -require_once APP_ROOT . '/src/SystemController.php'; +require_once APP_ROOT . '/src/Controllers/SystemController.php'; if (!AdminAuth::isAuthenticated()) { http_response_code(403); diff --git a/public/admin/system.php b/app/public/admin/system.php similarity index 99% rename from public/admin/system.php rename to app/public/admin/system.php index 04656ef..1914aad 100644 --- a/public/admin/system.php +++ b/app/public/admin/system.php @@ -1,9 +1,9 @@

Mots-clés ()

- + diff --git a/public/admin/thanks.php b/app/public/admin/thanks.php similarity index 98% rename from public/admin/thanks.php rename to app/public/admin/thanks.php index 80918d3..60a6be4 100644 --- a/public/admin/thanks.php +++ b/app/public/admin/thanks.php @@ -1,6 +1,6 @@ Récapitulatif TFE -

+

Retour au formulaire

diff --git a/public/apropos.php b/app/public/apropos.php similarity index 99% rename from public/apropos.php rename to app/public/apropos.php index a3b4216..62bc661 100644 --- a/public/apropos.php +++ b/app/public/apropos.php @@ -1,5 +1,5 @@ handle(); diff --git a/public/licence.php b/app/public/licence.php similarity index 96% rename from public/licence.php rename to app/public/licence.php index 1606051..a334fb3 100644 --- a/public/licence.php +++ b/app/public/licence.php @@ -1,5 +1,5 @@ /submit — POST endpoint for form submissions via share link * /partage/thanks.php?id=N — Post-submission confirmation page */ -require_once __DIR__ . '/../../config/bootstrap.php'; +require_once __DIR__ . '/../../bootstrap.php'; // Parse the requested path from REQUEST_URI $requestUri = $_SERVER['REQUEST_URI'] ?? ''; @@ -219,7 +219,7 @@ function requirePasswordGate(array $link, string $slug): void function renderShareLinkForm(string $slug, array $link): void { - require_once APP_ROOT . '/src/ThesisCreateController.php'; + require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php'; try { $ctrl = ThesisCreateController::make(); @@ -541,7 +541,7 @@ function handleShareLinkSubmission(string $slug): void exit; } - require_once APP_ROOT . '/src/ThesisCreateController.php'; + require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php'; try { $ctrl = ThesisCreateController::make(); diff --git a/public/partage/thanks.php b/app/public/partage/thanks.php similarity index 97% rename from public/partage/thanks.php rename to app/public/partage/thanks.php index df14015..6364f07 100644 --- a/public/partage/thanks.php +++ b/app/public/partage/thanks.php @@ -3,7 +3,7 @@ * Thanks page for share-link submissions. * Displays a centered confirmation with a link to create another thesis via the same link. */ -require_once __DIR__ . '/../../config/bootstrap.php'; +require_once __DIR__ . '/../../bootstrap.php'; App::boot(); diff --git a/public/repertoire.php b/app/public/repertoire.php similarity index 86% rename from public/repertoire.php rename to app/public/repertoire.php index d2058ea..f875465 100644 --- a/public/repertoire.php +++ b/app/public/repertoire.php @@ -1,6 +1,6 @@ and /partage// to the partage entry if (preg_match('#^/partage(/.*)?$#', $uri)) { $_SERVER['SCRIPT_NAME'] = '/partage/index.php'; - require __DIR__ . '/../public/partage/index.php'; + require __DIR__ . '/public/partage/index.php'; return true; } // Route /tfe/<...> to tfe.php if (preg_match('#^/tfe(/.*)?$#', $uri)) { $_SERVER['SCRIPT_NAME'] = '/tfe.php'; - require __DIR__ . '/../public/tfe.php'; + require __DIR__ . '/public/tfe.php'; return true; } diff --git a/src/AdminAuth.php b/app/src/AdminAuth.php similarity index 50% rename from src/AdminAuth.php rename to app/src/AdminAuth.php index 43d402c..71ce73e 100644 --- a/src/AdminAuth.php +++ b/app/src/AdminAuth.php @@ -6,15 +6,10 @@ * It protects against proxy misconfiguration, bypass, and local-dev * scenarios where the reverse proxy may be absent. * - * Usage (top of every admin page): - * require_once __DIR__ . '/../../lib/AdminAuth.php'; - * AdminAuth::requireLogin(); + * The admin password hash is stored in the site_settings table + * (key = 'admin_password_hash'). * - * Credential setup (production): - * php -r "echo password_hash('your-password', PASSWORD_DEFAULT);" - * # Paste result into config/admin_credentials.php as ADMIN_PASSWORD_HASH - * - * If ADMIN_PASSWORD_HASH is not defined the guard is a no-op (dev / cli-server). + * If the hash is empty/missing the guard is a no-op (dev / cli-server). */ class AdminAuth { @@ -41,33 +36,47 @@ class AdminAuth session_start(); } + /** + * Fetch the admin password hash from site_settings. + * Returns null if not set (dev mode). + */ + private static function getStoredHash(): ?string + { + // Legacy fallback: if the old constant is still defined, honour it. + if (defined('ADMIN_PASSWORD_HASH') && ADMIN_PASSWORD_HASH !== '') { + return ADMIN_PASSWORD_HASH; + } + + // Lazy-load minimal DB just for this lookup. + require_once APP_ROOT . '/src/Database.php'; + $db = new Database(); + $hash = $db->getSetting('admin_password_hash'); + return $hash !== '' ? $hash : null; + } + /** * Gate every admin page. * * Authentication order: - * 1. Session already authenticated → pass through. - * 2. nginx Basic Auth password present in $_SERVER['PHP_AUTH_PW'] + * 1. No password hash configured → dev mode, pass through. + * 2. Session already authenticated → pass through. + * 3. nginx Basic Auth password present in $_SERVER['PHP_AUTH_PW'] * → validate it with password_verify; on success create session * (seamless: user only sees the browser Basic Auth dialog). - * 3. Neither → redirect to the PHP login form (fallback for when - * the reverse proxy is absent / misconfigured). - * - * No-op if ADMIN_PASSWORD_HASH is not defined (development / cli-server). + * 4. Neither → redirect to the PHP login form. */ public static function requireLogin(): void { self::startSession(); - if (!defined('ADMIN_PASSWORD_HASH')) { - // No password configured → development / cli-server mode, skip PHP auth. - return; + $storedHash = self::getStoredHash(); + if ($storedHash === null) { + return; // No password configured → dev / cli-server, skip. } if (!empty($_SESSION[self::SESSION_KEY])) { - return; // already authenticated via session + return; // Already authenticated via session. } // Try to auto-authenticate from the nginx Basic Auth credentials. - // If nginx Basic Auth is bypassed, PHP_AUTH_PW won't be set and this - // branch is skipped — the fallback login form is shown instead. - if (isset($_SERVER['PHP_AUTH_PW']) && self::login($_SERVER['PHP_AUTH_PW'])) { + if (isset($_SERVER['PHP_AUTH_PW']) && self::verifyHash($_SERVER['PHP_AUTH_PW'], $storedHash)) { return; } header('Location: ' . self::LOGIN_URL); @@ -75,15 +84,15 @@ class AdminAuth } /** - * Validate a plaintext password against the stored bcrypt hash. + * Validate a plaintext password against the stored hash. * On success: regenerates the session ID and marks the session authenticated. * - * @return bool true on success, false on wrong password / no hash configured. + * @return bool true on success, false on wrong password / no hash stored. */ public static function login(string $password): bool { - $hash = defined('ADMIN_PASSWORD_HASH') ? ADMIN_PASSWORD_HASH : null; - if ($hash === null || !password_verify($password, $hash)) { + $storedHash = self::getStoredHash(); + if ($storedHash === null || !self::verifyHash($password, $storedHash)) { return false; } self::startSession(); @@ -93,19 +102,55 @@ class AdminAuth return true; } + /** + * Bcrypt verification wrapper. + */ + private static function verifyHash(string $password, string $hash): bool + { + return password_verify($password, $hash); + } + + /** + * Update the stored admin password hash in the database. + */ + public static function setPasswordHash(string $newHash): void + { + require_once APP_ROOT . '/src/Database.php'; + $db = new Database(); + $db->setSetting('admin_password_hash', $newHash); + } + + /** + * Remove the stored admin password hash (revert to dev mode). + */ + public static function removePasswordHash(): void + { + require_once APP_ROOT . '/src/Database.php'; + $db = new Database(); + $db->setSetting('admin_password_hash', ''); + } + /** * Check whether the current request is authenticated (without redirecting). */ public static function isAuthenticated(): bool { self::startSession(); - // No password configured → development mode, skip PHP auth. - if (!defined('ADMIN_PASSWORD_HASH')) { - return true; + $storedHash = self::getStoredHash(); + if ($storedHash === null) { + return true; // No password configured → dev mode. } return !empty($_SESSION[self::SESSION_KEY]); } + /** + * Check whether a password hash is configured in the system. + */ + public static function hasPassword(): bool + { + return self::getStoredHash() !== null; + } + /** * Destroy the session (logout). */ diff --git a/src/App.php b/app/src/App.php similarity index 100% rename from src/App.php rename to app/src/App.php diff --git a/app/src/Controllers/AboutController.php b/app/src/Controllers/AboutController.php new file mode 100644 index 0000000..d38ad56 --- /dev/null +++ b/app/src/Controllers/AboutController.php @@ -0,0 +1,43 @@ +getPage('about'); + $rawContent = $aboutPage ? $aboutPage['content'] : ''; + if (empty(trim($rawContent)) || trim($rawContent) === 'Contenu à venir') { + $rawContent = $this->defaultContent; + } + $contacts = $db->getAproposContent('contacts'); + $credits = $db->getAproposContent('credits'); + $contacts = is_array($contacts) && !empty($contacts) ? $contacts : null; + $credits = is_array($credits) && !empty($credits) ? $credits : null; + } catch (Exception $e) { + error_log("Error loading about page: " . $e->getMessage()); + $rawContent = $this->defaultContent; + $contacts = null; + $credits = null; + } + + $pd = new Parsedown(); + $pd->setSafeMode(true); + + return [ + 'nav' => 'apropos', + 'aboutHtml' => $pd->text($rawContent), + 'contacts' => $contacts, + 'credits' => $credits, + 'pageTitle' => 'À Propos – Posterg', + 'metaDescription' => "À propos de Posterg, le répertoire des mémoires de fin d'études de l'erg – École de Recherches Graphiques de Bruxelles.", + 'extraCss' => ['/assets/css/apropos.css'], + 'bodyClass' => 'apropos-body', + ]; + } +} diff --git a/app/src/Controllers/ExportController.php b/app/src/Controllers/ExportController.php new file mode 100644 index 0000000..232a1b6 --- /dev/null +++ b/app/src/Controllers/ExportController.php @@ -0,0 +1,166 @@ +db = $db; + } + + public static function create(): self + { + require_once APP_ROOT . '/src/Database.php'; + return new self(Database::getInstance()); + } + + // ── Database export ────────────────────────────────────────────────── + + /** + * Return the absolute path of the live database file. + */ + public function getDatabasePath(): string + { + return $this->db->getDatabasePath(); + } + + // ── CSV export ─────────────────────────────────────────────────────── + + /** + * Column headers matching the import format. + */ + public const CSV_HEADERS = [ + 'Identifiant', + 'Titre', + 'Sous-titre', + 'Auteur·ice(s)', + 'Contact', + 'Promoteur·ice(s)', + 'Format(s)', + 'Année', + 'AP', + 'Orientation', + 'Finalité', + 'Mots-clés', + 'Synopsis', + 'Contexte', + 'Remarques', + 'Langue', + 'Autorisation', + 'Licence', + 'Taille', + 'Points sur 20', + 'Lien BAIU', + ]; + + /** + * Fetch all theses and their related data, then return a list of rows + * shaped to match the import CSV column order. + * + * Uses batch queries (one per related table) to avoid N+1. + * + * @return list> Each inner list has CSV_HEADERS_COUNT elements. + */ + public function exportAllTheses(): array + { + // 1) Base thesis data + $theses = $this->db->getAllThesesForExport(); + if ($theses === []) { + return []; + } + + // 2) Load related data in batches + $byThesis = function (array $rows): array { + $map = []; + foreach ($rows as $r) { + $tid = (int) $r['thesis_id']; + $map[$tid][] = $r; + } + return $map; + }; + + $authors = $byThesis($this->db->getAllThesisAuthorsForExport()); + $supervisors = $byThesis($this->db->getAllThesisSupervisorsForExport()); + $tags = $byThesis($this->db->getAllThesisTagsForExport()); + $languages = $byThesis($this->db->getAllThesisLanguagesForExport()); + $formats = $byThesis($this->db->getAllThesisFormatsForExport()); + + // 3) Build CSV rows + $csvRows = []; + foreach ($theses as $t) { + $tid = (int) $t['id']; + + // Authors + contact (first author with email) + $authorList = []; + $contact = ''; + foreach (($authors[$tid] ?? []) as $a) { + $authorList[] = $a['name']; + if ($contact === '' && !empty($a['email'])) { + $contact = $a['email']; + } + } + + // Supervisors + $supList = []; + foreach (($supervisors[$tid] ?? []) as $s) { + $supList[] = $s['name']; + } + + // Tags + $tagList = []; + foreach (($tags[$tid] ?? []) as $tg) { + $tagList[] = $tg['name']; + } + + // Languages + $langList = []; + foreach (($languages[$tid] ?? []) as $l) { + $langList[] = $l['name']; + } + + // Formats + $fmtList = []; + foreach (($formats[$tid] ?? []) as $f) { + $fmtList[] = $f['name']; + } + + $csvRows[] = [ + $t['identifier'] ?? '', + $t['title'] ?? '', + $t['subtitle'] ?? '', + implode(', ', $authorList), + $contact, + implode(', ', $supList), + implode(', ', $fmtList), + $t['year'] ?? '', + $t['ap_program'] ?? '', + $t['orientation'] ?? '', + $t['finality_type'] ?? '', + implode(', ', $tagList), + $t['synopsis'] ?? '', + $t['context_note'] ?? '', + $t['remarks'] ?? '', + implode(', ', $langList), + $t['access_type'] ?? '', + $t['license_name'] ?? '', + $t['file_size_info'] ?? '', + isset($t['jury_points']) ? (string) $t['jury_points'] : '', + $t['baiu_link'] ?? '', + ]; + } + + return $csvRows; + } +} diff --git a/src/HomeController.php b/app/src/Controllers/HomeController.php similarity index 100% rename from src/HomeController.php rename to app/src/Controllers/HomeController.php diff --git a/app/src/Controllers/LicenceController.php b/app/src/Controllers/LicenceController.php new file mode 100644 index 0000000..9aaaea7 --- /dev/null +++ b/app/src/Controllers/LicenceController.php @@ -0,0 +1,36 @@ +getPage('licenses'); + $content = $dbPage ? $dbPage['content'] : ''; + $pageTitle = $dbPage ? $dbPage['title'] : 'Licences'; + } catch (Exception $e) { + error_log("Error loading licence page: " . $e->getMessage()); + $content = ''; + $pageTitle = 'Licences'; + } + + $pd = new Parsedown(); + $pd->setSafeMode(true); + $html = $pd->text($content); + + return [ + 'content' => $content, + 'html' => $html, + 'pageTitle' => $pageTitle . ' – Posterg', + 'metaDescription' => "Informations sur les licences d'utilisation des mémoires publiés sur Posterg, le répertoire des TFE de l'erg.", + 'currentNav' => 'licence', + 'extraCss' => ['/assets/css/apropos.css'], + 'bodyClass' => 'apropos-body', + ]; + } +} diff --git a/app/src/Controllers/LiveReloadController.php b/app/src/Controllers/LiveReloadController.php new file mode 100644 index 0000000..fd28eb4 --- /dev/null +++ b/app/src/Controllers/LiveReloadController.php @@ -0,0 +1,57 @@ +watchDirs = [ + $appRoot . '/public', + $appRoot . '/src', + $appRoot . '/config', + $appRoot . '/templates', + ]; + $this->stateFile = sys_get_temp_dir() . '/posterg-live-reload.txt'; + } + + public function handle(): array { + return ['json' => true, 'body' => $this->poll()]; + } + + private function poll(): array { + $hash = ''; + foreach ($this->watchDirs as $dir) { + if (!is_dir($dir)) continue; + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) + ); + foreach ($it as $file) { + if (in_array($file->getExtension(), $this->watchExts, true)) { + $hash .= $file->getMTime() . '|' . $file->getPathname() . "\n"; + } + } + } + + $fingerprint = md5($hash); + $prev = file_exists($this->stateFile) ? file_get_contents($this->stateFile) : null; + + if ($prev === null) { + file_put_contents($this->stateFile, $fingerprint); + $changed = false; + } else { + $changed = $fingerprint !== $prev; + if ($changed) { + file_put_contents($this->stateFile, $fingerprint); + } + } + + return ['changed' => $changed]; + } +} diff --git a/app/src/Controllers/MediaController.php b/app/src/Controllers/MediaController.php new file mode 100644 index 0000000..10067f1 --- /dev/null +++ b/app/src/Controllers/MediaController.php @@ -0,0 +1,114 @@ +getFileVisibility($requestedPath); + if ($accessTypeId !== null && $accessTypeId === 3) { + http_response_code(403); + exit; + } + } catch (\Throwable $e) { + error_log("MediaController visibility check error: " . $e->getMessage()); + } + } + + // 4. Verify MIME type + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($realFull); + + $allowedMimes = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'application/pdf', + 'video/mp4', + 'application/zip', + 'text/vtt', // WebVTT caption sidecar files + ]; + + // finfo may return 'text/plain' for WebVTT files on some systems; + // re-classify by extension so we don't block them. + if ($mimeType === 'text/plain' && strtolower(pathinfo($realFull, PATHINFO_EXTENSION)) === 'vtt') { + $mimeType = 'text/vtt'; + } + + if (!in_array($mimeType, $allowedMimes, true)) { + http_response_code(403); + exit; + } + + // 5. Send response headers + header('Content-Type: ' . $mimeType); + header('Content-Length: ' . filesize($realFull)); + header('X-Content-Type-Options: nosniff'); + + $ext = strtolower(pathinfo($realFull, PATHINFO_EXTENSION)); + + if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif'], true)) { + header('Cache-Control: public, max-age=604800'); + } elseif ($ext === 'pdf') { + header('Cache-Control: public, max-age=86400'); + header('Content-Disposition: inline'); + } elseif ($ext === 'vtt') { + header('Content-Type: text/vtt; charset=utf-8'); + header('Cache-Control: public, max-age=86400'); + } else { + header('Cache-Control: private, no-store'); + } + + // 6. Stream file + readfile($realFull); + } +} diff --git a/src/SearchController.php b/app/src/Controllers/SearchController.php similarity index 100% rename from src/SearchController.php rename to app/src/Controllers/SearchController.php diff --git a/src/SystemController.php b/app/src/Controllers/SystemController.php similarity index 100% rename from src/SystemController.php rename to app/src/Controllers/SystemController.php diff --git a/src/TfeController.php b/app/src/Controllers/TfeController.php similarity index 100% rename from src/TfeController.php rename to app/src/Controllers/TfeController.php diff --git a/src/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php similarity index 100% rename from src/ThesisCreateController.php rename to app/src/Controllers/ThesisCreateController.php diff --git a/src/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php similarity index 100% rename from src/ThesisEditController.php rename to app/src/Controllers/ThesisEditController.php diff --git a/src/Database.php b/app/src/Database.php similarity index 95% rename from src/Database.php rename to app/src/Database.php index 0a03c7c..0783718 100644 --- a/src/Database.php +++ b/app/src/Database.php @@ -1,6 +1,6 @@ pdo->lastInsertId(); } + // ======================================================================== + // EXPORT HELPERS — used by ExportController + // ======================================================================== + + /** + * Fetch all theses (admin — includes unpublished) with every column + * needed for the CSV export. + */ + public function getAllThesesForExport(): array { + return $this->pdo->query(" + SELECT + t.id, t.identifier, t.title, t.subtitle, t.year, + o.name AS orientation, + ap.name AS ap_program, + ft.name AS finality_type, + at.name AS access_type, + lt.name AS license_name, + t.synopsis, + t.context_note, + t.remarks, + t.file_size_info, + t.jury_points, + t.baiu_link + FROM theses t + LEFT JOIN orientations o ON t.orientation_id = o.id + LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id + LEFT JOIN finality_types ft ON t.finality_id = ft.id + LEFT JOIN access_types at ON t.access_type_id = at.id + LEFT JOIN license_types lt ON t.license_id = lt.id + ORDER BY t.year DESC, t.title ASC + ")->fetchAll(); + } + + /** + * All thesis→author rows with author name and email. + */ + public function getAllThesisAuthorsForExport(): array { + return $this->pdo->query(" + SELECT ta.thesis_id, a.name, a.email + FROM thesis_authors ta + JOIN authors a ON a.id = ta.author_id + ORDER BY ta.thesis_id, ta.author_order + ")->fetchAll(); + } + + /** + * All thesis→supervisor rows with name. + */ + public function getAllThesisSupervisorsForExport(): array { + return $this->pdo->query(" + SELECT ts.thesis_id, s.name + FROM thesis_supervisors ts + JOIN supervisors s ON s.id = ts.supervisor_id + ORDER BY ts.thesis_id, ts.supervisor_order + ")->fetchAll(); + } + + /** + * All thesis→tag rows with tag name. + */ + public function getAllThesisTagsForExport(): array { + return $this->pdo->query(" + SELECT tt.thesis_id, t.name + FROM thesis_tags tt + JOIN tags t ON t.id = tt.tag_id + ORDER BY tt.thesis_id, t.name + ")->fetchAll(); + } + + /** + * All thesis→language rows with language name. + */ + public function getAllThesisLanguagesForExport(): array { + return $this->pdo->query(" + SELECT tl.thesis_id, l.name + FROM thesis_languages tl + JOIN languages l ON l.id = tl.language_id + ORDER BY tl.thesis_id, l.name + ")->fetchAll(); + } + + /** + * All thesis→format rows with format name. + */ + public function getAllThesisFormatsForExport(): array { + return $this->pdo->query(" + SELECT tf.thesis_id, ft.name + FROM thesis_formats tf + JOIN format_types ft ON ft.id = tf.format_id + ORDER BY tf.thesis_id, ft.name + ")->fetchAll(); + } + // ======================================================================== // SINGLETON PATTERN ENFORCEMENT // ======================================================================== diff --git a/app/src/Dispatcher.php b/app/src/Dispatcher.php new file mode 100644 index 0000000..36bf265 --- /dev/null +++ b/app/src/Dispatcher.php @@ -0,0 +1,151 @@ + → TfeController → tfe view + * /apropos → AboutController → about view + * /licence → LicenceController → licence view + * /media.php → MediaController (direct output) + * /live-reload → LiveReloadController (direct output) + * /partage/ → share-link flow + * /maintenance.php → static maintenance page + */ +class Dispatcher { + private const ROUTES = [ + '' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'], + '/' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'], + '/index.php' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'], + '/search.php' => ['controller' => 'SearchController', 'action' => 'handle', 'view' => 'public/search'], + '/repertoire' => ['controller' => 'SearchController', 'action' => 'handleRepertoire', 'view' => 'public/repertoire'], + '/repertoire.php' => ['controller' => 'SearchController', 'action' => 'handleRepertoire', 'view' => 'public/repertoire'], + '/tfe.php' => ['controller' => 'TfeController', 'action' => 'handle', 'view' => 'public/tfe'], + '/apropos' => ['controller' => 'AboutController', 'action' => 'handle', 'view' => 'public/about'], + '/apropos.php' => ['controller' => 'AboutController', 'action' => 'handle', 'view' => 'public/about'], + '/licence' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'], + '/licence.php' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'], + ]; + + private string $path; + private array $queryParams; + + public function __construct() { + $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + $this->path = $uri; + $this->queryParams = $_GET; + } + + /** + * Resolve the URI to a route, instantiate the controller, + * execute the action, and render the view. + */ + public function dispatch(): void { + // 1. Direct-response endpoints (render their own output) + $direct = $this->matchDirect(); + if ($direct) { + $direct(); + return; + } + + // 2. Routed pages (controller + view) + $route = $this->matchRoute(); + if (!$route) { + http_response_code(404); + echo '

404 — Page non trouvée

'; + return; + } + + // 3. Load controller + $ctrlClass = $route['controller']; + require_once APP_ROOT . '/src/Controllers/' . $ctrlClass . '.php'; + + $controller = $ctrlClass::create(); + $vars = $controller->{$route['action']}(); + + // 4. Render view + $this->render($route['view'], $vars); + } + + /** + * Match endpoints that render their own response (no view layer). + */ + private function matchDirect(): ?callable { + $path = $this->path; + + // /live-reload + if ($path === '/live-reload' || $path === '/live-reload.php') { + return function() { + require_once APP_ROOT . '/src/Controllers/LiveReloadController.php'; + $controller = new LiveReloadController(APP_ROOT); + $result = $controller->handle(); + header('Content-Type: application/json'); + echo json_encode($result['body']); + }; + } + + // /media.php + if ($path === '/media' || $path === '/media.php') { + return function() { + require_once APP_ROOT . '/src/Controllers/MediaController.php'; + $controller = new MediaController(); + $controller->handle(); + }; + } + + // /maintenance.php + if ($path === '/maintenance' || $path === '/maintenance.php') { + return function() { + require APP_ROOT . '/public/maintenance.php'; + }; + } + + // /partage/* + if (preg_match('#^/partage(/.*)?$#', $path)) { + return function() { + require APP_ROOT . '/public/partage/index.php'; + }; + } + + return null; + } + + /** + * Match the current path against the static route table. + * Supports exact match and prefix-based (for /tfe?id=). + */ + private function matchRoute(): ?array { + $path = $this->path; + + // Exact match first + if (isset(self::ROUTES[$path])) { + return self::ROUTES[$path]; + } + + // /tfe?id= pattern (TFeController handles the id param internally) + if (preg_match('#^/tfe$#', $path) && isset($_GET['id'])) { + return self::ROUTES['/tfe.php']; + } + + return null; + } + + /** + * Render a view template, passing controller data through extract(). + */ + private function render(string $view, array $vars): void { + $viewPath = APP_ROOT . '/templates/' . $view . '.php'; + if (!file_exists($viewPath)) { + http_response_code(500); + echo "View not found: {$viewPath}"; + return; + } + extract($vars); + include $viewPath; + } +} diff --git a/src/Parsedown.php b/app/src/Parsedown.php similarity index 100% rename from src/Parsedown.php rename to app/src/Parsedown.php diff --git a/src/RateLimit.php b/app/src/RateLimit.php similarity index 100% rename from src/RateLimit.php rename to app/src/RateLimit.php diff --git a/src/ShareLink.php b/app/src/ShareLink.php similarity index 100% rename from src/ShareLink.php rename to app/src/ShareLink.php diff --git a/app/src/SmtpRelay.php b/app/src/SmtpRelay.php new file mode 100644 index 0000000..a3f0db0 --- /dev/null +++ b/app/src/SmtpRelay.php @@ -0,0 +1,181 @@ +getPDO()->query( + "SELECT host, port, encryption, username, password, from_email, from_name + FROM v_smtp_active LIMIT 1" + ); + $row = $stmt->fetch(); + + return $row ?: [ + 'host' => '', + 'port' => 587, + 'encryption' => 'tls', + 'username' => '', + 'password' => '', + 'from_email' => '', + 'from_name' => 'Post-ERG', + ]; + } + + /** + * Upsert SMTP settings. + * + * @param array $data Associative array with keys: host, port, encryption, + * username, password, from_email, from_name. + * Keys not present are left unchanged. + */ + public static function updateSettings(Database $db, array $data): void { + // Read existing so we can merge partial updates + $current = self::getSettings($db); + $merged = array_merge($current, $data); + + // Sanitize + $port = max(1, min(65535, (int)$merged['port'])); + $encryption = in_array($merged['encryption'], ['tls', 'ssl', 'none'], true) + ? $merged['encryption'] : 'tls'; + + $stmt = $db->getPDO()->prepare( + "UPDATE smtp_settings + SET host = :host, + port = :port, + encryption = :encryption, + username = :username, + password = :password, + from_email = :from_email, + from_name = :from_name, + updated_at = CURRENT_TIMESTAMP + WHERE id = 1" + ); + + $stmt->execute([ + ':host' => trim($merged['host']), + ':port' => $port, + ':encryption' => $encryption, + ':username' => trim($merged['username']), + ':password' => $merged['password'], // keep as-is + ':from_email' => trim($merged['from_email']), + ':from_name' => trim($merged['from_name']), + ]); + } + + /** + * Check whether the SMTP relay is fully configured. + */ + public static function isConfigured(Database $db): bool { + $s = self::getSettings($db); + return $s['host'] !== '' && $s['username'] !== '' && $s['from_email'] !== ''; + } + + // ----------------------------------------------------------------------- + // Send helpers (transport wired later — stub implementation now) + // ----------------------------------------------------------------------- + + /** + * Send an e-mail using the stored SMTP credentials. + * + * Currently uses PHP's `mail()` as a passthrough so the rest of the + * application can call `SmtpRelay::send(…)` everywhere. + * The actual SMTP transport layer will be wired in a later iteration + * (e.g. replace this body with PHPMailer / Symfony Mailer). + * + * @param string $to Recipient e-mail address + * @param string $subject Subject line + * @param string $body HTML body + * @param string $plain Plain-text alternative (optional) + * @return bool True on send request acceptance; false on failure + */ + public static function send( + Database $db, + string $to, + string $subject, + string $body, + string $plain = '' + ): bool { + $settings = self::getSettings($db); + if ($settings['from_email'] === '') { + error_log('[SmtpRelay] send() aborted — no from_email configured'); + return false; + } + + // Build MIME multipart headers + $boundary = 'posterg_' . md5((string) random_int(0, PHP_INT_MAX) . microtime(true)); + $headers = "From: {$settings['from_name']} <{$settings['from_email']}>\r\n"; + $headers .= "Reply-To: {$settings['from_email']}\r\n"; + $headers .= "MIME-Version: 1.0\r\n"; + + if ($plain !== '') { + $headers .= "Content-Type: multipart/alternative; boundary=\"{$boundary}\"\r\n"; + $message = "--{$boundary}\r\n"; + $message .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; + $message .= self::htmlToPlain($body) . "\r\n\r\n"; + $message .= "--{$boundary}\r\n"; + $message .= "Content-Type: text/html; charset=UTF-8\r\n\r\n"; + $message .= $body . "\r\n\r\n"; + $message .= "--{$boundary}--"; + } else { + $headers .= "Content-Type: text/html; charset=UTF-8\r\n"; + $message = $body; + } + + // TODO: replace with real SMTP transport (PHPMailer / Symfony Mailer) + // The stored credentials ($settings) will be passed to the mailer then. + $ok = mail($to, $subject, $message, $headers); + + if (!$ok) { + error_log("[SmtpRelay] mail() returned false for {$to}"); + } + + return $ok; + } + + /** + * Queue (persist) an e-mail for deferred sending. + * + * Stub — will create a `mail_queue` table in a future migration. + */ + public static function queue( + Database $db, + string $to, + string $subject, + string $body, + string $plain = '' + ): void { + // TODO: INSERT INTO mail_queue … + // Placeholder so callers exist now and wire up later. + } + + // ----------------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------------- + + /** + * Strip HTML tags to produce a rough plain-text fallback. + */ + private static function htmlToPlain(string $html): string { + $text = strip_tags($html); + // Collapse multiple whitespace lines + $text = preg_replace('/\n{3,}/', "\n\n", $text); + return trim($text); + } +} diff --git a/src/SystemCache.php b/app/src/SystemCache.php similarity index 100% rename from src/SystemCache.php rename to app/src/SystemCache.php diff --git a/storage/.gitkeep b/app/storage/.gitkeep similarity index 100% rename from storage/.gitkeep rename to app/storage/.gitkeep diff --git a/storage/Database_TFE_test.csv b/app/storage/Database_TFE_test.csv similarity index 100% rename from storage/Database_TFE_test.csv rename to app/storage/Database_TFE_test.csv diff --git a/storage/README.md b/app/storage/README.md similarity index 100% rename from storage/README.md rename to app/storage/README.md diff --git a/storage/db.sqlite b/app/storage/cache/.gitkeep similarity index 100% rename from storage/db.sqlite rename to app/storage/cache/.gitkeep diff --git a/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json new file mode 100644 index 0000000..3da2b3d --- /dev/null +++ b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json @@ -0,0 +1 @@ +[1776449542] \ No newline at end of file diff --git a/storage/fixtures/CreateTestDatabase.php b/app/storage/fixtures/CreateTestDatabase.php similarity index 100% rename from storage/fixtures/CreateTestDatabase.php rename to app/storage/fixtures/CreateTestDatabase.php diff --git a/storage/migrations/001_rename_keywords_to_tags.sql b/app/storage/migrations/001_rename_keywords_to_tags.sql similarity index 100% rename from storage/migrations/001_rename_keywords_to_tags.sql rename to app/storage/migrations/001_rename_keywords_to_tags.sql diff --git a/storage/migrations/002_add_visibility.sql b/app/storage/migrations/002_add_visibility.sql similarity index 100% rename from storage/migrations/002_add_visibility.sql rename to app/storage/migrations/002_add_visibility.sql diff --git a/storage/migrations/003_seed_license_types.sql b/app/storage/migrations/003_seed_license_types.sql similarity index 100% rename from storage/migrations/003_seed_license_types.sql rename to app/storage/migrations/003_seed_license_types.sql diff --git a/storage/migrations/004_jury_roles.sql b/app/storage/migrations/004_jury_roles.sql similarity index 100% rename from storage/migrations/004_jury_roles.sql rename to app/storage/migrations/004_jury_roles.sql diff --git a/storage/migrations/005_add_banner.sql b/app/storage/migrations/005_add_banner.sql similarity index 100% rename from storage/migrations/005_add_banner.sql rename to app/storage/migrations/005_add_banner.sql diff --git a/storage/migrations/006_add_composite_index.sql b/app/storage/migrations/006_add_composite_index.sql similarity index 100% rename from storage/migrations/006_add_composite_index.sql rename to app/storage/migrations/006_add_composite_index.sql diff --git a/storage/migrations/007_system_cache.sql b/app/storage/migrations/007_system_cache.sql similarity index 100% rename from storage/migrations/007_system_cache.sql rename to app/storage/migrations/007_system_cache.sql diff --git a/storage/migrations/008_formulaire_settings.sql b/app/storage/migrations/008_formulaire_settings.sql similarity index 100% rename from storage/migrations/008_formulaire_settings.sql rename to app/storage/migrations/008_formulaire_settings.sql diff --git a/storage/migrations/009_share_links.sql b/app/storage/migrations/009_share_links.sql similarity index 100% rename from storage/migrations/009_share_links.sql rename to app/storage/migrations/009_share_links.sql diff --git a/storage/migrations/010_apropos_contents.sql b/app/storage/migrations/010_apropos_contents.sql similarity index 100% rename from storage/migrations/010_apropos_contents.sql rename to app/storage/migrations/010_apropos_contents.sql diff --git a/storage/migrations/011_apropos_entries.sql b/app/storage/migrations/011_apropos_entries.sql similarity index 100% rename from storage/migrations/011_apropos_entries.sql rename to app/storage/migrations/011_apropos_entries.sql diff --git a/app/storage/migrations/012_smtp_settings.sql b/app/storage/migrations/012_smtp_settings.sql new file mode 100644 index 0000000..1ee356c --- /dev/null +++ b/app/storage/migrations/012_smtp_settings.sql @@ -0,0 +1,22 @@ +-- SMTP relay credentials stored in the database. +-- A single active row is read at send-time for flexibility (change provider, +-- rotate passwords, etc. without touching code or env vars). + +CREATE TABLE IF NOT EXISTS smtp_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row + host TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 587, + encryption TEXT NOT NULL DEFAULT 'tls', -- 'tls' | 'ssl' | 'none' + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', -- stored in clear for now; encrypt later + from_email TEXT NOT NULL DEFAULT '', + from_name TEXT NOT NULL DEFAULT 'Post-ERG', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Insert default empty row so the settings form can start working immediately. +INSERT OR IGNORE INTO smtp_settings (id) VALUES (1); + +-- Helper view so callers always read the same row. +CREATE VIEW IF NOT EXISTS v_smtp_active AS +SELECT * FROM smtp_settings WHERE id = 1; diff --git a/app/storage/migrations/013_admin_password.sql b/app/storage/migrations/013_admin_password.sql new file mode 100644 index 0000000..bb72888 --- /dev/null +++ b/app/storage/migrations/013_admin_password.sql @@ -0,0 +1,11 @@ +-- Migration 013: Store admin password hash in site_settings +-- +-- Previously stored in config/admin_credentials.php as the constant ADMIN_PASSWORD_HASH. +-- Now stored alongside SMTP credentials in the site_settings key-value table. +-- +-- After applying this migration, import your existing hash manually: +-- UPDATE site_settings SET value = '$2y$12$...' WHERE key = 'admin_password_hash'; +-- Or simply set a new one via the admin panel UI. + +INSERT OR IGNORE INTO site_settings (key, value) VALUES + ('admin_password_hash', ''); diff --git a/storage/posterg.db b/app/storage/posterg.db similarity index 96% rename from storage/posterg.db rename to app/storage/posterg.db index 0a75020a1187a9e2a8542c65eb9774e8e21890db..b66f21a33f3e1a1dc9d4686bcb621193c8fb6b24 100644 GIT binary patch delta 1176 zcmZuvU2M}<6t;a$LLA$t70S{U3K#Zc)+Low_cv)tf94ES#-_Buo|?L;p+<=vY_}0p zrFEhY6Pna*2>gIVr@{j-n}m??3lHo^+6GL#piMkXnt0g@(ljAWnkpf#lLjrQSF(@J zx!?J|^K(~LT2}71e6hPVLs8T?`5H(0GNH6*sP0QY?;s%-o(31=unGUdKk)QcYfBaQ z-eD4ZLm|KR&hwk#Zvq{u&=G&f3Vl zZ+kmsri+h0k4{H-a~f3+#|V7EzacZQ(>1PY8dvPO^i&xRTCStk(*v!fIogQ%#9 z2L)45*iqpzTt|E%nGf{YkkunHs>U!L&S-mgRSNG(gZj z1*hy*3Xbl&hI!g~6)dMya#|ToMo~ub9?qb+A3KKrruPcsWeWa)@8KGJ4vTOchG7q| z;$Px@@uqm0)Z>bM9cYN&z8s+)4UnsCw0Zyj3>w*gg3zfjlWxiR&~n_Qnt$*Al3NoBawFkc3Rd~w_;r3b`j9)p#ljE5vxF-b{^X1r zj!DwaPvc95UR|mWT`RiAlHTM@^Z+dL}sM^Hny?F>psfokt#3Vjg zr+qttF-|72Lmo;=cP$g!R+S?$yg=p>Sd6`2)671uV%ZJ<&??=rt8Z{1Q3L8`(LPsk z4a*FGZG&W{Qo!0cgk1_^co|EMQ>t0hGIgnzz3Aw+sg?DYGFvaa?sw!GEOi@H>RPrw zj|~$SOS)#`QNcb>UNF$lx>v@dRa3{^%iP=O{`Wa*I2!j@b+fWqW^dV oouj>rSyc9185Ki)*--q*>)!qh)9zhYP@Dg|jJ{>O9~HFx0>k4=)Bpeg delta 401 zcmXwy%_~Gv7{=dw&l${I_dItN(`4o%Wg#INC5()Jz`|En6C)vwELbRM%Ev-#e0CNV zqp`7}q@0ZfvcN3tNK&$~l$6qxoJpQ~7w_+RpXW`TJE@ej9`?5pQB2Rjnl|bp!8U5x zx%Ozp@KG?Dk;fN4PW;XSimMBevWmq#R33(R+VCk)$~YFr>XzSGk*86!qo~s@3ij5^ zi?mUxGHGLUkB8`hq_5Ge6{TI)kJV>|eZ!P24(QCdI%qTxy`>}yzy74AI@qSEKBK1i zEN5rrr5Um#JjSl*fi2Svw(Ljr5TQpG49KXJ5RQ!-eT<$ zc^79v73gN~M#vI5!ZR+hk2TDqA5pmA;!Wgq;83VsoV8Z@7!rhA>>#0OA7moUvKnRy QFE~e5!)>T+hQ)_|0m~O=EC2ui diff --git a/storage/schema.sql b/app/storage/schema.sql similarity index 95% rename from storage/schema.sql rename to app/storage/schema.sql index 5469e59..59887ef 100644 --- a/storage/schema.sql +++ b/app/storage/schema.sql @@ -292,7 +292,8 @@ CREATE TABLE IF NOT EXISTS site_settings ( INSERT OR IGNORE INTO site_settings (key, value) VALUES ('access_type_interdit_enabled', '1'), ('access_type_interne_enabled', '1'), - ('access_type_libre_enabled', '0'); + ('access_type_libre_enabled', '0'), + ('admin_password_hash', ''); -- ============================================================================ -- STATIC PAGES / CONTENT MANAGEMENT @@ -321,6 +322,25 @@ INSERT OR IGNORE INTO pages (slug, title, content) VALUES ('about', 'À propos', 'Contenu à venir'), ('licenses', 'Licences', 'Contenu à venir'); +-- ============================================================================ +-- SMTP SETTINGS +-- ============================================================================ + +-- Singleton row — id is always 1. Credentials stored in clear for now. +CREATE TABLE IF NOT EXISTS smtp_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + host TEXT NOT NULL DEFAULT '', + port INTEGER NOT NULL DEFAULT 587, + encryption TEXT NOT NULL DEFAULT 'tls', -- 'tls' | 'ssl' | 'none' + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', -- stored in clear for now; encrypt later + from_email TEXT NOT NULL DEFAULT '', + from_name TEXT NOT NULL DEFAULT 'Post-ERG', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +INSERT OR IGNORE INTO smtp_settings (id) VALUES (1); + -- ============================================================================ -- APROPOS CONTENTS (structured data for the "À propos" page) -- ============================================================================ diff --git a/app/storage/test.db b/app/storage/test.db new file mode 100644 index 0000000000000000000000000000000000000000..b2a0e0547657a1142e0b7c7598a0fb9d830dd506 GIT binary patch literal 569344 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 mq9y;42uK7Z0ulj + + +
+ + + + + + + + + + + diff --git a/templates/footer.php b/app/templates/footer.php similarity index 100% rename from templates/footer.php rename to app/templates/footer.php diff --git a/templates/head.php b/app/templates/head.php similarity index 100% rename from templates/head.php rename to app/templates/head.php diff --git a/templates/header.php b/app/templates/header.php similarity index 98% rename from templates/header.php rename to app/templates/header.php index 3373c27..2ef9765 100644 --- a/templates/header.php +++ b/app/templates/header.php @@ -25,7 +25,7 @@ $_thesisId = $_GET['id'] ?? null;
  • >Modifier
  • - +
  • Déconnexion
  • diff --git a/app/templates/partials/flash-messages.php b/app/templates/partials/flash-messages.php new file mode 100644 index 0000000..7397091 --- /dev/null +++ b/app/templates/partials/flash-messages.php @@ -0,0 +1,21 @@ + + + + diff --git a/templates/partials/form/checkbox-list.php b/app/templates/partials/form/checkbox-list.php similarity index 100% rename from templates/partials/form/checkbox-list.php rename to app/templates/partials/form/checkbox-list.php diff --git a/templates/partials/form/file-field.php b/app/templates/partials/form/file-field.php similarity index 100% rename from templates/partials/form/file-field.php rename to app/templates/partials/form/file-field.php diff --git a/templates/partials/form/jury-fieldset.php b/app/templates/partials/form/jury-fieldset.php similarity index 100% rename from templates/partials/form/jury-fieldset.php rename to app/templates/partials/form/jury-fieldset.php diff --git a/templates/partials/form/select-field.php b/app/templates/partials/form/select-field.php similarity index 100% rename from templates/partials/form/select-field.php rename to app/templates/partials/form/select-field.php diff --git a/templates/partials/form/text-field.php b/app/templates/partials/form/text-field.php similarity index 100% rename from templates/partials/form/text-field.php rename to app/templates/partials/form/text-field.php diff --git a/templates/partials/pagination.php b/app/templates/partials/pagination.php similarity index 100% rename from templates/partials/pagination.php rename to app/templates/partials/pagination.php diff --git a/templates/partials/repertoire-index.php b/app/templates/partials/repertoire-index.php similarity index 100% rename from templates/partials/repertoire-index.php rename to app/templates/partials/repertoire-index.php diff --git a/templates/partials/status-badge.php b/app/templates/partials/status-badge.php similarity index 100% rename from templates/partials/status-badge.php rename to app/templates/partials/status-badge.php diff --git a/app/templates/public/home.php b/app/templates/public/home.php new file mode 100644 index 0000000..4378ec3 --- /dev/null +++ b/app/templates/public/home.php @@ -0,0 +1,49 @@ + +

    + Année : + Réinitialiser +

    + + + + +
    +

    Mémoires de l'ERG

    + + + +
    diff --git a/app/templates/public/licence.php b/app/templates/public/licence.php new file mode 100644 index 0000000..ba4d4da --- /dev/null +++ b/app/templates/public/licence.php @@ -0,0 +1,9 @@ +
    +
    + + + +

    Contenu à venir.

    + +
    +
    diff --git a/templates/search-bar.php b/app/templates/search-bar.php similarity index 100% rename from templates/search-bar.php rename to app/templates/search-bar.php diff --git a/tests/Integration/SearchTest.php b/app/tests/Integration/SearchTest.php similarity index 100% rename from tests/Integration/SearchTest.php rename to app/tests/Integration/SearchTest.php diff --git a/tests/README.md b/app/tests/README.md similarity index 100% rename from tests/README.md rename to app/tests/README.md diff --git a/tests/Security/SecurityTest.php b/app/tests/Security/SecurityTest.php similarity index 100% rename from tests/Security/SecurityTest.php rename to app/tests/Security/SecurityTest.php diff --git a/tests/Unit/DatabaseTest.php b/app/tests/Unit/DatabaseTest.php similarity index 100% rename from tests/Unit/DatabaseTest.php rename to app/tests/Unit/DatabaseTest.php diff --git a/tests/Unit/RateLimitTest.php b/app/tests/Unit/RateLimitTest.php similarity index 100% rename from tests/Unit/RateLimitTest.php rename to app/tests/Unit/RateLimitTest.php diff --git a/tests/run-tests.php b/app/tests/run-tests.php similarity index 100% rename from tests/run-tests.php rename to app/tests/run-tests.php diff --git a/config/admin_credentials.example.php b/config/admin_credentials.example.php deleted file mode 100644 index 5c1a259..0000000 --- a/config/admin_credentials.example.php +++ /dev/null @@ -1,13 +0,0 @@ -&1 | stdbuf -oL grep -E '(Development Server|\[200\])' | stdbuf -oL grep -v 'live-reload\.php' || true + @php -S 127.0.0.1:8000 -t app/public/ app/router.php 2>&1 | stdbuf -oL grep -E '(Development Server|\[200\])' | stdbuf -oL grep -v 'live-reload\.php' || true [group('dev')] stop: @@ -29,7 +29,7 @@ logs: [group('deploy')] deploy: - rsync -vur --progress \ + rsync -vur --progress --delete \ --chown="www-data:posterg" \ --exclude 'vendor' \ --exclude 'tests' \ @@ -44,13 +44,9 @@ deploy: --exclude 'storage/cache/*' \ --exclude 'storage/fixtures' \ --exclude 'storage/docs' \ - --exclude 'nginx' \ - --exclude 'docs' \ - --exclude 'justfile*' \ - --exclude 'scripts' \ --exclude 'var/cache/*' \ --exclude 'var/logs/*' \ - ./ posterg:/var/www/posterg/ + app/ posterg:/var/www/posterg/ ssh posterg "mkdir -p /var/www/posterg/var/{cache,logs,tmp}" [group('deploy')] @@ -85,7 +81,7 @@ deploy-nginx: [group('deploy')] deploy-db: @ssh posterg '[ ! -f /var/www/posterg/storage/test.db ]' || (echo "ERROR: remote database already exists. Remove it manually if you intend to overwrite." && exit 1) - rsync -v --progress ./storage/test.db posterg:/var/www/posterg/storage/test.db + rsync -v --progress app/storage/test.db posterg:/var/www/posterg/storage/test.db ssh posterg "chown www-data:posterg /var/www/posterg/storage/test.db && chmod 660 /var/www/posterg/storage/test.db" # ============================================================================ @@ -94,27 +90,25 @@ deploy-db: [group('test')] test: - @DB_ENV=test php tests/run-tests.php + @DB_ENV=test php app/tests/run-tests.php [group('test')] test-unit: - @DB_ENV=test php tests/Unit/DatabaseTest.php - @DB_ENV=test php tests/Unit/RateLimitTest.php + @DB_ENV=test php app/tests/Unit/DatabaseTest.php + @DB_ENV=test php app/tests/Unit/RateLimitTest.php [group('test')] test-integration: - @DB_ENV=test php tests/Integration/SearchTest.php + @DB_ENV=test php app/tests/Integration/SearchTest.php [group('test')] test-security: - @DB_ENV=test php tests/Security/SecurityTest.php + @DB_ENV=test php app/tests/Security/SecurityTest.php [group('test')] syntax: - @find . -maxdepth 1 -name "*.php" -not -path "./vendor/*" -exec php -l {} \; | grep -v "No syntax errors" - @find admin/ -name "*.php" -exec php -l {} \; 2>/dev/null | grep -v "No syntax errors" || true - @find src/ -name "*.php" -exec php -l {} \; | grep -v "No syntax errors" - @echo "✅ Syntax OK" + @find app/ -name '*.php' -exec php -l {} \; 2>/dev/null | grep -v 'No syntax errors' || true + @echo '✅ Syntax OK' # ============================================================================ # Database @@ -135,29 +129,29 @@ migrate-prod: [group('database')] init-db: - @sqlite3 storage/test.db < storage/schema.sql - @sqlite3 storage/test.db "SELECT COUNT(*) || ' tables' FROM sqlite_master WHERE type='table';" + @sqlite3 app/storage/test.db < app/storage/schema.sql + @sqlite3 app/storage/test.db "SELECT COUNT(*) || ' tables' FROM sqlite_master WHERE type='table';" [group('database')] reset-db: - @rm -f storage/test.db + @rm -f app/storage/test.db @just init-db [group('database')] query: - @sqlite3 storage/test.db + @sqlite3 app/storage/test.db [group('database')] show id: - @sqlite3 -column -header storage/test.db "SELECT * FROM v_theses_full WHERE id = {{id}};" + @sqlite3 -column -header app/storage/test.db "SELECT * FROM v_theses_full WHERE id = {{id}};"; [group('database')] backup: - @sqlite3 storage/test.db .dump > storage/backup_$(date +%Y%m%d_%H%M%S).sql + @sqlite3 app/storage/test.db .dump > app/storage/backup_$(date +%Y%m%d_%H%M%S).sql [group('database')] fixtures: - @php storage/fixtures/CreateTestDatabase.php + @php app/storage/fixtures/CreateTestDatabase.php # ============================================================================ # Utils @@ -165,11 +159,11 @@ fixtures: [group('utils')] clean: - @rm -f error.log admin/error.log - @rm -rf src/cache/rate_limit/* + @rm -f app/error.log + @rm -rf app/storage/cache/rate_limit/* @rm -f /tmp/posterg-*.log /tmp/posterg-*.pid [group('utils')] setup-dirs: - @mkdir -p admin/data/{theses,covers,yaml} src/cache/rate_limit - @touch admin/data/theses/.gitkeep admin/data/covers/.gitkeep + @mkdir -p app/storage/cache/rate_limit + @touch app/storage/cache/rate_limit/.gitkeep diff --git a/nginx/posterg.conf b/nginx/posterg.conf index 899be36..e2221b1 100644 --- a/nginx/posterg.conf +++ b/nginx/posterg.conf @@ -1,5 +1,4 @@ # Nginx configuration for Post-ERG thesis website (Production) -# Updated for new directory structure # Place this in /etc/nginx/sites-available/posterg # Then symlink: ln -s /etc/nginx/sites-available/posterg /etc/nginx/sites-enabled/ @@ -15,24 +14,22 @@ limit_req_zone $binary_remote_addr zone=admin:10m rate=60r/m; server { listen 80 default_server; listen [::]:80 default_server; - + server_name posterg.erg.be www.posterg.erg.be; # Document root points to /public (only web-accessible files) - # Project structure: /var/www/posterg/ - # /config - Configuration (outside webroot) - # /docs - Documentation (outside webroot) - # /nginx - Server configs (outside webroot) + # Deployed structure: /var/www/posterg/ # /public - Web root ← THIS DIRECTORY # /admin - Admin interface # /assets - CSS, fonts, icons - # /scripts - Deployment scripts (outside webroot) # /src - PHP source classes (outside webroot) # /storage - SQLite databases (outside webroot) # /templates - PHP templates (outside webroot) # /tests - Test suites (outside webroot) + # /bootstrap.php - Application entry point + # /router.php - Dev server URL rewriter root /var/www/posterg/public; - + # Add index.php to the list index index.php index.html index.htm; @@ -135,10 +132,10 @@ server { location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/var/run/php/php8.4-fpm.sock; - + # Security parameters fastcgi_param PHP_VALUE "upload_max_filesize=50M \n post_max_size=100M"; - + # Timeouts fastcgi_read_timeout 120; fastcgi_send_timeout 120; @@ -146,7 +143,7 @@ server { # Additional security headers for admin add_header X-Robots-Tag "noindex, nofollow" always; - + # Try to serve file, otherwise 404 try_files $uri $uri/ =404; } @@ -173,7 +170,7 @@ server { # Security parameters fastcgi_param PHP_VALUE "upload_max_filesize=50M \n post_max_size=100M"; - + # Timeouts fastcgi_read_timeout 120; fastcgi_send_timeout 120; diff --git a/public/media.php b/public/media.php deleted file mode 100644 index 87df8fc..0000000 --- a/public/media.php +++ /dev/null @@ -1,121 +0,0 @@ -getFileVisibility($requestedPath); - if ($accessTypeId !== null && $accessTypeId === 3) { - // 3 = Interdit — block entirely - http_response_code(403); - exit; - } - // 2 = Interne — allow (no session auth requirement for now; could add later) - } catch (\Throwable $e) { - // On DB error, fail open (don't block legitimate requests) - error_log("media.php visibility check error: " . $e->getMessage()); - } -} - -// --- 3. Verify MIME type from file content (not extension) -------------------- - -$finfo = new finfo(FILEINFO_MIME_TYPE); -$mimeType = $finfo->file($realFull); - -$allowedMimes = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'application/pdf', - 'video/mp4', - 'application/zip', - 'text/vtt', // WebVTT caption sidecar files -]; - -// finfo may return 'text/plain' for WebVTT files on some systems; -// re-classify by extension so we don't block them. -if ($mimeType === 'text/plain' && strtolower(pathinfo($realFull, PATHINFO_EXTENSION)) === 'vtt') { - $mimeType = 'text/vtt'; -} - -if (!in_array($mimeType, $allowedMimes, true)) { - http_response_code(403); - exit; -} - -// --- 4. Send response headers ------------------------------------------------- - -header('Content-Type: ' . $mimeType); -header('Content-Length: ' . filesize($realFull)); -header('X-Content-Type-Options: nosniff'); - -$ext = strtolower(pathinfo($realFull, PATHINFO_EXTENSION)); - -if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif'], true)) { - // Images: cache publicly for 7 days - header('Cache-Control: public, max-age=604800'); -} elseif ($ext === 'pdf') { - // PDFs: cache for 1 day, display inline - header('Cache-Control: public, max-age=86400'); - header('Content-Disposition: inline'); -} elseif ($ext === 'vtt') { - // WebVTT captions: serve as text/vtt, cache 1 day - header('Content-Type: text/vtt; charset=utf-8'); - header('Cache-Control: public, max-age=86400'); -} else { - // Everything else: no public caching - header('Cache-Control: private, no-store'); -} - -// --- 5. Stream file ----------------------------------------------------------- - -readfile($realFull); diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 1b1ffc9..0dd1b04 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -8,9 +8,10 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -MIGRATIONS_DIR="$REPO_ROOT/storage/migrations" -TEST_DB="$REPO_ROOT/storage/test.db" -PROD_DB="$REPO_ROOT/storage/posterg.db" +APP_DIR="$REPO_ROOT/app" +MIGRATIONS_DIR="$APP_DIR/storage/migrations" +TEST_DB="$APP_DIR/storage/test.db" +PROD_DB="$APP_DIR/storage/posterg.db" # --------------------------------------------------------------------------- # Check whether a migration's effects are already present in the DB so that @@ -58,6 +59,15 @@ already_applied_structurally() { col=$(sqlite3 "$db" "SELECT COUNT(*) FROM pragma_table_info('authors') WHERE name='show_contact';") [ "$tbl" -eq 1 ] && [ "$col" -eq 1 ] ;; + 012_smtp_settings.sql) + tbl=$(sqlite3 "$db" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='smtp_settings';") + [ "$tbl" -eq 1 ] + ;; + 013_admin_password.sql) + # Effect: admin_password_hash key exists in site_settings (INSERT OR IGNORE — safe to re-run) + count=$(sqlite3 "$db" "SELECT COUNT(*) FROM site_settings WHERE key='admin_password_hash';") + [ "$count" -eq 1 ] + ;; *) # Unknown migration — assume not applied return 1 @@ -74,7 +84,7 @@ migrate_db() { table_count=$(sqlite3 "$db" "SELECT COUNT(*) FROM sqlite_master WHERE type='table';" 2>/dev/null || echo 0) if [ "$table_count" -eq 0 ]; then echo " [$label] initialising from schema…" - sqlite3 "$db" < "$REPO_ROOT/storage/schema.sql" + sqlite3 "$db" < "$APP_DIR/storage/schema.sql" echo " [$label] schema applied." fi diff --git a/storage/thesis.db b/storage/thesis.db deleted file mode 100644 index e69de29..0000000 diff --git a/templates/admin/footer.php b/templates/admin/footer.php deleted file mode 100644 index 770d4df..0000000 --- a/templates/admin/footer.php +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/templates/partials/flash-messages.php b/templates/partials/flash-messages.php deleted file mode 100644 index e7e8457..0000000 --- a/templates/partials/flash-messages.php +++ /dev/null @@ -1,18 +0,0 @@ - - -

    - - -

    - diff --git a/test.db b/test.db deleted file mode 100644 index e69de29..0000000