LMS_Frontend/R2_storage_paths.md. Keywords for search:
R2, bucket, object key, files/, all-lessons, all-classrooms, all-quizzes, all-chatrooms,
training_id, lesson_id, classroom_id, quiz_id, upload_file, import_to_r2,
R2StoragePathService.
files/.- ✅ Backend. The layout lives in one place,
app/Services/R2StoragePathService.php, and every key is built there.POST /upload_file(type3) reads the owner the app names (training_id+lesson_idfor a saved lesson,classroom_id, orquiz_id), loads it, checks it belongs to the request’s company and writes the key itself.POST /lessons/import_to_r2writesfiles/all-lessons/{training-slug}/bulk/fromtraining_id. Chat images, chat backups and instructor logos moved to their folders. Afolderorfolder_namestring sent by a client is ignored. Guarded bytests/Feature/R2StoragePathsTest.phpandtests/Feature/ChatroomTest.php. - ✅ Frontend (Update 184).
R2UploadTargetsends ids, never a folder string; the import modal shows the folder the backend answers. - Existing objects keep their old keys (nothing is moved): every URL is stored in
files.url, in the lesson / classroom HTML or inchatroom_attachments.path.
At a glance
1. The layout
{lessonId}_ prefix:
files/all-lessons/{training-slug}/{userId}_{timestamp}_{random8}.{ext}.
One folder name is made by R2StoragePathService::segment(): lower case, anything but a letter, a digit,
_ or - becomes a dash (so a classroom slug such as pmp-class(1) gives pmp-class-1, and .. can
never climb), dashes trimmed, and training-{id} / classroom-{id} / quiz-{id} / chatroom-{id}
when nothing is left. The app’s r2Segment() applies the same rule for what it displays.
2. Uploads that go to R2
2.1 Where each upload comes from
2.2 The key written now, and before
POST /r2_upload_file and POST /r2_bulk_upload (FilesRController) still write flat to files/,
but the apps never call them. Rows 3 and 4 were requested with a .pdf extension; they are written
with {ext} here because the classroom editor uploads images and PDFs, and question images are
images.2.3 What the Flutter app sends (Update 184)
folder field reaching the backend is ignored (not validated, never read).
2.4 Code behind each row
3. The contract: the owner ids the app sends
POST /upload_file (type 3) carries whose file it is, as multipart fields next to type=3,
company_id and file. The backend loads the owner and builds the folder.
- One owner at most.
training_id,classroom_idandquiz_idtogether answer422on the second one (messages.r2_one_owner_only). No owner at all: the file lands flat infiles/, as before. - The answer carries
file_id,urlandpath(the key).files.file_nameholds the full key of a type-3upload. - A validation error of
upload_fileanswers422({ "message", "errors" }) since 01/11/2026; it used to answer500.
pmp-foundations)
- 200
- 422
folder (files/all-lessons/pmp-foundations/bulk) next to chapter and lessons;
the app shows it in its success dialog. See
Chapters & Lessons Export / Import.
Row 4: the quiz exists before its images
Row 4: the quiz exists before its images
POST /quiz, no slug sent: the backend generates it) and the questions step is
only reachable through that button. So quiz_id is known on create as on update, and the images land
under the backend’s final slug. The draft is published (or not) by the final submit, which goes through
the update endpoint.No owner, no folder
No owner, no folder
files/, the behaviour of before. In practice the training draft is created on
step 1 of the training editor, the classroom on step 1 of its stepper and the quiz on step 3, so the
owner exists by the time the content editors upload.Existing files keep their keys
Existing files keep their keys
files.url, inside the lesson / classroom HTML or in
chatroom_attachments.path, so older files keep working from their old keys. Only new uploads follow
the layout.4. How the files are named
{random8} is 8 random characters made by Laravel’s Str::random(8)
(R2StoragePathService::uploadFileName, used by FilesController@upload_file, type 3).
- Allowed characters: letters and digits only (
A–Z,a–z,0–9), 62 in total, upper and lower case mixed. - No
-,_,+,/or=: Laravel removes those from the random bytes it encodes, so the result is always safe in a URL or a file name. - Why it is there: the R2 bucket is public. With 62⁸ (about 218 trillion) possible values, nobody can guess a file’s URL from the user id and the timestamp alone.
1793289600):
a–z and 0–9 (Str::lower(Str::random(n))
in ChatroomService):
5. What the backend does
app/Services/R2StoragePathService.php is the one place that knows the layout. Add a method there for
a new kind of upload; never hard-code a bucket folder in a controller, and never trust a folder string
from a client.
FilesController@upload_file, type 3 (rows 1 to 4)
training_id, lesson_id, classroom_id, quiz_id (nullable integers), asks the service
for the destination, names the file, putFileAs on the r2 disk (a false answer from the disk is
a 500), stores the full key in files.file_name and answers file_id, url, path. See
File Management & Storage §1.Export_Import_Chapters_And_Lessons_Controller@import_lessons_to_r2 (row 5)
422), writes every image to
files/all-lessons/{training-slug}/bulk/ and answers folder. folder_name is no longer read.ChatroomService::storeAttachment and ::backup (rows 6 and 7)
files/all-chatrooms/{slug}/ for the images; files/backups/conversations/{slug}/{adminId}_{Ymd-His}_{random8}.csv
for the backups (BACKUP_FOLDER now points at the service’s constant). See
Chatrooms §3.8 and §3.11.UserBackgroundItemsController::storeLogoFile (row 8)
files/instructor_bg_items/{userId}_{timestamp}_{originalName}. The UserBackgroundItem.image
payload is unchanged. See Instructor Dashboard §20.messages.r2_* (resources/lang/{en,fr,ar}/messages.php). Run the guard with
DB_CONNECTION=sqlite DB_DATABASE=:memory: php artisan test --filter='R2StoragePaths|ChatroomTest'.
6. Where the apps show a bucket path
- Import lessons modal (row 5): the “Storage folder” field shows the destination
(
files/all-lessons/{slug}/bulk/, locked: the training slug is not editable since Update 175), and the success dialog adds thefolderthe backend answered. - Chatroom backup (row 7): the “Backup saved” dialog shows the
paththe backend answers. - Nowhere else. The admin storage usage page (
/admin-config-storage-usage) lists the folders the backend returns (List R2 Folders, endpoint #12 of the file management page: the first level underfiles/, soall-lessons,all-classrooms,all-quizzes,all-chatrooms,backups,instructor_bg_itemsand(root)).
7. Uploads that do NOT go to R2 (local server disk)
These are saved understorage/app/public/files/ on the backend server and served from
/storage/files/...:
/storage/ CORS header described on the file management page.
8. Other things the audit found
- The HTML editor, quiz and training uploads hard-code
company_id: 1in the app, so everyfilesrow is tagged company 1, whatever the user’s real company is (frontend known issue 7). The owner check ofupload_filecompares the owner’s company to thatcompany_id. - Local type-
1uploads are named{userId}{originalName}with no timestamp: if the same user uploads a secondcover.png, it overwrites the first one. - One configuration finding about the backend repository is tracked privately in the workspace notes.