Add file collections, uploads, downloads, DOCX/XLSX/PPTX previews, business attachments, and editable UI Library file components to a NocoBase 3 App using the public File Repository services.
npx skills add https://github.com/nocobase/nocobase --skill nocobase-app-plugin-file
Use @nocobase/app-plugin-file/server and @nocobase/app-plugin-file/client. The plugin provides Repository managers, service tokens, and route helpers. The UI Library provides @nocobase/file-component-ui. The App or business plugin owns collections, migrations, Drive configuration, resource routes, permissions, and pages.
Inspect the App's existing registrations, migrations, disks, and resources first. Register the Server default export and the Client default factory before consumers, using the App's plugin lifecycle commands. The core does not create collections or routes.
Inspect client/extensions/nocobase-file-component-ui/ before writing upload or preview components. Current Default templates preinstall this application-owned UI Library source and its client dependencies; older applications and other templates may not. Import from its index.ts, not from plugin-internal paths. Registering the plugin supplies services and translations; it does not automatically install or upgrade the copied UI.
| Format | UI Library preview | Content access |
| ---------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------ |
| DOCX / XLSX / PPTX | Local @silurus/ooxml viewer | Same-origin session credentials; no public URL or third-party service required |
| Raster image / PDF / text / Markdown / audio / video | Built-in preview components | See the content access rules below |
| Legacy DOC / XLS / PPT and OpenDocument | Office Online fallback | Internet-accessible absolute URL; cannot use the App session |
| Unsupported or unsafe active content | Explanation and optional download | Download still requires permission |
To show a record's files as thumbnails and chips, or take uploads beside a form or a comment, install the UI Library's attachment-list component (yes n | pnpm exec shadcn add @nocobase/attachment-list; its example is @nocobase/attachment-list-demo), map file records to AttachmentFile, and open FilePreviewDialog from its onPreview. For custom attachment lists, uploads or business associations, reuse FilePreviewDialog with file records and contentUrl; it requires no repository prop. Extend the existing UI or add the necessary content adapter rather than reimplementing format detection and dropping supported formats. A DOCX download fallback indicates a request/rendering failure or an outdated/custom component, not that the plugin lacks DOCX support. Report the actual failure and preserve its evidence.
If the installed package, synchronized Skill and copied UI disagree, inspect the resolved plugin version, run pnpm nocobase skills sync to refresh guidance, and reconcile the UI source separately. Skills synchronization does not upgrade UI Library copies.
The following business example uses collection invoice_files, resource invoiceAttachments, connection main, and disk local. Adapt these to the App.
Create an App-owned, self-contained migration under database/main/migrations/ with a unique name matching its filename. Replace main with the target connection when needed:
import { defineMigration, type MigrationDefinition } from '@nocobase/db';
const migration: MigrationDefinition = defineMigration({
name: '202609080001_create_invoice_files',
async up({ builder }) {
await builder.createCollection('invoice_files', (collection) => {
collection.uuid('id').primary().notNull();
collection.string('disk', { length: 255 }).notNull();
collection.text('key').notNull();
collection.text('filename').notNull();
collection.string('ext', { length: 32 }).notNull();
collection.string('mimeType', { length: 255 }).notNull();
collection.bigInt('size').notNull();
collection.datetime('createdAt').notNull();
collection.datetime('updatedAt').notNull();
});
},
async down({ builder }) {
await builder.dropCollection('invoice_files');
},
});
export default migration;
The fields are fixed; mapping is unsupported. The primary key must accept a 36-character UUID, string fields accept string/char/text, size accepts integer/bigInt, and timestamps accept datetime/datetimeTz. Apply with the App's migration command. Never import a live collection schema from a migration.
Runtime code that selects or checks the full column set imports FILE_COLUMNS (and its element type FileColumn) from @nocobase/app-plugin-file/server instead of copying the list, for example select: (s) => s.fields(...FILE_COLUMNS). A migration still spells its columns out as above, because importing the list would let a later release change an already applied migration.
Upload generates the ID and storage key, normalizes the filename/extension, validates stored size, and supplies timestamps. Extra required columns need defaults because upload accepts no business values. contentUrl is derived, never persisted. Save returned file IDs through an App-owned relation or link table when submitting the business form; upload and form submission are separate commits.
Declare in the App or business plugin's Server routes module and merge both returned contributions into its existing routes:
import { defineFileRepositoryApiRoutes } from '@nocobase/app-plugin-file/server';
const fileRoutes: ReturnType<typeof defineFileRepositoryApiRoutes> =
defineFileRepositoryApiRoutes({
repositories: [
{
name: 'invoiceAttachments',
collection: 'invoice_files',
connection: 'main',
disk: 'local',
accessPath: '/uploads/invoices',
accessMode: 'stream',
policy: {
read: {
scope: true,
fields: [
'id',
'filename',
'ext',
'mimeType',
'size',
'createdAt',
'updatedAt',
],
},
create: { scope: true },
update: false,
delete: { scope: true },
},
actions: {
findMany: { maxLimit: 100 },
findOne: {},
deleteOne: {},
uploadOne: { maxSize: 5 * 1024 * 1024 },
uploadMany: { maxSize: 20 * 1024 * 1024 },
},
},
],
});
export default fileRoutes;
name is the Client resource; collection defaults to name, connection to the database default, and accessPath to /uploads/<name>. Disk is required. Content paths contain static alphanumeric, underscore or hyphen segments, start with /, and have no trailing slash.
Only declared POST /api/<name>/<action> operations are exposed; name is a camelCase path segment such as invoiceAttachments. Ordinary actions are findMany/findOne/count/exists/aggregate/groupBy/createOne/updateOne/deleteOne. There are no createMany/updateMany/deleteMany HTTP actions.
Every exposed action appears in the application's API document at /api/swagger/docs (JSON at /api/swagger), under the exposure's name as its tag with operationIds such as invoiceAttachmentsFindMany and invoiceAttachmentsUploadOne. The upload operations are documented as multipart/form-data with their maxSize, 201 result and 400/403/413/415 errors; read it there rather than guessing a request shape. The content route under accessPath is outside /api and is not in the document.
policy is required and governs every action of the exposure, uploads included. It is a Repository Policy: read, create, update and delete, each true, false, or a rule node. A node that is false refuses that operation; a node with no fields accepts no caller-supplied field, which is what a metadata createOne needs before it will accept anything. Pass a function of the principal — policy: (principal) => ({ ... }) — together with a principal(context) resolver to scope rows to the caller; without a principal the request is refused with 403.
An upload supplies no caller fields, so it binds a Policy derived from this one: the create scope and defaults are inherited and the field allowlist is replaced by the file columns. create: false therefore forbids uploading as well as creating metadata — decided, with the principal, before the body limit, the content type or the multipart body is read, so a refused upload answers 403 WRITE_FORBIDDEN (domain app) and stores nothing — and a create.defaults of { ownerId } is stamped onto uploaded rows — which is what keeps an uploaded file inside the same scope findMany reads. The content route under accessPath is the exception: it is public and this Policy does not reach it.
Content is GET <accessPath>/<uuid>.<ext> outside /api, omitting the dot when extensionless. Stream returns full bytes as an attachment; redirect returns a public storage URL or a five-minute signed URL. A disk without URL support requires stream mode; there is no automatic fallback.
These are public routes. For restricted files, register App-owned authentication and authorization on the paths each contribution owns before mounting it — /<name>/<action> per exposed API action, <accessPath>/* for content. Never router.use('*', ...) in a contribution router: contributions share the mounted router, so it also guards the SPA and every contribution mounted after yours. Check the operation and record/parent-record access; a login page, private disk, Client filter or Policy field allowlist is not authentication. Add scoped middleware and principal-based Policy to the generated routes first. If an existing contract needs an adapter, extend that module using the public Server manager and retain its authorization checks; missing built-in authentication alone is not a reason to replace the routes. Never trust browser-supplied ownership.
Resolve the original token from the current App container after registration; in a ServiceProvider use this.app.container. Do not create another database, Drive manager, or API Client.
import { serverFileRepositoryManagerToken } from '@nocobase/app-plugin-file/server';
const manager = container.resolve(serverFileRepositoryManagerToken);
const files = manager.repository('invoice_files', {
connection: 'main',
disk: 'local',
accessPath: '/uploads/invoices',
// Only the upload path binds this; the Repository itself is unrestricted,
// as a db.repository() call is.
policy: { read: true, create: true, update: false, delete: false },
});
const { record } = await files.uploadOne({ file });
const url = files.getUrl(record);
Server repository() takes the exact logical name from createCollection, not its physical table name (for example, crmAccountFiles is not crm_account_files), and requires disk/accessPath matching its routes. getUrl is synchronous and App-local; getStorageUrl asynchronously uses the record's disk/key to obtain a public or signed storage URL. Neither queries the database. Direct Server CRUD does not decorate URLs; uploads do.
A trusted server write can pass its outermost DatabaseConnection as transaction in these repository options. Uploaded rows then commit with the business write. Let errors propagate to roll back that transaction; rollback verifies ownership through the root connection before deleting uploaded objects. If ownership verification fails, objects are retained and a transaction callback warning is reported. Do not pass a savepoint connection or catch an upload error and commit the enclosing write. HTTP uploads remain independent commits and accept no transaction from the client.
Client code resolves clientFileRepositoryManagerToken and calls manager.repository('invoiceAttachments') with the resource name. It reuses apiClientToken, session and base URL; do not pass disk/connection/accessPath.
const { record } = await files.uploadOne({ file });
const batch = await files.uploadMany({ files: Array.from(input.files ?? []) });
const rows = await files.findMany({ limit: 20 });
await files.deleteOne({ filter: { id: record.id } });
Inputs are a native File or a nonempty File array. Results are { record, createdTargets, version? } and { createdCount, records }. Upload already creates metadata. Client uploads accept an optional second { signal } argument; abort does not undo a server commit. Use returned contentUrl directly: HTTP already adds the host prefix, such as /main. Both the read Policy and custom selects must retain id, ext, filename, mimeType, size, createdAt and updatedAt when their results feed UI Library UI. id and ext derive contentUrl; the remaining fields support thumbnails, preview selection and component refresh. Verify queried records, not only the immediate upload response. Storage disk and key need not be exposed to read-only UI.
The UI Library block carries its own locales/. Merge them into application locales before application overrides. Its components use the current namespace and label overrides take precedence. Plugin registration supplies repository services; it does not install UI.
Reuse the preinstalled directory when present. For an application without it, materialize component-ui from a NocoBase source workspace into the target application:
pnpm exec shadcn add @nocobase/file-component-ui
Run the command from a consuming application. In this source repository, copy the files declared by ui-library/registry/file/registry.json to their targets and retain workspace dependency ranges. For upgrades, merge changes with the existing application copy. The block adds no route or permissions; keep existing customized primitives.
Default already declares the viewer. For an application installing the source or upgrading an older copy, add the OOXML viewer from the target App directory if missing:
pnpm add -D --save-exact @silurus/[email protected]
The viewer belongs in the App's devDependencies: Vite compiles this application-owned client source. UI Library item dependencies describes the installation recipe, while a plugin's published runtime Client imports belong in that plugin's peerDependencies. Do not move this viewer into server dependencies or add it as a plugin peer solely for copied UI Library source. Merge the block's locale resources into the application namespace. Merge UI Library upgrades with App customizations instead of overwriting installed source.
Use a version of @nocobase/dev-config whose createAppViteConfig excludes @silurus/ooxml from dependency prebundling. For an older shared preset or custom Vite configuration, merge this entry into the existing configuration and preserve other exclusions:
optimizeDeps: {
exclude: ['@silurus/ooxml'],
},
Restart the development server after changing the configuration. This exclusion preserves the viewer's import.meta.url-relative WASM paths during development; verify the parser WASM requests in both development and the served production build.
Compose inside the started App's React context:
import { useMemo, useState, type ReactElement } from 'react';
import { useService } from '@nocobase/app-client';
import {
clientFileRepositoryManagerToken,
type FileRecord,
} from '@nocobase/app-plugin-file/client';
import {
FileUploadField,
FileList,
} from '#extensions/nocobase-file-component-ui/index';
export function InvoiceAttachments(): ReactElement {
const manager = useService(clientFileRepositoryManagerToken);
const repository = useMemo(
() => manager.repository('invoiceAttachments'),
[manager],
);
const [value, setValue] = useState<readonly FileRecord[]>([]);
const [error, setError] = useState('');
return (
<>
<FileUploadField
repository={repository}
value={value}
onChange={setValue}
multiple
onError={(cause) => setError(cause.message)}
/>
<FileList files={value} onError={(cause) => setError(cause.message)} />
{error && <p role='alert'>{error}</p>}
</>
);
}
FileUploadField takes a repository and controlled value/onChange; onStatusChange reports idle/uploading/error so forms can prevent incomplete submissions. Accept/maxSize/maxFiles are UI checks. removeOnDelete calls deleteOne, deleting metadata only; otherwise removal unlinks the selection. Read-only FileList, FileThumbnail, FilePreviewField and FilePreviewDialog use contentUrl without a repository prop. Import UI types from the installed recipe. Supply translated labels and adapt App-owned source as needed.
Preview supports safe raster images, PDF, text/Markdown and audio/video. HTML/SVG/XML previews and unsafe URL schemes are rejected. PDF is fetched and embedded as a same-origin blob: retyped to application/pdf, and an HTML or XML response is refused; the browser's own PDF viewer renders it, so a browser without one downloads it instead. DOCX, XLSX and PPTX use lazily loaded @silurus/ooxml viewers to render locally from FileRecord.contentUrl; their content is not sent to a third-party preview service. Legacy DOC/XLS/PPT and OpenDocument formats use Office Online, which requires an internet-accessible absolute URL and cannot use the App session.
OOXML, PDF and text fetches use same-origin credentials. An App-owned content route protected by same-origin session cookies can therefore serve restricted previews, provided it checks the caller's record access. Cross-origin fetches omit credentials and require CORS; an external cookie-protected URL is not supported by this default path. The Repository API client's Bearer token is not automatically attached to content fetches.
For Bearer-only content authorization, adapt the installed UI Library source in the App. Add an authenticated content loader to the OOXML fetch path and PreviewBody's PDF/text fetches, and adapt download and media/image paths as needed. Resolve authentication through the App's supported client/session services and send credentials only to the trusted content endpoint. Do not put tokens in contentUrl. If the adapter creates blob URLs, keep an App-owned trusted URL set, pass it to the relevant resolveSafeFileUrl calls, and revoke URLs when replaced or unmounted. Merely assigning a blob: URL to FileRecord.contentUrl is insufficient: the default components reject untrusted blob URLs.
OOXML request or rendering failure displays an error and, when downloads are enabled, a download action. download={false} removes that action. This is not a second preview service or a guarantee that downloading the same inaccessible URL will succeed.
Verify upload → query → contentUrl → identical downloaded bytes, including batch upload, resource aliases and host prefixes. After materialization run the consuming App's typecheck/build and exercise upload, cancel/retry, remove, download and preview. Restricted files need anonymous, forbidden-user and permitted-user tests against both API and content routes. Inspectors check registration only.
For preview integration or changes, verify each of PDF/DOCX/XLSX/PPTX with real files in development and a served production build. Check relative same-origin URLs, an authorized and unauthorized session, cross-origin CORS success/failure, failed requests and invalid documents, and download={false}. Close or switch files while loading and after a failure: pending requests must abort, viewers must be destroyed, and the next file must render without stale state. Verify PDF in a headed browser: previewing and paging a PDF must keep the top-level URL and the session. In a headless browser without a PDF viewer, a download or an about:blank tab is a browser limitation: record the browser and its mode and retry headed before reporting a component defect. A login redirect points to the content route or the session. Unit mocks and a successful build do not establish document rendering fidelity.
/api error body with domain file; branch on error.reason (ApiClientError.reason in the Client), never on message. A successful upload answers 201.multipart/form-data. INVALID_MULTIPART/INVALID_FILE/INVALID_FILES (400): send native File values and a nonempty batch. PRINCIPAL_REQUIRED (403): a Policy function needs a resolved principal.There is no built-in route authentication, row ACL, Range/206, ETag, conditional download, resumable upload, physical cleanup, content sniffing or malware scan. Metadata deletion retains objects; cancelled forms may leave unlinked files. Implement the policies required by the business, including referenced-file deletion and orphan cleanup.
Take nocobase/nocobase-app-plugin-file from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference pnpm.
Without those the skill loads but fails at the first command.