Index
# mmo.edms — overview for AI code generation `mmo.edms` (PHP namespace `MMO\edms`, 3.x) stores the files of an application's clients: a **File** entity per file, whose bytes a **storage** keeps, a **Label** tree that is at the same time folders and tags, rights that are `mmo.oauth` scopes, an http api (`FileController`), and three `mmo.client` elements (``, ``, ``). It depends on `mmo.core`, `mmo.client` and `mmo.oauth`. Read this file before writing code against `mmo.edms`. `CHANGELOG.md` says what changed from 2.x, and how to upgrade. ## Wiring it into an app ```json "MMO\\core\\Application": { "paths": {"files": "{root}/files"}, "routes": { "MMO\\oauth\\controllers\\AccessController": {}, "MMO\\core\\controllers\\RepositoryController": {}, "MMO\\edms\\controllers\\FileController": {}, "MMO\\client\\controllers\\PageController": {} } }, "MMO\\edms\\FileManager": { "repositories": {"main": {"$": "#repository"}}, "maxSize": 536870912 }, "MMO\\core\\controllers\\RepositoryController": { "repositories": {"main": {"$": "#repository"}} }, "repository": { "$": "MMO\\core\\entities\\JsonRepository", "filenames": {"files": "{db}/files.json", "labels": "{db}/labels.json"}, "collections": {"files": "MMO\\edms\\entities\\File", "labels": "MMO\\edms\\entities\\Label"} } ``` - `AccessController` (wired as mmo.oauth's `docs/mcp/Overview.md` says: its keys, its `ClientRepository`) gives each request its `user`, which every edms route needs. - The bytes go to the `{files}` path (`{root}/files` by default: **outside the web root**, the download route being the only way to them). Another storage is configured as `"storage": {"$": "...", ...}` (an `iStorage`). - `FileManager`'s other settings: `uploadFolder` (`{files}/.uploads`: only the server's account reads it, not a shared `/tmp`), `maxChunkSize` (16 MiB), `uploadTimeout` (a day), `trashRetention` (30 days). - The command line (`EdmsShellController`, in the routes of the app's cli): `edms gc` (every day or so), `edms check`, `edms migrate` — `demo/cli.php` is an example. - **edms guards its collections itself**: `File` and `Label` are mmo.oauth `iGuarded`, so `AccessController` leaves them to `helpers/Access` — a user needs no `data.{repo}.files`/`labels` scope for edms to save what it does — but in the generic data api: `RepositoryController`'s routes (`data/{repo}/files.json`, `data.json?uid=`) and `StatisticController`'s refuse them to anyone without those scopes. Grant them to an administrator at most: they let a client write the rows as it likes, a file of another's bytes included. - An application without `AccessController` has nothing to guard `RepositoryController`'s routes: it does not route it, as the demo, which has no login, does not. (Until 2026-09, with `AccessController` too: its users had to hold `data.{repo}.files`/`labels` for edms to save their changes, and those scopes opened the generic api to them.) - An application attaching files to its entities (an upload's `attachment`) answers `events\Attach` to say who may: nobody answering lets anyone. ## The model ### `File` (`src/entities/File.php`) - `label` (the entity's own) is the file's **name**; `description`; `labels` (`Label[]`); `attachment` (any entity); `data` (its custom fields, which its labels define: `getPropertiesSchema()`). - Set by edms only (`setter: false`, stored with `store: true`): `owner`, `size`, `mime` (read from the bytes), `sha256`, `storageKey` (where the storage keeps the bytes; null for a file of edms 2, whose key is `{ownerId}/{id}` — `getStorageKey()`). - `open()` (a stream), `toResponse()` (a `FileResponse`), `isOwnedBy($client)`, `isTrashed()` (status `archive`), `scopeID($action)` = `edms.files.{id}-{ctime}[#action]` (its creation time too: a repository can give the id of a file deleted to a new one, which must not get its rights), label helpers (`addLabel()`, `removeLabel()`...). - `getFilePath()` is deprecated (a storage may keep no local file). ### `Label` (`src/entities/Label.php`) - A tree (`parent`, `@unique label,parent`): the global tags are under `_TAGS`, the shared folders under `_FILESYSTEM`, a client's space under `{client->localName()}` (`users/2`: its tags are `users/2/_TAGS/...`). - `owner`: the client whose space the label is in (a label takes its parent's), null for everyone's. - `color`, `description`, `properties` (a json schema's `properties`: the custom fields of its files). - `scopeID($action)` = `edms.labels.{id}[#action]`; `getPath()`, `loopParents()` (each label once, should the parents make a loop), `getChildren($recursive)`. - A label deleted is archived (never removed: its id, and the scopes naming it, stay its own); made again at the same place, it comes back. ## Rights (`src/helpers/Access.php`) mmo.oauth scopes the client (or its roles) holds, keyed by id: - `edms.files.{id}-{ctime}` a file; `edms.labels.{id}` a label **and every label under it**; `edms.files`, `edms.labels` all of them; `edms` everything. An action narrows one: `#show`, `#edit`, `#delete`, on a file `#share`, and on a label `#create` (put files in it, make labels under it). A scope without an action is every action. - The owner of a file can do anything with it; the owner of a label's space anything with the label, and only *see* the files others put in it. - Otherwise the most precise scope set decides: the file's own, then **any** of its labels (or of their parents) granting the action, then `edms.files`/`edms.labels`. A folder shared (`$user->allow($label->scopeID('show'))`) is every file in it. - Putting a file in a label is **sharing** it: the file's owner does (or a client granted `#share` on it), and it needs `create` on the label. A client who can only edit a file renames it, describes it, fills its fields, takes it out of the labels it sees. - A client knows the labels it has a right on, and the global tags (`_TAGS/...`): any other is unknown to it -- a 404 when it names one, and absent from the files it is shown (with the custom fields it defines). What it cannot see stays as it is when it changes a file. - `FileManager::can($client, $action, $file)`, `canShare()`, `canLabel()`, `isLabelVisible()`, `check()`/`checkLabel()` (throw `eForbidden`, a 403). - `FileManager` lists every label's scope in `ScopesList` (for the screens granting them). ## `FileManager` (`src/FileManager.php`, `FileManager::Shared()`) The methods taking a client first act **for** it, and check its rights; the others are the application's: - files: `uploadFile($client, $localFile, $name, $labels, $data)` (moves the file to the storage), `findFiles($client, $options)` (a page of what it can see: `labels`, `recursive`, `attachment`, `q`, `mine`, `trash`, `sort`, `offset`, `limit`), `updateFile($client, $file, $changes)` (`label`, `description`, `data` — checked against its labels' schema —, `labels`/`addLabels`/`removeLabels`), `trashFile()`, `restoreFile()`, `deleteFile()` (from the trash, for good), `getFileById()`, `getClientFiles()`, `getFilesFor()`; - labels: `createLabel($client, $name, $parent, $data)` (no parent: a tag of the client), `updateLabel()`, `deleteLabel()`, `getLabelTree($client)`, `getLabelAt($path, $create)`, `getTagLabel()`, `getClientTagLabel($client, ...)`, `getClientLabel()`, `getFileSystemLabel()`, `getLabelFromString($token)` (`12`, `"_FILESYSTEM/docs"`, `"#invoices"`), `getLabelsFromString()`; - the rest: `getStorage()`, `getUploads()`, `getAccess()`, `collectGarbage()`, `checkStorage()`, `migrate()`. Deleting: a file goes to the trash (`archive`), then is deleted for good (from the trash, or by the garbage collector after `trashRetention`): its row, and its bytes once the repository saved (`onCommitted`). The scopes naming it name no other file, a new file being made later. ## The http api (`src/controllers/FileController.php`) | route | | |---|---| | `GET edms/files.json` | the files the user can see, a page at a time: `labels`, `recursive`, `attachment`, `q`, `mine`, `trash`, `sort`, `offset`, `limit` (`meta.data.total`) | | `GET/PATCH/DELETE edms/files/{id}.json` | a file; `PATCH` its `label`, `description`, `data`, `labels`/`addLabels`/`removeLabels`; `DELETE` puts it in the trash, `?permanent=1` deletes it from there | | `POST edms/files/{id}/restore.json` | out of the trash | | `GET edms/files/{id}/content` | the bytes (`inline=1` shows them, for the types that cannot run a script); `Range` and `If-None-Match` answered | | `POST edms/files/bulk.json` | `{action: trash, restore, delete, label, unlabel; ids; labels}` | | `GET edms/files/archive.zip?ids=` | several files in a zip | | `GET/POST edms/labels.json`, `PATCH/DELETE edms/labels/{id}.json` | the labels the user can see (a tree, with `rights`), made, changed, deleted | | `POST edms/uploads.json`, `PUT edms/uploads/{id}`, `GET/DELETE edms/uploads/{id}.json` | an upload (below) | - Someone has to be logged in (the route's `user`): 401 otherwise. A file is `exportFile()`d for its viewer: its fields, its owner's id and title, `url`, `rights` (`show`, `edit`, `delete`, `share`), the labels the viewer knows — never where its bytes are, nor the owner's account. - A request changing something sends the session's CSRF token in `X-CSRF-Token` (`FileController::Token()`; the pages have it in `MMO.edms.config.token`), unless an `Authorization` header authenticates it. A page open since its session changed (a new login, a page the browser restored) holds a token the server no longer has: `MMO.edms.Api` asks `GET edms/token.json` for the session's own once, and sends the request again. - An error is an http status (403, 404, 409, 413...) with its reason in `meta.error.message`. - The routes of edms 2 stay: `files/list.{json,csv}`, `files/mylist.{json,csv}`, `files/labels.json`, `files/schemas.json`, `download/{id}`. ### An upload 1. `POST edms/uploads.json` `{name, size, labels, attachment, temporary}` → `{id, offset: 0, chunkSize}`. The labels are checked now (and again at the end): ids, quoted paths, `"#tags"` (the user's own are made). 2. `PUT edms/uploads/{id}`, the chunk's raw bytes as the body, `Content-Range: bytes {first}-{last}/{size}` (or `?offset=`): only where the previous one stopped (409 otherwise: `GET edms/uploads/{id}.json` says where), never past the size (413). The last one makes the file (`file` in the answer). 3. The last chunk sent again (its answer was lost) answers with the same file; so does `GET edms/uploads/{id}.json`. 4. A `temporary` upload makes no file: the page sends its `id` to the application, which takes the bytes with `FileController::TemporaryFile($id, $user)` (the local file is the caller's to move or delete). 5. `DELETE edms/uploads/{id}.json` stops it. The garbage collector deletes those abandoned. ## In the browser - `MMO.edms` (`src/js/edms.js`, in the client bundle): `MMO.edms.Api` (every route above; `Shared()` uses the page's `MMO.edms.config`), `MMO.edms.Upload` (a file a chunk at a time, resumed and retried), `MMO.edms.node()` (dom whose strings are text, never html), `MMO.edms.escape()`. - ``: `submit()` sends the files, two at once (those added meanwhile after); events `uploadedFile` (a file, or `{id, name, temporary}`) and `uploaded` (`{files, errors}`). - ``: the label tree, search, the user's files or the trash, bulk actions, a file opened on the side (rename, describe, classify, custom fields); dropped files go into the folder shown. - ``: an image picked is sent to edms (the value is its inline url), or, `embedded`, kept in the value as a `data:` url. - Views `file-info` and `rights`, for a file of the api. A name is the user's: the elements put it in the page as text. In `.jhs`, never print one with `{path}` in text content (that is html) — `set-textContent="{path}"`, or `MMO.edms.node()`. ## Verifying your work `php tools/mmo.php verify --lib=edms` (from the hub): lint, phpstan, and the phpunit suite (`tests/`, each class on a repository and a storage of its own, in a temporary folder). The demo is at `http://localhost/MMO/master/libs/edms`; `php demo/cli.php edms check` checks its files.