mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 09:53:08 +02:00
285 lines
13 KiB
Markdown
285 lines
13 KiB
Markdown
# File Uploads
|
|
|
|
Reference for all file upload handling in XAMXAM: accepted types, size limits, storage layout, display behaviour, and ordering.
|
|
|
|
---
|
|
|
|
## Upload surfaces
|
|
|
|
There are three forms where files can be uploaded:
|
|
|
|
| Surface | Path | Who uses it |
|
|
|---------|------|-------------|
|
|
| Admin — add thesis | `/admin/add.php` | Administrator |
|
|
| Admin — edit thesis | `/admin/edit.php?id=N` | Administrator |
|
|
| Student submission | `/partage/<slug>` | Student via share link |
|
|
|
|
All three surfaces share the same backend controller logic (`ThesisCreateController` / `ThesisEditController`) and the same validation rules.
|
|
|
|
---
|
|
|
|
## File categories
|
|
|
|
Each uploaded file is assigned a `file_type` that controls how it is displayed on the public TFE page.
|
|
|
|
| `file_type` | How displayed | Trigger |
|
|
|-------------|---------------|---------|
|
|
| `main` | Inline `<iframe>` with download fallback | `.pdf` extension |
|
|
| `image` | `<img>` | image/* MIME or image extension |
|
|
| `video` | `<video controls>` with Range support | video/* MIME or video extension |
|
|
| `audio` | `<audio controls>` with Range support | audio/* MIME or audio extension |
|
|
| `caption` | Not displayed — paired with preceding video | `.vtt` extension |
|
|
| `cover` | Cover thumbnail (not shown in file loop) | Separate `couverture` input |
|
|
| `other` | Download link only, never rendered | Everything else |
|
|
|
|
---
|
|
|
|
## Accepted file types
|
|
|
|
### TFE content files (`files[]` input)
|
|
|
|
#### Documents
|
|
| Extension | MIME type | Display |
|
|
|-----------|-----------|---------|
|
|
| `.pdf` | `application/pdf` | Inline iframe |
|
|
|
|
#### Images
|
|
| Extension | MIME type | Display |
|
|
|-----------|-----------|---------|
|
|
| `.jpg` / `.jpeg` | `image/jpeg` | `<img>` |
|
|
| `.png` | `image/png` | `<img>` |
|
|
| `.gif` | `image/gif` | `<img>` |
|
|
| `.webp` | `image/webp` | `<img>` |
|
|
|
|
#### Video
|
|
| Extension | MIME type | Display |
|
|
|-----------|-----------|---------|
|
|
| `.mp4` | `video/mp4` | `<video>` |
|
|
| `.webm` | `video/webm` | `<video>` |
|
|
| `.mov` | `video/quicktime` | `<video>` (served with Range support) |
|
|
| `.ogv` | `video/ogg` | `<video>` |
|
|
|
|
#### Audio
|
|
| Extension | MIME type | Display |
|
|
|-----------|-----------|---------|
|
|
| `.mp3` | `audio/mpeg` | `<audio>` |
|
|
| `.ogg` / `.oga` | `audio/ogg` | `<audio>` |
|
|
| `.wav` | `audio/wav` | `<audio>` |
|
|
| `.flac` | `audio/flac` | `<audio>` |
|
|
| `.aac` | `audio/aac` | `<audio>` |
|
|
| `.m4a` | `audio/mp4` | `<audio>` |
|
|
|
|
#### Captions (WebVTT)
|
|
| Extension | MIME type | Behaviour |
|
|
|-----------|-----------|-----------|
|
|
| `.vtt` | `text/vtt` | Silently paired with the preceding `<video>`. The N-th `.vtt` file is attached to the N-th video in display order. Not shown as a standalone item. |
|
|
|
|
#### Archives and other downloadable files
|
|
| Extension | MIME type | Display |
|
|
|-----------|-----------|---------|
|
|
| `.zip` | `application/zip` | Download link |
|
|
| `.tar` | `application/x-tar` | Download link |
|
|
| `.gz` / `.tgz` | `application/gzip` | Download link |
|
|
| Any other extension | `application/octet-stream` | Download link |
|
|
|
|
Files whose MIME type is `application/octet-stream` are accepted **only if their extension is in the known list above**. Unknown extensions with an unknown MIME type are rejected.
|
|
|
|
### Cover image (`couverture` input)
|
|
|
|
| Extension | MIME type |
|
|
|-----------|-----------|
|
|
| `.jpg` / `.jpeg` | `image/jpeg` |
|
|
| `.png` | `image/png` |
|
|
|
|
Max size: **20 MB**.
|
|
|
|
### Banner image
|
|
|
|
> Removed — the home-page banner was merged into covers (migration
|
|
> `028_drop_banner_path.sql`). There is no separate banner upload anymore.
|
|
|
|
---
|
|
|
|
## Size limits
|
|
|
|
Per-field limits are enforced in `app/src/Controllers/validate-file-fragment-shared.php`
|
|
(server-side `finfo` + size check on every upload):
|
|
|
|
| Field | Max size | Accepted content |
|
|
|-------|----------|------------------|
|
|
| `tfe` (main files) | **500 MB** default; **PDF capped at 100 MB**; **video/audio up to 5 GB** | PDF, images, video, audio, archives |
|
|
| `annexes` | **500 MB** (PDF capped at 100 MB, video/audio up to 5 GB) | PDF, archives, images, media |
|
|
| `couverture` (cover) | **20 MB** | JPG / PNG / WEBP |
|
|
| `note_intention` | **100 MB** | PDF |
|
|
|
|
(Admins bypass validation entirely — `admin_mode=1`.)
|
|
|
|
The PHP engine limits are set as follows:
|
|
|
|
| File | Applies to | `upload_max_filesize` | `post_max_size` |
|
|
|------|------------|----------------------|------------------|
|
|
| `app/public/.user.ini` | PHP-FPM / nginx | 8192M | 8704M |
|
|
| `app/public/.htaccess` | Apache (`mod_php`) | 512M | 1024M |
|
|
| `justfile` — `dev` recipe | PHP built-in dev server (`php -S` ignores the files above, so limits are passed via `-d` flags) | 8192M | 8704M |
|
|
|
|
For environments that require different limits, edit all three.
|
|
|
|
---
|
|
|
|
## File ordering
|
|
|
|
Files are displayed on the public TFE page in `sort_order` sequence (ascending). The `sort_order` column is stored in `thesis_files`.
|
|
|
|
### Setting order on upload (add / partage forms)
|
|
|
|
The file queue in the upload form is drag-sortable via SortableJS. Drag rows into the desired display order before submitting. The order is submitted as `file_orders[]` hidden fields and stored on insert.
|
|
|
|
### Changing order after upload (edit form)
|
|
|
|
The existing-files list on the edit form is also drag-sortable. Drag rows into the desired order and save — the new order is submitted as `file_sort_order[]` (an array of file IDs in the desired sequence) and persisted via `Database::reorderThesisFiles()`.
|
|
|
|
---
|
|
|
|
## Per-file labels
|
|
|
|
Each TFE content file can have an optional **display label** (a short caption or description). This is shown as a `<figcaption>` beneath the file on the public page.
|
|
|
|
- On the upload queue: type in the label field below each filename before submitting.
|
|
- On the edit form: the label input is shown inline in each file row; edited labels are saved alongside the sort order.
|
|
|
|
Labels are stored in `thesis_files.display_label`. If blank, the field falls back to the legacy `description` column.
|
|
|
|
---
|
|
|
|
## Storage layout
|
|
|
|
Files are stored outside the webroot in `app/storage/`. Each thesis gets its own
|
|
folder keyed by object type, year, author slug and a title slug:
|
|
|
|
```
|
|
app/storage/
|
|
├── {objet}/ # tfe | these | frart (plus legacy theses/, documents/)
|
|
│ └── <year>/
|
|
│ └── <YEAR>_<AUTHOR_SLUG>_<TITLE_SLUG>/ # e.g. 2025_EMMA_RENARD_REHABILITATION_..._VERS_UN
|
|
│ ├── <PREFIX>_TFE_01.pdf # main files: _TFE_<NN>.<ext>
|
|
│ ├── ..._TFE_02.jpg
|
|
│ ├── ..._ANNEXE_01.pdf # annexes: _ANNEXE_<NN>.<ext>
|
|
│ ├── ..._COUVERTURE.png # cover image (_COUVERTURE.<ext>)
|
|
│ ├── ..._NOTE_INTENTION.pdf # note d'intention (_NOTE_INTENTION.pdf)
|
|
│ └── ..._TFE_03.vtt # captions share the _TFE_ series
|
|
└── covers/ # legacy dir — no longer written to
|
|
```
|
|
|
|
Where `<PREFIX>` = `<YEAR>_<AUTHOR_SLUG>_<TITLE_SLUG>` (the folder name), and the
|
|
object type `{objet}` ∈ `tfe`, `these`, `frart` (older/imported rows may live
|
|
under legacy `theses/` or `documents/`).
|
|
|
|
- Author slug: uppercase ASCII, spaces → underscores, accents stripped (e.g. `EMMA_RENARD`).
|
|
- Title slug: accented chars → ASCII base, other non-alphanumerics → underscores (uppercase).
|
|
- The cover and note d'intention are stored **inside** the thesis folder using the
|
|
`_COUVERTURE` / `_NOTE_INTENTION` suffixes, recorded as `file_type='cover'` /
|
|
`'note_intention'` rows — not in a separate `covers/` directory.
|
|
- TFE files (and captions) are numbered `_TFE_<NN>` (`_TFE_01`, …); annexes use
|
|
`_ANNEXE_<NN>` so several files of the same kind coexist in the folder.
|
|
- If a folder `<PREFIX>` already exists a numeric suffix is appended to the folder
|
|
(`_1`, `_2`, …); if a filename already exists in the folder a numeric suffix is
|
|
appended before the extension.
|
|
|
|
Files are never served directly from disk. All access goes through `MediaController` (`/media?path=…`), which enforces:
|
|
- Path traversal prevention (character whitelist + `realpath()` jail)
|
|
- Visibility gate: `access_type_id = 3` (Interdit) → HTTP 403
|
|
- MIME allow-list check before serving
|
|
|
|
### Opening “Interdit” files from the backoffice
|
|
|
|
Publicly, an Interdit file's `/media?path=…` returns 403. The admin can still open
|
|
one via a **dedicated admin-only route**: `/admin/media.php?path=…`.
|
|
|
|
- It is gated by `AdminAuth::requireLogin()` — served under `/admin` so the
|
|
administrator's session cookie (scoped to `/admin`) is sent.
|
|
- It calls `MediaController::handle(adminBypass: true)`, which lifts **only** the
|
|
Interdit visibility gate. Path whitelist, `realpath()` jail and the MIME
|
|
allow-list are still enforced — an admin cannot read arbitrary server files.
|
|
- **Defence-in-depth:** on the admin route the requested `path` is additionally
|
|
restricted to the five thesis-file prefixes (`tfe/`, `these/`, `frart/`,
|
|
`documents/`, `theses/`) via `MediaController::isThesisFilePath()`. Any other
|
|
storage path (`schema.sql`, `xamxam.db`, `tmp/`, `backups/`, `cache/`, …) is
|
|
rejected with 403 before it even reaches the MIME check.
|
|
- The backoffice recap page (`recapitulatif.php`) already emits the admin URL
|
|
for files whose owning thesis is `access_type_id = 3`.
|
|
|
|
### Reliable tab title for opened files
|
|
|
|
The recap opens an Interdit file via `/admin/media-viewer.php?path=…`, a small
|
|
HTML wrapper that sets a proper `<title>` (the original uploaded file name) and
|
|
embeds the file through `/admin/media.php?path=…` in a full-viewport iframe.
|
|
This gives a useful, consistent tab title across every file type. (Serving the
|
|
raw PDF directly shows the URL, `media.php`, instead of the file name, because
|
|
most PDFs carry no embedded `/Title` metadata that Chrome/Firefox's viewer
|
|
would otherwise use.) The wrapper also re-declares the site's favicon
|
|
(`/assets/favicon/…`) so the tab keeps the site icon even though it's a
|
|
standalone page.
|
|
|
|
Every served file (admin route and public `/media`) sets `Content-Disposition`
|
|
to `inline` together with the original uploaded filename (`thesis_files.file_name`),
|
|
so the browser shows a meaningful tab title (e.g. `rapport_2024.pdf`) instead of
|
|
`media.php`. The name is emitted safely as an ASCII `filename` fallback plus a
|
|
UTF-8 `filename*=UTF-8''…` form for accented / international names (RFC 6266),
|
|
with control chars / quotes / backslashes stripped to prevent header injection.
|
|
|
|
---
|
|
|
|
## Security notes
|
|
|
|
- MIME type is verified via `finfo` (magic bytes), not the browser-supplied `Content-Type`.
|
|
- Extension is additionally checked against the allow-list as a second gate.
|
|
- Filenames are sanitised (accents stripped, non-alphanumeric → `_`) before writing to disk; the original name is stored in `thesis_files.file_name` for display.
|
|
- Cover and banner images are stored under a random 32-hex-char name, completely decoupled from the original filename.
|
|
- Uploaded files are `chmod 0644` after move.
|
|
- HTTP Range requests are supported for audio and video so the browser can seek without downloading the entire file.
|
|
|
|
---
|
|
|
|
## Database schema reference
|
|
|
|
```sql
|
|
CREATE TABLE thesis_files (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
thesis_id INTEGER NOT NULL,
|
|
file_type TEXT NOT NULL, -- 'main'|'image'|'video'|'audio'|'caption'|'cover'|'other'
|
|
file_path TEXT NOT NULL, -- path relative to STORAGE_ROOT
|
|
file_name TEXT NOT NULL, -- original filename (display only)
|
|
file_size INTEGER, -- bytes
|
|
mime_type TEXT,
|
|
description TEXT, -- legacy caption field
|
|
display_label TEXT, -- per-file caption
|
|
sort_order INTEGER NOT NULL DEFAULT 0, -- display order
|
|
file_hash TEXT, -- stored hash (optional)
|
|
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (thesis_id) REFERENCES theses(id) ON DELETE CASCADE
|
|
);
|
|
```
|
|
|
|
Files are queried ordered by `sort_order ASC, uploaded_at ASC`.
|
|
|
|
---
|
|
|
|
## Relevant source files
|
|
|
|
| File | Role |
|
|
|------|------|
|
|
| `app/src/Controllers/ThesisCreateController.php` | Upload validation + storage on create |
|
|
| `app/src/Controllers/ThesisEditController.php` | Upload validation + storage on edit; reorder + label save |
|
|
| `app/src/Controllers/MediaController.php` | Secure file serving with Range support |
|
|
| `app/src/Database.php` | `insertThesisFile`, `reorderThesisFiles`, `updateThesisFileLabel`, `getThesisFiles` |
|
|
| `app/templates/partials/form/fieldset-files.php` | Upload UI partial (add / partage forms) |
|
|
| `app/templates/admin/edit.php` | Edit-form files section (sortable existing files + new upload queue) |
|
|
| `app/templates/public/tfe.php` | Public rendering of all file types |
|
|
| `app/src/Controllers/FilepondHandler.php` + `app/public/assets/js/app/file-upload-filepond.js` | FilePond-backed upload queue |
|
|
| `app/public/.htaccess` | PHP upload limits (Apache) |
|
|
| `app/public/.user.ini` | PHP upload limits (PHP-FPM / nginx) |
|
|
| `app/migrations/applied/007_thesis_files_sort_and_label.sql` | DB migration adding `sort_order` + `display_label` |
|
|
|
|
> Note: the upload UI was migrated from the legacy SortableJS `file-upload-queue.js`
|
|
> to **FilePond** (`file-upload-filepond.js` + `FilepondHandler.php`).
|