openapi: "3.0.3" info: title: NAS Storage Analyzer API version: "1.0.0" description: | Read-only API over the results of the latest completed scan of each share. The API never queries the file system; everything comes from the database. **Authentication.** Every endpoint except the ones marked public needs a signed-in session: the `nasa_session` cookie (`__Host-nasa_session` over https) set by `/auth/login`, `/auth/setup` or a passkey sign-in. Requests that change something must also send the `csrf_token` returned by the sign-in as the `X-CSRF-Token` header; browsers' `Origin` must match the site. Errors carry a stable `code` (`unauthenticated`, `mfa_required`, `enroll_required`, `forbidden`, `csrf`, `locked`, ...). Roles: `viewer` reads, `admin` also manages shares, scans, users and settings. Sizes are bytes (`size` is the logical size, `alloc` the allocated size, `st_blocks * 512`). Hardlinked files are counted once. Lists use keyset pagination: pass the returned `next_cursor` as `cursor` to get the next page; a cursor only works with the same `sort` and `order`. The filter parameters (`category`, `ext`, `min_size`, ...) restrict which *files* count. In directory listings a folder's size is then the size of its matching files. With no filter the pre-computed directory totals are used, with only a `category` filter the per-category totals; other filters are aggregated live and may be slower on very large shares. The `X-Query-Tier` response header of the children endpoint shows which of the three was used. Every list endpoint can return CSV: use `format=csv` or `Accept: text/csv`. Exports page through the whole result (up to the server's row limit, header `X-Max-Rows`). Text cells that start with `=`, `+`, `-`, `@`, tab or CR are prefixed with `'` so spreadsheets do not run them as formulas. servers: - url: / security: - session: [] tags: - {name: auth} - {name: admin} - {name: shares} - {name: browse} - {name: lists} - {name: reference} - {name: operations} paths: /healthz: get: tags: [operations] summary: Liveness probe responses: "200": {description: "The process is up.", content: {text/plain: {schema: {type: string}}}} /readyz: get: tags: [operations] summary: Readiness probe (database reachable) responses: "200": {description: "Ready.", content: {text/plain: {schema: {type: string}}}} "503": {$ref: "#/components/responses/Problem"} /metrics: get: tags: [operations] summary: Prometheus metrics responses: "200": {description: "Prometheus text exposition format.", content: {text/plain: {schema: {type: string}}}} /openapi.yaml: get: tags: [operations] summary: This document responses: "200": {description: "The OpenAPI document.", content: {application/yaml: {schema: {type: string}}}} /api/v1/version: get: tags: [operations] operationId: getVersion summary: Which version and release channel is running (public) description: | The channel comes from `NASSA_TAG`, the variable that also picks the image in `docker-compose.yml`: a tag that contains `beta` is a `beta` build, anything else (`latest`, a version number, unset) is `stable`. The web interface shows a **beta** label next to its name on a beta build. security: [] responses: "200": description: The running build. content: {application/json: {schema: {$ref: "#/components/schemas/Version"}}} /api/v1/shares: post: tags: [admin] operationId: createShare summary: Add a share (admin) description: "`kind` is `bind` (a path below the shares directory, already mounted into the container; recommended), `nfs` or `smb` (mounted read-only by the worker, needs `CAP_SYS_ADMIN`). Settings you leave out get their defaults." requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/ShareInput"}}}} responses: "201": {description: "Created.", content: {application/json: {schema: {$ref: "#/components/schemas/ShareInfo"}}}} default: {$ref: "#/components/responses/Problem"} get: tags: [shares] operationId: listShares summary: List shares with the state of their latest scans parameters: [{$ref: "#/components/parameters/Format"}] responses: "200": description: All shares. content: application/json: schema: {type: object, required: [items], properties: {items: {type: array, items: {$ref: "#/components/schemas/ShareInfo"}}}} text/csv: {schema: {type: string}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}: parameters: [{$ref: "#/components/parameters/Share"}] get: tags: [shares] operationId: getShare summary: One share responses: "200": {description: "The share.", content: {application/json: {schema: {$ref: "#/components/schemas/ShareInfo"}}}} default: {$ref: "#/components/responses/Problem"} patch: tags: [admin] operationId: patchShare summary: Change settings of a share (admin) description: Only the fields you send change. `password` sets a new SMB password, an empty string removes it. The password is never returned. requestBody: required: true content: {application/json: {schema: {$ref: "#/components/schemas/ShareInput"}}} responses: "200": {description: "The updated share.", content: {application/json: {schema: {$ref: "#/components/schemas/ShareInfo"}}}} default: {$ref: "#/components/responses/Problem"} delete: tags: [admin] operationId: deleteShare summary: Delete a share with all of its scan data (admin; not while a scan is queued or running) responses: "204": {description: "Deleted."} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/scan: parameters: [{$ref: "#/components/parameters/Share"}] post: tags: [admin] operationId: startScan summary: Scan now (admin) description: "`auto` is incremental unless a full scan is due; `full` reads every file. 409 `already_active` when a scan is already queued or running." requestBody: content: application/json: schema: type: object properties: mode: {type: string, enum: [auto, full]} responses: "202": {description: "Queued.", content: {application/json: {schema: {$ref: "#/components/schemas/Job"}}}} default: {$ref: "#/components/responses/Problem"} delete: tags: [admin] operationId: cancelScan summary: Cancel the queued or running scan (admin) description: The data of the previous scan stays as it was. responses: "204": {description: "Cancelling."} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/recategorize: post: tags: [admin] operationId: recategorize summary: Re-apply the extension map to the stored files (admin) parameters: [{$ref: "#/components/parameters/Share"}] responses: "202": {description: "Queued.", content: {application/json: {schema: {$ref: "#/components/schemas/Job"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/delete-script: post: tags: [admin] operationId: createDeleteScript summary: Write a script that deletes the selected files and folders (admin) description: | Returns the text of a script for `cmd`, PowerShell or `sh`, to download and run by the person on their own machine. **The application deletes nothing**: it only writes text, and the share stays mounted read-only. The script shows a summary, needs `DELETE ` to be typed, and then deletes exactly the listed files, keeping any whose size (and, outside `cmd`, modification time) differs from the last scan. Files that cannot be addressed safely are left out and named in the script (names with control or replacement characters, what Windows cannot address, a name that differs only in upper or lower case from another, an id that is no longer in the index). **A folder in `dir_ids` is deleted with everything in it**, subfolders and files at any depth. The number to type is then the number of items: the files, the folders and everything the last scan found in the selected folders. The script checks a folder when it runs and keeps it, whole, when it now holds a link or a special file or more files or folders than the scan found, so what was added since is never taken. A folder inside another selected folder, and a file inside one, go with it and are not listed twice. The share's own folder is never put in a script. `cmd` cannot check a folder and takes none: use PowerShell or `sh`. **`style: simple`** asks for the other kind of script: a plain text file (`.txt`) with a few comment lines and then one command per file or folder (`rm`, `Remove-Item`, `del` and `rd`), to read, cut down and paste into a terminal, for example over SSH on the NAS. It asks nothing and checks nothing, keeps no data block and takes at most 200 files and folders; see ADR-0011. A name that cannot be quoted for certain (in `cmd`, one with `%` or `!`) is left out and listed in the comment lines, as everything else the advanced script leaves out. `client_root` is where the machine that runs the script sees the share (`\\nas\media`, `Z:\`, `/mnt/user/media`); empty uses the share's `client_windows` or `client_posix` setting. Ids of another share are treated as files that are gone. The audit log records the script type, the counts and a SHA-256 of the script, never a file name. parameters: [{$ref: "#/components/parameters/Share"}] requestBody: required: true content: application/json: schema: type: object required: [script] properties: ids: type: array maxItems: 2000 items: {type: integer, format: int64, minimum: 1} description: "File ids from the file lists. At least one id in `ids` or `dir_ids`, at most 2000 in all." dir_ids: type: array maxItems: 2000 items: {type: integer, format: int64, minimum: 1} description: "Folder ids from the folder lists: each folder is deleted with everything in it. Counts toward the same limit of 2000 as `ids`." script: {type: string, enum: [cmd, powershell, sh], description: "`cmd` and `powershell` address Windows paths; `sh` addresses Linux and macOS paths."} client_root: {type: string, description: "Base path on the machine that runs the script. Empty uses the share's own."} lang: {type: string, enum: [en, nl], default: en, description: "Language of what the script prints."} style: {type: string, enum: [advanced, simple], default: advanced, description: "`advanced` is the script with a summary, a typed confirmation and a check of every file and folder. `simple` is a `.txt` with one command per file or folder and no checks; at most 200 files and folders."} responses: "200": description: The script, with CRLF line ends for `cmd` and PowerShell (UTF-8, with a byte-order mark for PowerShell) and LF for `sh`. headers: Content-Disposition: {schema: {type: string}, description: "`attachment` with a file name that ends in `.cmd`, `.ps1` or `.sh`, or in `.txt` for a simple script."} X-Files: {schema: {type: integer}, description: "Files in the script that were selected one by one."} X-Folders: {schema: {type: integer}, description: "Folders in the script, each deleted with everything in it."} X-Items: {schema: {type: integer}, description: "The number to type to confirm: the files, the folders and what the scan found in them. For a simple script, which asks nothing, the number of commands."} X-Skipped: {schema: {type: integer}, description: "Files left out; the script lists them."} X-Bytes: {schema: {type: integer, format: int64}, description: "Total size of the files in the script and of what the scan found in the folders, an upper bound when files have several hard links."} content: text/plain: {schema: {type: string}} "400": {description: "Invalid request: `field` is `ids` (none, too many, or none of the files or folders can be put in a script), `script`, `lang`, `style` or `client_root`.", content: {application/problem+json: {schema: {$ref: "#/components/schemas/Problem"}}}} "404": {description: "No such share.", content: {application/problem+json: {schema: {$ref: "#/components/schemas/Problem"}}}} "409": {description: "The share has not been scanned yet (`no_data`), or a scan was published while the script was made (`scan_changed`): ask again.", content: {application/problem+json: {schema: {$ref: "#/components/schemas/Problem"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/scans: get: tags: [shares] operationId: listScans summary: Scan history of a share, newest first parameters: - {$ref: "#/components/parameters/Share"} - {$ref: "#/components/parameters/Limit"} - {name: before, in: query, description: "Only scans with a smaller id (paging).", schema: {type: integer, format: int64, minimum: 0}} responses: "200": {description: "A page of scans.", content: {application/json: {schema: {$ref: "#/components/schemas/ScanPage"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/scans/{scan}/errors: get: tags: [shares] operationId: listScanErrors summary: Errors logged during a scan (permission denied, vanished files, timeouts) parameters: - {$ref: "#/components/parameters/Share"} - {name: scan, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} - {name: kind, in: query, schema: {type: string, enum: [permission, vanished, timeout, io, other]}} - {name: after, in: query, description: "Only errors with a larger id (paging).", schema: {type: integer, format: int64, minimum: 0}} - {$ref: "#/components/parameters/Limit"} responses: "200": {description: "A page of errors.", content: {application/json: {schema: {$ref: "#/components/schemas/ScanErrorPage"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/categories: get: tags: [browse] operationId: shareCategories summary: Size per file category for the share or a folder parameters: - {$ref: "#/components/parameters/Share"} - {name: dir, in: query, description: "Folder id or `root`. Default root.", schema: {type: string}} - {$ref: "#/components/parameters/Format"} responses: "200": description: Categories, largest first. content: application/json: schema: {type: object, required: [items], properties: {items: {type: array, items: {$ref: "#/components/schemas/CategoryTotal"}}}} text/csv: {schema: {type: string}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/extensions: get: tags: [browse] operationId: shareExtensions summary: Size per file extension for the share parameters: - {$ref: "#/components/parameters/Share"} - {$ref: "#/components/parameters/Category"} - {$ref: "#/components/parameters/Limit"} - {$ref: "#/components/parameters/Format"} responses: "200": description: Extensions, largest first. content: application/json: schema: {type: object, required: [items], properties: {items: {type: array, items: {$ref: "#/components/schemas/ExtensionTotal"}}}} text/csv: {schema: {type: string}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/dirs/{dir}: get: tags: [browse] operationId: getDir summary: A folder with breadcrumb, totals and category breakdown parameters: - {$ref: "#/components/parameters/Share"} - {$ref: "#/components/parameters/Dir"} - {$ref: "#/components/parameters/Category"} - {$ref: "#/components/parameters/Ext"} - {$ref: "#/components/parameters/MinSize"} - {$ref: "#/components/parameters/MaxSize"} - {$ref: "#/components/parameters/ModifiedBefore"} - {$ref: "#/components/parameters/ModifiedAfter"} - {$ref: "#/components/parameters/UsedBefore"} - {$ref: "#/components/parameters/UsedAfter"} - {$ref: "#/components/parameters/UnusedDays"} responses: "200": {description: "The folder.", content: {application/json: {schema: {$ref: "#/components/schemas/DirDetail"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/dirs/{dir}/children: get: tags: [browse] operationId: listChildren summary: Subfolders of a folder with size, share of the parent and file count parameters: - {$ref: "#/components/parameters/Share"} - {$ref: "#/components/parameters/Dir"} - {$ref: "#/components/parameters/Category"} - {$ref: "#/components/parameters/Ext"} - {$ref: "#/components/parameters/MinSize"} - {$ref: "#/components/parameters/MaxSize"} - {$ref: "#/components/parameters/ModifiedBefore"} - {$ref: "#/components/parameters/ModifiedAfter"} - {$ref: "#/components/parameters/UsedBefore"} - {$ref: "#/components/parameters/UsedAfter"} - {$ref: "#/components/parameters/UnusedDays"} - {name: sort, in: query, description: "Default `size`.", schema: {type: string, enum: [size, files, name, mtime, used]}} - {$ref: "#/components/parameters/Order"} - {$ref: "#/components/parameters/Limit"} - {$ref: "#/components/parameters/Cursor"} - {$ref: "#/components/parameters/Format"} responses: "200": description: A page of subfolders. headers: X-Query-Tier: {description: "1 pre-computed folder totals, 2 per-category totals, 3 live aggregation.", schema: {type: string}} content: application/json: {schema: {$ref: "#/components/schemas/ChildPage"}} text/csv: {schema: {type: string}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/dirs/{dir}/treemap: get: tags: [browse] operationId: getTreemap summary: Nested size tree of a folder for treemap/sunburst charts parameters: - {$ref: "#/components/parameters/Share"} - {$ref: "#/components/parameters/Dir"} - {$ref: "#/components/parameters/Category"} - {name: depth, in: query, description: "Levels below the folder (1-3, default 2).", schema: {type: integer, minimum: 1, maximum: 3}} - {name: max_children, in: query, description: "Largest subfolders shown per level (1-100, default 20); the rest is folded into an `other` node.", schema: {type: integer, minimum: 1, maximum: 100}} responses: "200": {description: "The tree.", content: {application/json: {schema: {$ref: "#/components/schemas/TreeNode"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/files: get: tags: [lists] operationId: listFiles summary: Files of the share or of a folder description: Without `dir` the whole share is searched. With `dir` only that folder, unless `recursive=true`. parameters: - {$ref: "#/components/parameters/Share"} - {name: dir, in: query, description: "Folder id or `root`.", schema: {type: string}} - {name: recursive, in: query, schema: {type: boolean}} - {name: counted_only, in: query, description: "Hide the second and later hardlinks of an inode.", schema: {type: boolean, default: false}} - {$ref: "#/components/parameters/Category"} - {$ref: "#/components/parameters/Ext"} - {$ref: "#/components/parameters/MinSize"} - {$ref: "#/components/parameters/MaxSize"} - {$ref: "#/components/parameters/ModifiedBefore"} - {$ref: "#/components/parameters/ModifiedAfter"} - {$ref: "#/components/parameters/UsedBefore"} - {$ref: "#/components/parameters/UsedAfter"} - {$ref: "#/components/parameters/UnusedDays"} - {$ref: "#/components/parameters/Q"} - {name: sort, in: query, description: "Default `size`.", schema: {type: string, enum: [size, name, mtime, used, ext]}} - {$ref: "#/components/parameters/Order"} - {$ref: "#/components/parameters/Limit"} - {$ref: "#/components/parameters/Cursor"} - {$ref: "#/components/parameters/Format"} responses: "200": description: A page of files. content: application/json: {schema: {$ref: "#/components/schemas/FilePage"}} text/csv: {schema: {type: string}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/top/files: get: tags: [lists] operationId: topFiles summary: Largest files parameters: - {$ref: "#/components/parameters/Share"} - {name: dir, in: query, description: "Only below this folder (id or `root`).", schema: {type: string}} - {$ref: "#/components/parameters/Category"} - {$ref: "#/components/parameters/Ext"} - {$ref: "#/components/parameters/MinSize"} - {$ref: "#/components/parameters/MaxSize"} - {$ref: "#/components/parameters/ModifiedBefore"} - {$ref: "#/components/parameters/ModifiedAfter"} - {$ref: "#/components/parameters/UsedBefore"} - {$ref: "#/components/parameters/UsedAfter"} - {$ref: "#/components/parameters/UnusedDays"} - {$ref: "#/components/parameters/Q"} - {name: sort, in: query, schema: {type: string, enum: [size, name, mtime, used, ext]}} - {$ref: "#/components/parameters/Order"} - {$ref: "#/components/parameters/Limit"} - {$ref: "#/components/parameters/Cursor"} - {$ref: "#/components/parameters/Format"} responses: "200": description: A page of files, hardlinks counted once. content: application/json: {schema: {$ref: "#/components/schemas/FilePage"}} text/csv: {schema: {type: string}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/cold/files: get: tags: [lists] operationId: coldFiles summary: Largest files that have not been used for a long time description: Like top/files, restricted to files whose last use is older than `unused_days` (default 365) or `used_before`. "Last used" follows the share's basis (atime, mtime or the later of both). parameters: - {$ref: "#/components/parameters/Share"} - {name: dir, in: query, schema: {type: string}} - {$ref: "#/components/parameters/Category"} - {$ref: "#/components/parameters/Ext"} - {$ref: "#/components/parameters/MinSize"} - {$ref: "#/components/parameters/MaxSize"} - {$ref: "#/components/parameters/ModifiedBefore"} - {$ref: "#/components/parameters/ModifiedAfter"} - {$ref: "#/components/parameters/UsedBefore"} - {$ref: "#/components/parameters/UsedAfter"} - {$ref: "#/components/parameters/UnusedDays"} - {$ref: "#/components/parameters/Q"} - {name: sort, in: query, schema: {type: string, enum: [size, name, mtime, used, ext]}} - {$ref: "#/components/parameters/Order"} - {$ref: "#/components/parameters/Limit"} - {$ref: "#/components/parameters/Cursor"} - {$ref: "#/components/parameters/Format"} responses: "200": description: A page of files. content: application/json: {schema: {$ref: "#/components/schemas/FilePage"}} text/csv: {schema: {type: string}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/top/dirs: get: tags: [lists] operationId: topDirs summary: Largest folders parameters: - {$ref: "#/components/parameters/Share"} - {name: dir, in: query, description: "Only below this folder.", schema: {type: string}} - {$ref: "#/components/parameters/DirBy"} - {$ref: "#/components/parameters/MinDepth"} - {name: sort, in: query, schema: {type: string, enum: [size, used]}} - {$ref: "#/components/parameters/Order"} - {$ref: "#/components/parameters/Limit"} - {$ref: "#/components/parameters/Cursor"} - {$ref: "#/components/parameters/Format"} responses: "200": description: A page of folders. content: application/json: {schema: {$ref: "#/components/schemas/DirPage"}} text/csv: {schema: {type: string}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/cold/dirs: get: tags: [lists] operationId: coldDirs summary: Largest folders in which nothing has been used for a long time parameters: - {$ref: "#/components/parameters/Share"} - {name: dir, in: query, schema: {type: string}} - {$ref: "#/components/parameters/DirBy"} - {$ref: "#/components/parameters/MinDepth"} - {$ref: "#/components/parameters/UnusedDays"} - {$ref: "#/components/parameters/UsedBefore"} - {name: maximal, in: query, description: "Skip a cold folder whose parent is cold as well (default true).", schema: {type: boolean, default: true}} - {name: sort, in: query, schema: {type: string, enum: [size, used]}} - {$ref: "#/components/parameters/Order"} - {$ref: "#/components/parameters/Limit"} - {$ref: "#/components/parameters/Cursor"} - {$ref: "#/components/parameters/Format"} responses: "200": description: A page of folders. content: application/json: {schema: {$ref: "#/components/schemas/DirPage"}} text/csv: {schema: {type: string}} default: {$ref: "#/components/responses/Problem"} /api/v1/search: get: tags: [lists] operationId: search summary: Search file and folder names (case-insensitive substring), largest first parameters: - {name: q, in: query, required: true, description: "At least 3 characters.", schema: {type: string, minLength: 3}} - {name: share, in: query, description: "Limit to one share.", schema: {type: integer, format: int64, minimum: 1}} - {name: kind, in: query, schema: {type: string, enum: [all, files, dirs]}} - {name: limit, in: query, description: "Up to 200.", schema: {type: integer, minimum: 1, maximum: 200}} - {$ref: "#/components/parameters/Format"} responses: "200": description: Matches, largest first. headers: X-Truncated: {description: "`true` when the result is truncated (see `truncated`).", schema: {type: string}} content: application/json: schema: type: object required: [items, truncated] properties: items: {type: array, items: {$ref: "#/components/schemas/SearchHit"}} truncated: type: boolean description: "More than 20,000 files matched, so the list shows the largest of the first 20,000 found. Ask the user to refine the search. A term that looks like an extension (`.jpg`) is always exact." text/csv: {schema: {type: string}} default: {$ref: "#/components/responses/Problem"} /api/v1/categories: get: tags: [reference] operationId: listCategories summary: File categories responses: "200": description: Categories. content: application/json: schema: {type: object, required: [items], properties: {items: {type: array, items: {$ref: "#/components/schemas/Category"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/extension-map: get: tags: [reference] operationId: listExtensionMap summary: Which category each extension belongs to responses: "200": description: The mapping. content: application/json: schema: {type: object, required: [items], properties: {items: {type: array, items: {$ref: "#/components/schemas/ExtensionMapping"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/status: get: tags: [auth] operationId: authStatus summary: Is setup needed, and who is signed in? (public) security: [] responses: "200": description: State of this browser's session. content: {application/json: {schema: {$ref: "#/components/schemas/AuthStatus"}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/setup: post: tags: [auth] operationId: authSetup summary: Create the first administrator (public, only while no account exists) description: Needs the one-time setup token printed in the application log (or set as `NASSA_SETUP_TOKEN`). The new session must enrol a second factor before it can do anything else. security: [] requestBody: required: true content: {application/json: {schema: {$ref: "#/components/schemas/SetupRequest"}}} responses: "200": {description: "Created; the session is in the `enroll_required` stage.", content: {application/json: {schema: {$ref: "#/components/schemas/SessionResponse"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/login: post: tags: [auth] operationId: authLogin summary: Sign in with username and password (public) description: The answer says what comes next. `mfa_required` needs a second factor (TOTP, recovery code or passkey) via the `/auth/mfa/*` endpoints. `enroll_required` means the user has no second factor yet and must add one. security: [] requestBody: required: true content: {application/json: {schema: {$ref: "#/components/schemas/LoginRequest"}}} responses: "200": {description: "Password accepted.", content: {application/json: {schema: {$ref: "#/components/schemas/SessionResponse"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/logout: post: tags: [auth] operationId: authLogout summary: Sign out responses: "204": {description: Signed out.} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/me: get: tags: [auth] operationId: authMe summary: The signed-in user responses: "200": {description: "The user and a fresh CSRF token.", content: {application/json: {schema: {$ref: "#/components/schemas/SessionResponse"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/mfa/totp: post: tags: [auth] operationId: mfaTotp summary: Second step of sign-in with an authenticator app code requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/CodeRequest"}}}} responses: "200": {description: "Signed in.", content: {application/json: {schema: {$ref: "#/components/schemas/SessionResponse"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/mfa/recovery: post: tags: [auth] operationId: mfaRecovery summary: Second step of sign-in with a one-time recovery code requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/CodeRequest"}}}} responses: "200": {description: "Signed in.", content: {application/json: {schema: {$ref: "#/components/schemas/SessionResponse"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/mfa/passkey/begin: post: tags: [auth] operationId: mfaPasskeyBegin summary: Start the passkey check of the second step responses: "200": {description: "WebAuthn request options for `navigator.credentials.get`.", content: {application/json: {schema: {$ref: "#/components/schemas/WebAuthnChallenge"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/mfa/passkey/finish: post: tags: [auth] operationId: mfaPasskeyFinish summary: Finish the passkey check of the second step parameters: [{name: ceremony, in: query, required: true, schema: {type: string}}] requestBody: {required: true, description: "The JSON form of the PublicKeyCredential from the browser.", content: {application/json: {schema: {type: object}}}} responses: "200": {description: "Signed in.", content: {application/json: {schema: {$ref: "#/components/schemas/SessionResponse"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/passkey/login/begin: post: tags: [auth] operationId: passkeyLoginBegin summary: Start a passwordless sign-in with a passkey (public) security: [] responses: "200": {description: "WebAuthn request options (no credential list, so usernames are not revealed).", content: {application/json: {schema: {$ref: "#/components/schemas/WebAuthnChallenge"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/passkey/login/finish: post: tags: [auth] operationId: passkeyLoginFinish summary: Finish a passwordless sign-in (public) description: A passkey must verify the user (PIN or biometrics); that makes it as strong as password plus second factor. security: [] parameters: [{name: ceremony, in: query, required: true, schema: {type: string}}] requestBody: {required: true, content: {application/json: {schema: {type: object}}}} responses: "200": {description: "Signed in.", content: {application/json: {schema: {$ref: "#/components/schemas/SessionResponse"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/totp/begin: post: tags: [auth] operationId: totpBegin summary: Start setting up an authenticator app responses: "200": description: Secret and `otpauth://` URL for the QR code. content: application/json: schema: type: object required: [secret, otpauth_url] properties: secret: {type: string} otpauth_url: {type: string} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/totp/confirm: post: tags: [auth] operationId: totpConfirm summary: Confirm the authenticator app with a code description: The first second factor of a user also yields their recovery codes, shown once, and completes the sign-in. requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/CodeRequest"}}}} responses: "200": {description: "Enabled.", content: {application/json: {schema: {$ref: "#/components/schemas/SessionResponse"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/totp: delete: tags: [auth] operationId: totpDisable summary: Remove the authenticator app (needs the password; another factor must remain) requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/PasswordRequest"}}}} responses: "204": {description: Removed.} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/passkeys/register/begin: post: tags: [auth] operationId: passkeyRegisterBegin summary: Start adding a passkey responses: "200": {description: "WebAuthn creation options for `navigator.credentials.create`.", content: {application/json: {schema: {$ref: "#/components/schemas/WebAuthnChallenge"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/passkeys/register/finish: post: tags: [auth] operationId: passkeyRegisterFinish summary: Finish adding a passkey parameters: - {name: ceremony, in: query, required: true, schema: {type: string}} - {name: name, in: query, description: "A label for the passkey.", schema: {type: string, maxLength: 60}} requestBody: {required: true, content: {application/json: {schema: {type: object}}}} responses: "200": {description: "Added.", content: {application/json: {schema: {$ref: "#/components/schemas/SessionResponse"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/passkeys: get: tags: [auth] operationId: listPasskeys summary: The user's passkeys responses: "200": description: Passkeys. content: {application/json: {schema: {type: object, required: [items], properties: {items: {type: array, items: {$ref: "#/components/schemas/Passkey"}}}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/passkeys/{id}: parameters: [{name: id, in: path, required: true, schema: {type: string}}] patch: tags: [auth] operationId: renamePasskey summary: Rename a passkey requestBody: required: true content: {application/json: {schema: {type: object, required: [name], properties: {name: {type: string, maxLength: 60}}}}} responses: "204": {description: Renamed.} default: {$ref: "#/components/responses/Problem"} delete: tags: [auth] operationId: deletePasskey summary: Remove a passkey (not the last second factor) responses: "204": {description: Removed.} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/recovery-codes: post: tags: [auth] operationId: newRecoveryCodes summary: Replace the recovery codes (needs the password) requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/PasswordRequest"}}}} responses: "200": description: Ten new single-use codes, shown once. The old ones stop working. content: {application/json: {schema: {type: object, required: [recovery_codes], properties: {recovery_codes: {type: array, items: {type: string}}}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/password: post: tags: [auth] operationId: changePassword summary: Change the password (signs out other sessions) requestBody: required: true content: {application/json: {schema: {type: object, required: [current, new], properties: {current: {type: string}, new: {type: string, minLength: 12}}}}} responses: "204": {description: Changed.} default: {$ref: "#/components/responses/Problem"} /api/v1/auth/prefs: put: tags: [auth] operationId: putPrefs summary: Save interface preferences requestBody: required: true content: application/json: schema: type: object properties: units: {type: string, enum: [binary, decimal]} language: {type: string, enum: [nl, en]} theme: {type: string, enum: [system, light, dark]} accent: {type: string, pattern: '^(#[0-9a-fA-F]{6})?$', description: "The accent colour of the interface as #rrggbb (stored in lower case); empty means the default. Anything else is refused: the value ends up in a CSS custom property."} delete_scripts: {type: string, enum: [advanced, simple], description: "Which delete script the interface asks for: the advanced one (summary, typed confirmation, checks) or the simple one (a .txt with one command per file or folder). Set under Settings, Delete."} responses: "200": {description: "The merged preferences.", content: {application/json: {schema: {type: object, required: [prefs], properties: {prefs: {type: object, additionalProperties: {type: string}}}}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/users: get: tags: [admin] operationId: listUsers summary: All users (admin) responses: "200": {description: "Users.", content: {application/json: {schema: {type: object, required: [items], properties: {items: {type: array, items: {$ref: "#/components/schemas/User"}}}}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/users/{id}: parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}] patch: tags: [admin] operationId: patchUser summary: Change role, display name or disabled flag (admin) description: The last active administrator can be neither demoted nor disabled. requestBody: required: true content: application/json: schema: type: object properties: display_name: {type: string} role: {type: string, enum: [admin, viewer]} disabled: {type: boolean} responses: "200": {description: "Updated.", content: {application/json: {schema: {$ref: "#/components/schemas/User"}}}} default: {$ref: "#/components/responses/Problem"} delete: tags: [admin] operationId: deleteUser summary: Delete a user (admin; not the last administrator) responses: "204": {description: Deleted.} default: {$ref: "#/components/responses/Problem"} /api/v1/users/{id}/reset-mfa: post: tags: [admin] operationId: resetUserMfa summary: Remove a user's second factors and sign them out (admin) parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}] responses: "204": {description: Reset; the user enrols again at the next sign-in.} default: {$ref: "#/components/responses/Problem"} /api/v1/users/{id}/reset: post: tags: [admin] operationId: resetUser summary: Make a login link that brings a user back (admin) description: | For a user who can no longer sign in (a lost phone, a forgotten password). It makes a `reset` invitation: a link the user opens to choose a new password or passkey. **Nothing about the user changes until they have finished**: the old password, passkeys, second factor and recovery codes are replaced, and every session of that user ended, in the same transaction that stores the new credential. There is one reset invitation per user; making a new one replaces the old. You cannot make one for yourself (change your password instead) or for a disabled user. The answer carries the link **once**; it is stored only as a hash. parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}] responses: "201": {description: "The link.", content: {application/json: {schema: {$ref: "#/components/schemas/InvitationCreated"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/invitations: get: tags: [admin] operationId: listInvitations summary: Open invitations (admin) responses: "200": {description: "Open (unused, unexpired) invitations, oldest first. The links are not here: they are shown once, when they are made.", content: {application/json: {schema: {type: object, required: [items], properties: {items: {type: array, items: {$ref: "#/components/schemas/Invitation"}}}}}}} default: {$ref: "#/components/responses/Problem"} post: tags: [admin] operationId: createInvitation summary: Invite someone (admin) description: | Makes a link for a new account. The guest opens it and chooses their own password or passkey, so the administrator never learns a credential. The token is 32 random bytes and only its SHA-256 is stored; the link is in this answer **once**. It works once and for seven days. The role is decided here, by the administrator, and cannot be changed by the guest. `username` is optional: a name to suggest and to reserve meanwhile (the guest may change it). At most 50 users (pending invitations count as users) and 50 open invitations. `url` is built from `NASSA_PUBLIC_URL` (or the first passkey origin); empty means the interface builds it from the address you are using. requestBody: required: true content: application/json: schema: type: object properties: username: {type: string, pattern: '^[A-Za-z0-9._@-]{3,64}$'} role: {type: string, enum: [admin, viewer], default: viewer} responses: "201": {description: "The link.", content: {application/json: {schema: {$ref: "#/components/schemas/InvitationCreated"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/invitations/{id}: delete: tags: [admin] operationId: revokeInvitation summary: Revoke an invitation (admin) description: A lost link is revoked and made again, never looked up. parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}] responses: "204": {description: "Revoked: the link stops working at once."} default: {$ref: "#/components/responses/Problem"} /api/v1/invitations/info: get: tags: [auth] operationId: invitationInfo summary: What the guest sees of an invitation (public) description: | Public: the token is the credential, sent in the `X-Invite-Token` header so that it stays out of access logs. Unknown, expired, used, revoked and malformed tokens all answer the same `404`, so this cannot be used to ask which tokens exist; an address that keeps presenting bad ones is locked out like a failing sign-in. security: [] parameters: [{name: X-Invite-Token, in: header, required: true, schema: {type: string}}] responses: "200": {description: "The invitation.", content: {application/json: {schema: {$ref: "#/components/schemas/InviteInfo"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/invitations/password: post: tags: [auth] operationId: invitationPassword summary: Finish an invitation with a password (public) description: | Creates the account (or, for a reset, replaces the credentials of the user), deletes the invitation and signs the guest in. The guest must then enrol a second factor like every account (`enroll_required`). For a reset the name in the request is ignored: the user's own name is used. security: [] requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/InvitationPasswordRequest"}}}} responses: "200": {description: "Signed in, second factor still to enrol.", content: {application/json: {schema: {$ref: "#/components/schemas/SessionResponse"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/invitations/passkey/begin: post: tags: [auth] operationId: invitationPasskeyBegin summary: Start choosing a passkey for an invitation (public) description: The normal passkey registration ceremony for the user the invitation is for. A taken name is refused here, before a passkey is made. security: [] requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/InvitationPasskeyRequest"}}}} responses: "200": {description: "WebAuthn creation options.", content: {application/json: {schema: {type: object, required: [options, ceremony], properties: {options: {type: object}, ceremony: {type: string}}}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/invitations/passkey/finish: post: tags: [auth] operationId: invitationPasskeyFinish summary: Finish an invitation with a passkey (public) description: | Verifies the authenticator and, in one transaction, creates the account (or replaces the credentials), stores the passkey and deletes the invitation. A passkey verifies the user, so the guest is signed in completely. security: [] parameters: - {name: X-Invite-Token, in: header, required: true, schema: {type: string}} - {name: ceremony, in: query, required: true, schema: {type: string}} - {name: name, in: query, description: "A label for the passkey.", schema: {type: string, maxLength: 60}} requestBody: {required: true, content: {application/json: {schema: {type: object}}}} responses: "200": {description: "Signed in.", content: {application/json: {schema: {$ref: "#/components/schemas/SessionResponse"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/audit: get: tags: [admin] operationId: listAudit summary: The audit log (admin) parameters: - {$ref: "#/components/parameters/Limit"} - {name: before, in: query, description: "Only entries with a smaller id (paging).", schema: {type: integer, format: int64}} - {name: action, in: query, description: "Prefix of the action, for example `login.` or `share.`.", schema: {type: string}} - {name: scan_id, in: query, description: "Instead of a page: the entry that says how this scan ended (`scan.finished`, `scan.errors`, `scan.empty` or `scan.failed`), as a list of at most one entry, and `next_before` null. The other parameters are then ignored. An empty list means no entry exists for that scan (a cancelled scan has none).", schema: {type: integer, format: int64, minimum: 1}} responses: "200": description: Entries, newest first. content: application/json: schema: type: object required: [items, next_before] properties: items: {type: array, items: {$ref: "#/components/schemas/AuditEntry"}} next_before: {type: integer, format: int64, nullable: true} default: {$ref: "#/components/responses/Problem"} /api/v1/system/update: get: tags: [admin] operationId: getUpdateStatus summary: Is a newer release available? (administrator) description: | The app asks GitHub for the latest published, non-prerelease release of its own repository and compares it with the version it runs. A prerelease is never announced. The request carries a fixed `User-Agent` and nothing else: no version, no instance id, no user data. The answer is remembered (six hours after a success, ten minutes after a failure), so a page view never calls GitHub. A failure keeps the last good answer and adds `error`. The check can be switched off with `NASSA_UPDATE_CHECK=false`; that is reported as `enabled: false`, not as an error. It only announces: nothing is updated, restarted or downloaded. responses: "200": description: The remembered answer. content: {application/json: {schema: {$ref: "#/components/schemas/UpdateStatus"}}} default: {$ref: "#/components/responses/Problem"} /api/v1/system/update/check: post: tags: [admin] operationId: checkForUpdate summary: Ask GitHub again now (administrator) description: Bypasses the remembered answer but not a floor of 30 seconds between two real requests, so it cannot be used to hammer GitHub. Answers with the status either way. responses: "200": description: The answer after the check. content: {application/json: {schema: {$ref: "#/components/schemas/UpdateStatus"}}} default: {$ref: "#/components/responses/Problem"} /api/v1/backup: get: tags: [admin] operationId: downloadBackup summary: Download everything you entered as one file (administrator) description: | The owner's own copy of what *they* entered: the shares (and their settings), the file-type map and each person's interface preferences. It **never** holds a password, passkey, authenticator secret, recovery code, session, invitation, account or role, so a file that is mailed or kept in a cloud drive cannot open the application on another server. The one exception is the SMB password of a share, and only as the ciphertext the database already holds, sealed with `NASSA_ENCRYPTION_KEY`, which is never in the file. Scan results are not in it either: they come back by scanning again. It is not a replacement for a database dump (docs/deploy.md). responses: "200": description: The backup, as a JSON download. headers: Content-Disposition: {schema: {type: string}, description: 'attachment; filename="nas-storage-analyzer-backup-YYYY-MM-DD.json"'} Content-Length: {schema: {type: integer}, description: "Known before the body starts, so the screen can show how far the download is."} Cache-Control: {schema: {type: string}, description: "no-store"} content: {application/json: {schema: {$ref: "#/components/schemas/BackupFile"}}} default: {$ref: "#/components/responses/Problem"} /api/v1/backup/stage: post: tags: [admin] operationId: stageBackup summary: Upload a backup and have it checked (administrator) description: | The body is the file itself, at most 8 MB (whatever runs in front of the application has to allow that much). It is **fully checked** with the same rules as typing the data in by hand, and **nothing changes**: the answer says what a restore would do, and a token to start it with. A refused file is not kept. A person has one staged file at a time, and one that is not used is removed after an hour. Refusals: `backup_not_backup`, `backup_newer` (the version that wrote it is in `params`), `backup_invalid` (with the `field` in the file), `backup_too_many`, and 413 `backup_too_big`. requestBody: required: true content: application/json: {schema: {type: object}} responses: "200": description: The file is acceptable. Nothing has been changed yet. content: {application/json: {schema: {$ref: "#/components/schemas/StagedBackup"}}} default: {$ref: "#/components/responses/Problem"} /api/v1/backup/stage/{token}: delete: tags: [admin] operationId: unstageBackup summary: Remove a staged backup the person changed their mind about (administrator) parameters: [{name: token, in: path, required: true, schema: {type: string}}] responses: "204": {description: "Removed, or already gone."} default: {$ref: "#/components/responses/Problem"} /api/v1/backup/jobs: post: tags: [admin] operationId: startRestore summary: Start the restore of a staged backup (administrator) description: | Answers at once and restores on the server: leaving the page, reloading or closing the browser stops nothing. The restore checks the file once more and replaces everything in **one transaction**, so a failure changes nothing. It is refused with `409 already_running` while another restore runs, and with `409 busy` while scans are queued or running; in both cases the staged file is **kept**, so the same token can be used again a moment later. (A scan queued in the instant between this answer and the swap makes the job fail with the reason `busy`; nothing is changed then either.) The token works once it has been accepted. Follow it with `GET /api/v1/backup/jobs/latest`. requestBody: required: true content: application/json: schema: {type: object, required: [token], properties: {token: {type: string, description: "The token of the staged file."}}} responses: "202": description: Started. content: {application/json: {schema: {$ref: "#/components/schemas/BackupJob"}}} default: {$ref: "#/components/responses/Problem"} /api/v1/backup/jobs/latest: get: tags: [admin] operationId: latestRestore summary: The running restore, or the last finished one that has not been dismissed (administrator) description: The screen asks once a second while it is open. A running restore that has not moved for 15 minutes is given up as interrupted; nothing was changed then. responses: "200": description: The job, or null when there is none. content: application/json: schema: {type: object, required: [job], properties: {job: {allOf: [{$ref: "#/components/schemas/BackupJob"}], nullable: true}}} default: {$ref: "#/components/responses/Problem"} /api/v1/backup/jobs/{id}: delete: tags: [admin] operationId: dismissRestore summary: Dismiss a finished restore once its outcome has been shown (administrator) parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}] responses: "204": {description: Dismissed.} default: {$ref: "#/components/responses/Problem"} /api/v1/system/reset: get: tags: [admin] operationId: factoryResetInfo summary: What the factory-reset form needs (administrator) description: The word the administrator has to type (defined here, in one place) and which fresh proofs this person can give, so the form shows only the fields that apply. Changes nothing. responses: "200": {description: The word and the proofs., content: {application/json: {schema: {$ref: "#/components/schemas/ResetInfo"}}}} default: {$ref: "#/components/responses/Problem"} post: tags: [admin] operationId: factoryReset summary: Wipe the whole installation (administrator, with a fresh proof) description: | Deletes **everything the installation keeps in its database**: every account (the caller's too, who is signed out), every password, passkey and second factor, every share with its scan data and history, the file types that were edited (the defaults come back), the audit log, work in progress and waiting backups. Afterwards the application is a new installation and the setup wizard starts again. What stays lives outside the database: the environment variables (including `NASSA_ENCRYPTION_KEY`), the image, the host folder `DATA_PATH` itself, and the files on the NAS, which are only ever read. A session is **not enough**. The body carries the word (`phrase`) and a fresh proof of who is asking: either the `password` with a `code` of the authenticator app (or a `recovery_code`), or a `passkey` (the answer to the challenge of `POST /api/v1/system/reset/passkey`, with its `ceremony`). A wrong word wipes nothing and is not a failed attempt; a wrong password, code or passkey counts toward the same lockout as signing in. It is refused with `409 busy` while a scan is queued or running. Everything is one transaction: if anything fails, nothing is wiped. The audit log goes with the database, so the only record is one warning in the process log with the numbers of rows removed. requestBody: required: true content: application/json: schema: {$ref: "#/components/schemas/ResetRequest"} responses: "200": {description: Wiped., content: {application/json: {schema: {$ref: "#/components/schemas/ResetResult"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/system/reset/passkey: post: tags: [admin] operationId: factoryResetPasskeyChallenge summary: A one-time challenge for confirming a factory reset with a passkey (administrator) description: The challenge is stored for this person and works once. A challenge that was not issued to the person who answers, one that has been used, and the passkey of somebody else are refused. responses: "200": {description: "WebAuthn request options for `navigator.credentials.get`.", content: {application/json: {schema: {$ref: "#/components/schemas/WebAuthnChallenge"}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/history: get: tags: [lists] operationId: shareHistory summary: Size of the share over time, from the snapshot of every scan parameters: - {$ref: "#/components/parameters/Share"} - {name: since, in: query, description: "RFC 3339 time or YYYY-MM-DD date.", schema: {type: string}} responses: "200": description: Points, oldest first. content: {application/json: {schema: {type: object, required: [items], properties: {items: {type: array, items: {$ref: "#/components/schemas/HistoryPoint"}}}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/dirs/{dir}/history: get: tags: [lists] operationId: dirHistory summary: Size of one folder over time description: Only large folders near the top of the tree are kept in snapshots (down to the share's `snapshot_depth`); for others the list is empty. parameters: [{$ref: "#/components/parameters/Share"}, {$ref: "#/components/parameters/Dir"}] responses: "200": description: Points, oldest first. content: {application/json: {schema: {type: object, required: [items], properties: {items: {type: array, items: {$ref: "#/components/schemas/DirHistoryPoint"}}}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/shares/{share}/jobs: get: tags: [shares] operationId: listJobs summary: Recent scan and maintenance jobs of a share parameters: [{$ref: "#/components/parameters/Share"}] responses: "200": description: Newest first. content: {application/json: {schema: {type: object, required: [items], properties: {items: {type: array, items: {$ref: "#/components/schemas/Job"}}}}}} default: {$ref: "#/components/responses/Problem"} /api/v1/settings: get: tags: [reference] operationId: getSettings summary: Instance settings the interface needs responses: "200": description: Settings. content: application/json: schema: type: object required: [shares_root, version, server_time, mount_supported] properties: shares_root: {type: string, description: Bind shares must lie below this directory.} version: {type: string} server_time: {type: string, format: date-time} mount_supported: {type: boolean} default: {$ref: "#/components/responses/Problem"} /api/v1/fs/dirs: get: tags: [admin] operationId: browseFolders summary: List the sub-folders of a folder below the shares root (admin) description: >- For the folder picker of the share form. Lists folders only (never files) and never leaves the shares root: a link is shown only when it points to a folder inside the root. A folder with more entries than the limits (20,000 read, 1,000 returned) is cut off and `truncated` is true. parameters: - name: path in: query required: false description: Absolute path inside the container, at or below `shares_root`. Default is the root itself. schema: {type: string} responses: "200": description: The folder and its sub-folders, sorted by name. content: application/json: schema: type: object required: [root, path, dirs, truncated] properties: root: {type: string, description: The shares root; the picker cannot go above it.} path: {type: string, description: The folder that was listed.} parent: {type: string, description: The folder above this one. Absent at the root.} dirs: type: array items: type: object required: [name, path] properties: name: {type: string} path: {type: string, description: "Full path inside the container, usable as the path of a share."} symlink: {type: boolean, description: A link to a folder inside the root.} truncated: {type: boolean} default: {$ref: "#/components/responses/Problem"} /api/v1/events: get: tags: [shares] operationId: events summary: Live state of all shares (server-sent events) description: "A `text/event-stream`. The event `status` carries a JSON array of `ShareStatus` and is sent when anything changed (and once at the start); a `: ping` comment keeps idle connections open. While a scan runs, its progress is included." responses: "200": description: The stream. content: {text/event-stream: {schema: {type: string}}} default: {$ref: "#/components/responses/Problem"} /api/v1/extension-map/{ext}: parameters: [{name: ext, in: path, required: true, description: Extension without dot, schema: {type: string}}] put: tags: [admin] operationId: putExtension summary: Map an extension to a category (admin) description: Queues a re-categorisation of every share, which updates sizes per category without rescanning. requestBody: {required: true, content: {application/json: {schema: {type: object, required: [category_id], properties: {category_id: {type: integer}}}}}} responses: "200": description: Saved. content: {application/json: {schema: {type: object, required: [ext, category_id, recategorize_jobs], properties: {ext: {type: string}, category_id: {type: integer}, recategorize_jobs: {type: integer}}}}} default: {$ref: "#/components/responses/Problem"} delete: tags: [admin] operationId: deleteExtension summary: Remove an extension mapping (admin); it becomes "other" responses: "200": description: Removed. content: {application/json: {schema: {type: object, required: [ext, recategorize_jobs], properties: {ext: {type: string}, recategorize_jobs: {type: integer}}}}} default: {$ref: "#/components/responses/Problem"} components: securitySchemes: session: {type: apiKey, in: cookie, name: nasa_session, description: "Session cookie (named `__Host-nasa_session` over https). Changing requests also need the X-CSRF-Token header."} responses: Problem: description: An error (RFC 9457 problem details). 400 invalid input, 404 unknown share or folder, 409 share not scanned yet, 503 query too slow or database down. content: application/problem+json: schema: {$ref: "#/components/schemas/Problem"} parameters: Share: {name: share, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} Dir: {name: dir, in: path, required: true, description: "Folder id or `root`.", schema: {type: string}} Limit: {name: limit, in: query, description: "Page size (default 100, at most 1000). `n` is accepted as an alias.", schema: {type: integer, minimum: 1, maximum: 1000}} Cursor: {name: cursor, in: query, description: "The `next_cursor` of the previous page.", schema: {type: string}} Order: {name: order, in: query, description: "Default `desc`, except for `name` and `ext` which default to `asc`.", schema: {type: string, enum: [asc, desc]}} Format: {name: format, in: query, description: "Use `csv` for a CSV export (same as `Accept text/csv`).", schema: {type: string, enum: [json, csv]}} Category: {name: category, in: query, description: "Category ids or keys (video, audio, images, documents, archives, code, disk_images, other); comma-separated or repeated.", schema: {type: string}} Ext: {name: ext, in: query, description: "Extensions without dot, comma-separated or repeated. `none` matches files without extension.", schema: {type: string}} MinSize: {name: min_size, in: query, description: "Minimum file size in bytes.", schema: {type: integer, format: int64, minimum: 0}} MaxSize: {name: max_size, in: query, description: "Maximum file size in bytes.", schema: {type: integer, format: int64, minimum: 0}} ModifiedBefore: {name: modified_before, in: query, description: "mtime before this RFC 3339 time or YYYY-MM-DD date.", schema: {type: string}} ModifiedAfter: {name: modified_after, in: query, description: "mtime at or after this time.", schema: {type: string}} UsedBefore: {name: used_before, in: query, description: "Last used before this time (basis of the share: atime, mtime or the later of both).", schema: {type: string}} UsedAfter: {name: used_after, in: query, description: "Last used at or after this time.", schema: {type: string}} UnusedDays: {name: unused_days, in: query, description: "Shortcut for `used_before` = now minus this many days. Default 365 on the cold endpoints.", schema: {type: integer, minimum: 0, maximum: 36500}} Q: {name: q, in: query, description: "Case-insensitive substring of the file name.", schema: {type: string}} DirBy: {name: by, in: query, description: "`total` ranks by the whole subtree (default), `direct` by files directly inside, which avoids listing every ancestor of a big folder.", schema: {type: string, enum: [total, direct]}} MinDepth: {name: min_depth, in: query, description: "Skip folders above this depth (the share root is depth 0; default 1).", schema: {type: integer, minimum: 0}} schemas: Problem: type: object required: [type, title, status] properties: type: {type: string} title: {type: string} status: {type: integer} detail: {type: string} code: {type: string, description: "Stable reason, for example unauthenticated, mfa_required, enroll_required, forbidden, csrf, locked, invalid_request, conflict, already_active, busy, no_data, no_stage, already_running, backup_not_backup, backup_newer, backup_invalid, backup_too_many, backup_too_big, reset_phrase, reauth_required."} field: {type: string, description: "The form field a validation error is about; for a backup file, where in the file (for example `shares[2].cron`)."} params: {type: object, additionalProperties: {type: string}, description: "Values a message needs (a version, a limit), for the screen to put in a sentence in the reader's language."} request_id: {type: string} Totals: type: object required: [size, alloc, files] properties: size: {type: integer, format: int64} alloc: {type: integer, format: int64} files: {type: integer, format: int64} ScanInfo: type: object required: [id, share_id, status, mode, trigger, started_at, finished_at, dirs, reused_dirs, files, bytes, alloc_bytes, error_count, fs_total_bytes, fs_free_bytes, atime_quality, error] properties: id: {type: integer, format: int64} share_id: {type: integer, format: int64} status: {type: string, enum: [running, succeeded, failed, cancelled]} mode: {type: string, enum: [full, incremental]} trigger: {type: string} started_at: {type: string, format: date-time} finished_at: {type: string, format: date-time, nullable: true} dirs: {type: integer, format: int64} reused_dirs: {type: integer, format: int64} files: {type: integer, format: int64} bytes: {type: integer, format: int64} alloc_bytes: {type: integer, format: int64} error_count: {type: integer, format: int64} fs_total_bytes: {type: integer, format: int64, nullable: true} fs_free_bytes: {type: integer, format: int64, nullable: true} atime_quality: type: object nullable: true description: Can "last used" be based on atime? `verdict` is reliable, doubtful or unreliable; `reasons` are machine keys. properties: verdict: {type: string, enum: [reliable, doubtful, unreliable]} reasons: {type: array, items: {type: string}, nullable: true} sample_files: {type: integer, format: int64} pct_equal_mtime: {type: number} pct_before_mtime: {type: number} carried_over: {type: boolean} mount: type: object properties: mount_point: {type: string} fs_type: {type: string} options: {type: array, items: {type: string}} error: {type: string, nullable: true} ShareInfo: type: object required: [id, name, kind, last_used_basis, excludes, workers, max_ops_per_sec, created_at, root_dir_id, config, has_password, cron, next_run_at, last_run_at, full_scan_every, max_ops_day, day_start_hour, day_end_hour, retention_scans, retention_days, snapshot_depth, last_scan, latest_scan, active_job] properties: config: {type: object, additionalProperties: {type: string}, description: "Kind-specific settings without secrets (path, server, export, share, username, domain, version, mount_options) and the base paths of the delete scripts (client_windows, client_posix)."} has_password: {type: boolean} cron: {type: string} next_run_at: {type: string, format: date-time, nullable: true} last_run_at: {type: string, format: date-time, nullable: true} full_scan_every: {type: integer} max_ops_day: {type: integer} day_start_hour: {type: integer} day_end_hour: {type: integer} retention_scans: {type: integer} retention_days: {type: integer} snapshot_depth: {type: integer} active_job: nullable: true description: The queued or running job, with live progress. allOf: [{$ref: "#/components/schemas/ActiveJob"}] id: {type: integer, format: int64} name: {type: string} kind: {type: string, enum: [bind, nfs, smb]} last_used_basis: {type: string, enum: [atime, mtime, max]} excludes: {type: array, items: {type: string}} workers: {type: integer} max_ops_per_sec: {type: integer} created_at: {type: string, format: date-time} root_dir_id: {type: integer, format: int64, nullable: true} last_scan: nullable: true description: The scan whose data is published. allOf: [{$ref: "#/components/schemas/ScanInfo"}] latest_scan: nullable: true description: The most recent attempt, which may be running or have failed. allOf: [{$ref: "#/components/schemas/ScanInfo"}] ScanPage: type: object required: [items, next_before] properties: items: {type: array, items: {$ref: "#/components/schemas/ScanInfo"}} next_before: {type: integer, format: int64, nullable: true} ScanErrorPage: type: object required: [items, next_after] properties: items: type: array items: type: object required: [id, path, kind, message, at] properties: id: {type: integer, format: int64} path: {type: string} kind: {type: string} message: {type: string} at: {type: string, format: date-time} next_after: {type: integer, format: int64, nullable: true} CategoryTotal: type: object required: [id, key, size, alloc, files, pct] properties: id: {type: integer} key: {type: string} size: {type: integer, format: int64} alloc: {type: integer, format: int64} files: {type: integer, format: int64} pct: {type: number, description: "Share of the scope's total size, 0-100."} ExtensionTotal: type: object required: [ext, category_id, size, alloc, files, pct] properties: ext: {type: string, description: "Empty for files without extension."} category_id: {type: integer} size: {type: integer, format: int64} alloc: {type: integer, format: int64} files: {type: integer, format: int64} pct: {type: number} Category: type: object required: [id, key] properties: id: {type: integer} key: {type: string} ExtensionMapping: type: object required: [ext, category_id] properties: ext: {type: string} category_id: {type: integer} Child: type: object required: [id, name, size, alloc, files, dirs, pct, oldest_mtime, newest_mtime, oldest_atime, newest_atime] properties: id: {type: integer, format: int64} name: {type: string} size: {type: integer, format: int64, description: "Size of the matching files below this folder."} alloc: {type: integer, format: int64} files: {type: integer, format: int64} dirs: {type: integer, format: int64, description: "Subfolders below, regardless of the filter."} pct: {type: number, description: "Share of the parent's matching size, 0-100."} oldest_mtime: {type: string, format: date-time, nullable: true} newest_mtime: {type: string, format: date-time, nullable: true} oldest_atime: {type: string, format: date-time, nullable: true} newest_atime: {type: string, format: date-time, nullable: true} ChildPage: type: object required: [items, parent, tier] properties: items: {type: array, items: {$ref: "#/components/schemas/Child"}} next_cursor: {type: string} parent: {$ref: "#/components/schemas/Totals"} tier: {type: integer, enum: [1, 2, 3]} Crumb: type: object required: [id, name] properties: id: {type: integer, format: int64} name: {type: string, description: "Empty for the share root."} DirDetail: type: object required: [id, parent_id, name, depth, path, breadcrumb, total, direct, dirs, dirs_direct, oldest_mtime, newest_mtime, oldest_atime, newest_atime, categories] properties: id: {type: integer, format: int64} parent_id: {type: integer, format: int64, nullable: true} name: {type: string} depth: {type: integer} path: {type: string, description: "Path below the share root with a leading slash; empty for the root."} breadcrumb: {type: array, items: {$ref: "#/components/schemas/Crumb"}} total: {$ref: "#/components/schemas/Totals"} direct: {$ref: "#/components/schemas/Totals"} dirs: {type: integer, format: int64} dirs_direct: {type: integer, format: int64} oldest_mtime: {type: string, format: date-time, nullable: true} newest_mtime: {type: string, format: date-time, nullable: true} oldest_atime: {type: string, format: date-time, nullable: true} newest_atime: {type: string, format: date-time, nullable: true} matching: allOf: [{$ref: "#/components/schemas/Totals"}] description: Present when a filter was given. categories: {type: array, items: {$ref: "#/components/schemas/CategoryTotal"}} DirRow: type: object required: [id, name, path, size, alloc, files, dirs, size_direct, files_direct, oldest_mtime, newest_mtime, oldest_atime, newest_atime] properties: id: {type: integer, format: int64} name: {type: string} path: {type: string} size: {type: integer, format: int64} alloc: {type: integer, format: int64} files: {type: integer, format: int64} dirs: {type: integer, format: int64} size_direct: {type: integer, format: int64} files_direct: {type: integer, format: int64} oldest_mtime: {type: string, format: date-time, nullable: true} newest_mtime: {type: string, format: date-time, nullable: true} oldest_atime: {type: string, format: date-time, nullable: true} newest_atime: {type: string, format: date-time, nullable: true} DirPage: type: object required: [items] properties: items: {type: array, items: {$ref: "#/components/schemas/DirRow"}} next_cursor: {type: string} TreeNode: type: object required: [name, size, files, kind] properties: id: {type: integer, format: int64} name: {type: string} size: {type: integer, format: int64} files: {type: integer, format: int64} kind: {type: string, enum: [dir, files, other], description: "`files` = files directly inside the parent, `other` = the remaining small subfolders."} children: {type: array, items: {$ref: "#/components/schemas/TreeNode"}} File: type: object required: [id, dir_id, name, path, ext, category_id, size, alloc, mtime, atime, ctime, nlink, counted] properties: id: {type: integer, format: int64} dir_id: {type: integer, format: int64} name: {type: string} path: {type: string, description: "Path below the share root with a leading slash."} ext: {type: string} category_id: {type: integer} size: {type: integer, format: int64} alloc: {type: integer, format: int64} mtime: {type: string, format: date-time} atime: {type: string, format: date-time} ctime: {type: string, format: date-time} nlink: {type: integer} counted: {type: boolean, description: "False for extra hardlinks of an inode, whose size is not added to any total."} FilePage: type: object required: [items] properties: items: {type: array, items: {$ref: "#/components/schemas/File"}} next_cursor: {type: string} SearchHit: type: object required: [share_id, kind, id, dir_id, name, path, size, mtime] properties: share_id: {type: integer, format: int64} kind: {type: string, enum: [file, dir]} id: {type: integer, format: int64} dir_id: {type: integer, format: int64} name: {type: string} path: {type: string} size: {type: integer, format: int64} mtime: {type: string, format: date-time, nullable: true} Version: type: object required: [version, commit, channel] properties: version: {type: string, description: "MAJOR.MINOR.PATCH the image was built for, `dev` for a build from a checkout.", example: "0.1.0"} commit: {type: string, description: "The git commit the image was built from; empty when unknown.", example: "5d74b70"} channel: {type: string, enum: [stable, beta]} UpdateStatus: type: object required: [enabled, current, latest, available, url, checked_at, error, error_detail, repo] properties: enabled: {type: boolean, description: "False when the check is switched off (NASSA_UPDATE_CHECK=false)."} current: {type: string, description: "The version this installation runs; `dev` for a build from a checkout.", example: "0.1.0"} latest: {type: string, description: "The newest published release without the v; empty when there is none.", example: "0.2.0"} available: {type: boolean, description: "Latest is a higher version than current. Never true for a version that does not parse (`dev`)."} url: {type: string, description: "The release page, built from the repository and the version, never taken from GitHub's answer."} checked_at: {type: string, format: date-time, nullable: true, description: "When GitHub last answered; null when it has not yet."} error: {type: string, description: "The cause in words an administrator can act on, for example `HTTP 403 (rate limit)`; empty when the last attempt worked."} error_detail: {type: string, description: "The raw reason, also in the log."} repo: {type: string, example: "helmerzNL/NAS-Storage-Analyzer"} BackupFile: type: object description: | The backup file. A section that is absent is left as it is by a restore; one that is present but empty replaces what is there with nothing. A file of an older `format_version` still restores; a newer one is refused with the version that wrote it. required: [format, format_version, app_version, created_at, includes_files, shares, file_types, preferences] properties: format: {type: string, enum: [nas-storage-analyzer-backup]} format_version: {type: integer, minimum: 1, example: 1} app_version: {type: string, example: "0.1.0"} created_at: {type: string, format: date-time} includes_files: {type: boolean, description: "Always false: the application has no uploaded files, so a backup is one JSON file and never an archive."} shares: {type: array, items: {$ref: "#/components/schemas/BackupShare"}} file_types: type: array items: type: object required: [ext, category] properties: ext: {type: string, example: mkv} category: {type: string, description: "The category key (video, audio, images, documents, archives, code, disk_images, other).", example: video} preferences: type: array description: What each person chose for the interface, by user name. Nothing else about a person. items: type: object required: [username, prefs] properties: username: {type: string} prefs: {type: object, additionalProperties: {type: string}} BackupShare: type: object required: [name, kind, excludes, workers, max_ops_per_sec, max_ops_day, day_start_hour, day_end_hour, cron, last_used_basis, full_scan_every, retention_scans, retention_days, snapshot_depth] properties: name: {type: string} kind: {type: string, enum: [bind, nfs, smb]} path: {type: string} server: {type: string} export: {type: string} share: {type: string} username: {type: string} domain: {type: string} version: {type: string} mount_options: {type: string} client_windows: {type: string} client_posix: {type: string} excludes: {type: array, items: {type: string}} workers: {type: integer} max_ops_per_sec: {type: integer} max_ops_day: {type: integer} day_start_hour: {type: integer} day_end_hour: {type: integer} cron: {type: string} last_used_basis: {type: string, enum: [atime, mtime, max]} full_scan_every: {type: integer} retention_scans: {type: integer} retention_days: {type: integer} snapshot_depth: {type: integer} secret: {type: string, format: byte, description: "The SMB password as the ciphertext the database holds (base64), sealed with the key in the environment. Never the password itself: a file that carries a `password` is refused."} BackupChanges: type: object description: What a restore does to what is there now, or did. required: [shares_added, shares_updated, shares_removed, file_types_changed, preferences_applied, preferences_skipped, passwords_unreadable, recategorize_shares] properties: shares_added: {type: array, items: {type: string}} shares_updated: {type: array, items: {type: string}, description: "Shares that are in the file and whose settings differ from what is stored; the others are left alone, so a restore that changes nothing does not even move a schedule."} shares_removed: {type: array, items: {type: string}, description: "Shares the file does not have: they go, **with all of their scan data**."} file_types_changed: {type: integer, description: "Extensions added, moved to another category or removed."} preferences_applied: {type: integer, description: "People whose preferences differ and are set; people who already have them are not counted."} preferences_skipped: {type: array, items: {type: string}, description: "People the file names who have no account here."} passwords_unreadable: {type: array, items: {type: string}, description: "Shares restored without their SMB password, because this installation's encryption key cannot open the one in the file."} recategorize_shares: {type: integer, description: "How many shares are categorised again in the background because the file-type map changed."} BackupSummary: type: object required: [format_version, app_version, created_at, counts, missing, changes, scans_active] properties: format_version: {type: integer} app_version: {type: string} created_at: {type: string, format: date-time} counts: type: object required: [shares, file_types, preferences] properties: {shares: {type: integer}, file_types: {type: integer}, preferences: {type: integer}} missing: {type: array, items: {type: string, enum: [shares, file_types, preferences]}, description: "Sections the file does not have: a restore leaves them as they are."} changes: {$ref: "#/components/schemas/BackupChanges"} scans_active: {type: boolean, description: "Scans are queued or running: the restore is refused until they are done or cancelled."} StagedBackup: type: object required: [token, summary] properties: token: {type: string, description: "32 hexadecimal characters; works once, for the person who staged the file."} summary: {$ref: "#/components/schemas/BackupSummary"} BackupJob: type: object required: [id, status, phase, step, steps, error_key, error_params, summary, checking_ms, replacing_ms, started_by, started_at, finished_at] properties: id: {type: integer, format: int64} status: {type: string, enum: [running, done, failed]} phase: {type: string, enum: [checking, replacing]} step: {type: integer} steps: {type: integer, description: "0 means it cannot be counted."} error_key: {type: string, description: "Why it failed, as a key the screen words in the reader's language: invalid, busy, locked, interrupted or failed. `failed` always means nothing was changed.", example: ""} error_params: {type: object, additionalProperties: {type: string}} summary: {allOf: [{$ref: "#/components/schemas/BackupChanges"}], nullable: true} checking_ms: {type: integer, nullable: true} replacing_ms: {type: integer, nullable: true} started_by: {type: string} started_at: {type: string, format: date-time} finished_at: {type: string, format: date-time, nullable: true} ResetInfo: type: object required: [phrase, methods, scans_active] properties: phrase: {type: string, description: "The word to type.", example: RESET} methods: type: object required: [password_and_code, recovery, passkey] properties: password_and_code: {type: boolean, description: "The password and a code of the authenticator app. A password alone is never enough."} recovery: {type: boolean, description: "Unused recovery codes are left, so the code can be one of those."} passkey: {type: boolean, description: "A passkey proves it alone."} scans_active: {type: boolean, description: "A scan is queued or running: a reset is refused until it is done or cancelled."} ResetRequest: type: object required: [phrase] properties: phrase: {type: string, description: "The word from `GET /api/v1/system/reset`, typed by the administrator."} password: {type: string} code: {type: string, description: "A code of the authenticator app."} recovery_code: {type: string} ceremony: {type: string, description: "The `ceremony` of the passkey challenge."} passkey: {type: object, description: "The JSON form of the PublicKeyCredential that answers the challenge."} ResetResult: type: object required: [reset, restarting, setup_token_configured] properties: reset: {type: boolean} restarting: {type: boolean, description: "Always false: everything this installation keeps is in the database, and the running process picks the empty one up by itself. Nothing needs a restart policy."} setup_token_configured: {type: boolean, description: "True when `NASSA_SETUP_TOKEN` is set, which then stays the token of the setup wizard. False: a new token was made and written to the application log."} AuthStatus: type: object required: [setup_required, authenticated, min_password_length, https_required] properties: setup_required: {type: boolean} authenticated: {type: boolean} min_password_length: {type: integer} https_required: {type: boolean, description: "The server wants sign-in over https but this request came over http."} stage: {type: string, enum: [pending, enroll, active]} csrf_token: {type: string} methods: {type: array, items: {type: string, enum: [totp, passkey, recovery]}} user: {$ref: "#/components/schemas/User"} SessionResponse: type: object required: [status, csrf_token] properties: status: {type: string, enum: [ok, mfa_required, enroll_required]} csrf_token: {type: string, description: Send as X-CSRF-Token on every changing request.} user: {$ref: "#/components/schemas/User"} methods: {type: array, items: {type: string, enum: [totp, passkey, recovery]}} recovery_codes: {type: array, items: {type: string}, description: Shown once.} Invitation: type: object required: [id, kind, user_id, username, role, created_by, created_at, expires_at] properties: id: {type: integer, format: int64} kind: {type: string, enum: [new, reset]} user_id: {type: integer, format: int64, nullable: true, description: "The user a reset is for; null for a new account."} username: {type: string, description: "A new account: the name reserved for the guest (may be empty). A reset: the user's own name."} role: {type: string, enum: [admin, viewer]} created_by: {type: string, description: "The administrator who made it (empty when that account is gone)."} created_at: {type: string, format: date-time} expires_at: {type: string, format: date-time} InvitationCreated: description: An invitation with its link, which is shown this once. allOf: - $ref: "#/components/schemas/Invitation" - type: object required: [token, url] properties: token: {type: string, description: "32 random bytes, base64url. Only its SHA-256 is stored."} url: {type: string, description: "The link to hand over (`/#invite=`); empty when no public address is configured."} InviteInfo: type: object required: [kind, username, role, invited_by, expires_at] properties: kind: {type: string, enum: [new, reset]} username: {type: string, description: "The suggested name (new, may be empty) or the user's own name (reset, read-only)."} role: {type: string, enum: [admin, viewer]} invited_by: {type: string} expires_at: {type: string, format: date-time} InvitationPasswordRequest: type: object required: [token, password] properties: token: {type: string} username: {type: string, description: "A new account: the name the guest chooses. Ignored for a reset."} password: {type: string, minLength: 12} InvitationPasskeyRequest: type: object required: [token] properties: token: {type: string} username: {type: string} SetupRequest: type: object required: [token, username, password] properties: token: {type: string} username: {type: string} password: {type: string, minLength: 12} LoginRequest: type: object required: [username, password] properties: username: {type: string} password: {type: string} CodeRequest: type: object required: [code] properties: code: {type: string} PasswordRequest: type: object required: [password] properties: password: {type: string} WebAuthnChallenge: type: object required: [options, ceremony] properties: options: {type: object, description: "WebAuthn options in JSON form (base64url for binary fields); pass `options.publicKey` to the browser."} ceremony: {type: string, description: "Send back as the `ceremony` query parameter when finishing."} User: type: object required: [id, username, display_name, role, auth_source, totp_enabled, passkeys, recovery_codes_left, disabled, created_at, last_login_at, prefs] properties: id: {type: integer, format: int64} username: {type: string} display_name: {type: string} role: {type: string, enum: [admin, viewer]} auth_source: {type: string} totp_enabled: {type: boolean} passkeys: {type: integer} recovery_codes_left: {type: integer} disabled: {type: boolean} created_at: {type: string, format: date-time} last_login_at: {type: string, format: date-time, nullable: true} prefs: {type: object, additionalProperties: {type: string}} Passkey: type: object required: [id, name, created_at, last_used_at, synced] properties: id: {type: string} name: {type: string} created_at: {type: string, format: date-time} last_used_at: {type: string, format: date-time, nullable: true} synced: {type: boolean, description: "Backed up to a password manager or cloud keychain."} AuditEntry: type: object required: [id, at, user_id, username, ip, action, target, detail] properties: id: {type: integer, format: int64} at: {type: string, format: date-time} user_id: {type: integer, format: int64, nullable: true} username: {type: string} ip: {type: string} action: {type: string} target: {type: string} detail: {type: object} ShareInput: type: object description: Settings of a share. For creation `name` and `kind` are required; defaults apply to the rest. properties: name: {type: string} kind: {type: string, enum: [bind, nfs, smb]} path: {type: string, description: "bind: absolute path below the shares directory."} server: {type: string, description: "nfs, smb"} export: {type: string, description: "nfs: exported path."} share: {type: string, description: "smb: share name."} username: {type: string, description: "smb; empty means guest."} domain: {type: string} password: {type: string, description: "smb; write-only, stored encrypted."} version: {type: string, description: "nfs: 3, 4, 4.1, 4.2. smb: 2.0, 2.1, 3.0, 3.02, 3.1.1."} mount_options: {type: string, description: "Extra mount options from an allow-list; the mount is always read-only."} client_windows: {type: string, description: "Where a Windows PC sees this share, for the delete scripts: `\\\\nas\\media` or `Z:\\`. Empty means not set. The application never opens this path."} client_posix: {type: string, description: "Where Linux or macOS sees this share, for the delete scripts: an absolute path such as `/mnt/user/media`. Empty means not set."} excludes: {type: array, items: {type: string}, description: "Name patterns such as .snapshot or @eaDir, matched at any depth; a pattern with a slash matches the path from the share root."} workers: {type: integer, minimum: 1, maximum: 256} max_ops_per_sec: {type: integer, minimum: 0, description: "Metadata operations per second; 0 is unlimited."} max_ops_day: {type: integer, minimum: 0, description: "Limit during the day window; 0 means the same limit all day."} day_start_hour: {type: integer, minimum: 0, maximum: 23} day_end_hour: {type: integer, minimum: 0, maximum: 24} cron: {type: string, description: "Five-field schedule, for example `0 3 * * *`; empty means no schedule."} last_used_basis: {type: string, enum: [atime, mtime, max]} full_scan_every: {type: integer, minimum: 0, description: "Every Nth scan is a full one; 0 never."} retention_scans: {type: integer, minimum: 0} retention_days: {type: integer, minimum: 0} snapshot_depth: {type: integer, minimum: 1, maximum: 12} ActiveJob: type: object required: [id, kind, state, trigger, requested_by, requested_at, scan_id, progress] properties: id: {type: integer, format: int64} kind: {type: string, enum: [scan, recategorize]} state: {type: string, enum: [queued, running]} trigger: {type: string} requested_by: {type: string} requested_at: {type: string, format: date-time} scan_id: {type: integer, format: int64, nullable: true} progress: type: object nullable: true description: Live numbers of a running scan. properties: dirs: {type: integer, format: int64} reused_dirs: {type: integer, format: int64} files: {type: integer, format: int64} errors: {type: integer, format: int64} bytes: {type: integer, format: int64} alloc: {type: integer, format: int64} current_dir: {type: string} files_per_sec: {type: number} percent: {type: number, nullable: true} eta_seconds: {type: number, nullable: true} elapsed_seconds: {type: number} ops_limit: {type: integer} Job: type: object required: [id, kind, share_id, state, mode, trigger, requested_by, requested_at, started_at, finished_at, scan_id, error] properties: id: {type: integer, format: int64} kind: {type: string, enum: [scan, recategorize]} share_id: {type: integer, format: int64} state: {type: string, enum: [queued, running, done, failed, cancelled]} mode: {type: string, enum: [auto, full]} trigger: {type: string} requested_by: {type: string} requested_at: {type: string, format: date-time} started_at: {type: string, format: date-time, nullable: true} finished_at: {type: string, format: date-time, nullable: true} scan_id: {type: integer, format: int64, nullable: true} error: {type: string, nullable: true} HistoryPoint: type: object required: [scan_id, at, size, alloc, files, dirs, categories] properties: scan_id: {type: integer, format: int64} at: {type: string, format: date-time} size: {type: integer, format: int64} alloc: {type: integer, format: int64} files: {type: integer, format: int64} dirs: {type: integer, format: int64} categories: type: array items: type: object required: [id, size, files] properties: id: {type: integer} size: {type: integer, format: int64} files: {type: integer, format: int64} DirHistoryPoint: type: object required: [scan_id, at, size, files] properties: scan_id: {type: integer, format: int64} at: {type: string, format: date-time} size: {type: integer, format: int64} files: {type: integer, format: int64} ShareStatus: type: object required: [share_id, job, latest_scan_id, latest_scan_status, latest_scan_finished_at] properties: share_id: {type: integer, format: int64} job: nullable: true allOf: [{$ref: "#/components/schemas/ActiveJob"}] latest_scan_id: {type: integer, format: int64, nullable: true} latest_scan_status: {type: string, nullable: true} latest_scan_finished_at: {type: string, format: date-time, nullable: true}