Files
xamxam/docs/file-uploads.md
T

13 KiB

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

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).