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