Project Documents API Developer Guide - edmozley/freeitsm GitHub Wiki
Project documents in the API โ Developer Guide
5.1.0 ยท #287 (proposal by @cloudcogsio) ยท Reference: REST API: Projects โ Documents ยท Design: Project documents in the API ยท Security: API threat protection โ Developer Guide
๐ The one-line version
The rules for adding, attaching and removing documents moved out of the three browser endpoints into DocumentsService (includes/services/documents.php); the browser endpoints and api/v1/resources/project_documents.php both call it, acting as an analyst with that analyst's modules. The content route adds its own permission and the download guard.
๐ Files
| File | What |
|---|---|
includes/services/documents.php |
DocumentsService::add(), attach(), detach(), listFor(), isOn(), referenceReady() |
api/documents/save.php, attach.php, unlink.php |
The Documents panel - now thin wrappers around the service; responses unchanged |
api/v1/resources/project_documents.php |
The five routes |
api/v1/lib/routes.php |
The routes (content also checks documents.download in the handler) |
api/v1/spec.json, lib/openapi_schemas.php |
Catalogue entries; ProjectDocument schema |
includes/projects/assistant.php |
projectAssistOverview() lists document titles (MCP, War Room, project assistant) |
๐งฉ The service
// Acting as an analyst - a signed-in person, or the analyst a key acts as.
$allowed = modulesWithoutSwitchedOff($conn, getAnalystAllowedModules($conn, $analystId)); // null = all
$r = DocumentsService::add($conn, $analystId, $allowed, 'project', $projectId, [
'file' => $_FILES['file'], // OR
'external_url' => 'https://...', // a link (http/https only)
'title' => 'Network design',
'description' => '...',
'client_reference' => 'pmtool:doc:4411', // optional retry key
'tenant_id' => $projectTenantId, // the document's company (default: the analyst's active one)
]);
// ['id' => 123, 'replayed' => false]
DocumentsService::attach($conn, $analystId, $allowed, 'project', $projectId, $documentId);
$gone = DocumentsService::detach($conn, $analystId, $allowed, 'project', $projectId, $documentId); // true = last link
$page = DocumentsService::listFor($conn, $analystId, $allowed, 'project', $projectId, $offset, $limit, $reference);
๐ด Access first, before any file is written - documentCanWriteParent() runs before uploadStoreFile(). ServiceError kinds: forbidden (the record - the same whether it does not exist or is not yours), not_found (the document), validation, conflict (reference_conflict).
Retries: client_reference
ALTER TABLE documents
ADD client_reference VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
ADD UNIQUE KEY uq_documents_client_reference (uploaded_by_id, client_reference);
Unique per uploader. A second add() with the same reference returns the first document (replayed: true) when it is on the same record and is the same thing (same name and SHA-256 for a file, same URL for a link) - the just-written copy is deleted - otherwise reference_conflict. Same pattern as Change evidence (#194).
๐ The routes
['GET', '#^/projects/(\d+)/documents$#', ['projects', 'read'], 'apiProjectDocumentsList'],
['POST', '#^/projects/(\d+)/documents$#', ['projects', 'update'], 'apiProjectDocumentsCreate'],
['POST', '#^/projects/(\d+)/documents/(\d+)/link$#', ['projects', 'update'], 'apiProjectDocumentsLink'],
['GET', '#^/projects/(\d+)/documents/(\d+)/content$#', ['projects', 'read'], 'apiProjectDocumentContent'], // + documents.download
['DELETE', '#^/projects/(\d+)/documents/(\d+)$#', ['projects', 'update'], 'apiProjectDocumentsDelete'],
Every handler starts with apiLoadProject() - company scope and members-only visibility, 404 otherwise. A document is only ever reached through its project: DocumentsService::isOn($conn, $did, 'project', $pid) or 404, so the content route never serves a document by id alone.
The content route:
apiRequirePermission($apiKey, 'documents', 'download');
$doc = apiProjectDocumentRow($conn, $pid, $did); // on this project, or 404
if (!documentCanView($conn, $analystId, $allowed, $did)) apiError(404, ...);
if ($doc['kind'] === 'link') { apiDownloadRecord(..., 'follow', 0); apiRespond(['kind' => 'link', 'external_url' => ...]); } // never a redirect
apiDownloadGuard($conn, $apiKey, $size, $did); // BEFORE reading
apiDownloadRecord($conn, $apiKey, $did, 'download', $size);
// application/octet-stream, nosniff, attachment, no-store
Parity, not more: attaching uses documentCanWriteParent() exactly as the app does - anyone who can see a project can add to its Documents panel; the API does not add the project's who may change rule on top.
๐ค The AI overview
projectAssistOverview() - shared by the MCP project_overview tool, Warbot and the project assistant - lists up to 20 document titles (with link for a link and the date added) through DocumentsService::listFor() as the asking analyst. Never a URL, never contents; any failure leaves the section out.
๐งช Test
tests/project-documents-api.php (30) - upload, retry (200 / 409), link, javascript: refused, list, ?client_reference=, the permission, exact bytes, the access log row, link content, another project's document / a members-only project / an unknown project (404), attach, remove (lives on / last link), plus the threat-protection checks. The browser endpoints are covered by tests/document-permissions.php (22), unchanged.
See also: Documents ยท REST API โ how it works