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