Browser
│ :3000
▼
┌────────────┐ /api ┌─────────────┐
│ web │─────────▶│ app │ Laravel 13 / FrankenPHP
│ Nuxt 4 │ └──────┬──────┘
└────────────┘ │
┌──────────┼──────────┬────────────┐
▼ ▼ ▼ ▼
postgres redis meilisearch minio
│
┌──────────┴──────────┐
▼ ▼
worker scheduler
│
┌─────────┼──────────┐
▼ ▼ ▼
tika gotenberg Nextcloud (read-only)
Only web has a published port. Nitro forwards /api/** to app — so the interface and
the API share an origin, and the session cookie plus CSRF protection work without special
handling. The backend is not reachable from outside; neither are Meilisearch or MinIO.
Two more paths take the same route: /.well-known/oauth-protected-resource and
/.well-known/oauth-authorization-server. They belong to the MCP server's OAuth discovery
and have to answer at the root of the domain, so Nitro forwards them from a server
middleware rather than the /api proxy — see mcp.md.
- The scheduler checks every minute which folder has passed its interval and creates
an
IndexRun. CrawlFolderJobfetches one directory level viaPROPFIND. For each subfolder it dispatches itself again — Nextcloud does not answerDepth: infinity.- For each file it compares
oc:fileidand the ETag against the stored state. Unchanged means: skipped, no extraction. New or changed means:ProcessDocumentJob. - That job downloads the file via
GETinto a temporary file, sends it to Tika (PUT /tikafor text,PUT /metafor metadata), renders the preview image, stores the full text in object storage, and writes the document into Meilisearch. - Files that no longer appear remotely are dropped from the database and the index.
Every dispatched job increments index_runs.pending_jobs, every completed one decrements
it. The decrement runs as an UPDATE … RETURNING — exactly one worker sees the zero and
closes the run, even when several finish at the same time. Runs caught mid-crawl by a
restart are cleaned up by an hourly task.
The first run to finish also stamps watched_folders.baseline_at. That is the line the
"Neu" automation waits for — see tags.md.
The apache/tika:3.3.1.0-full image ships with Tesseract, so a single container covers
both extraction and OCR.
PDFs first get a pass with X-Tika-PDFOcrStrategy: no_ocr. If that yields less than
nextsearch.tika.ocr.min_characters, the document has no text layer and is processed a
second time with ocr_only. Only if that produces more text is it kept, and ocr_used
is set — the interface then flags that recognition errors are possible.
| Source format | Path |
|---|---|
pdftoppm (poppler, in the app image) → PNG → WebP |
|
| Images | GD → WebP |
| Office | Gotenberg → PDF → as above |
| .eml, .md, .txt | no rendering, the interface shows a type tile |
Images are served through the backend on request rather than via a signed S3 URL: MinIO
has no published port by design, and a presigned URL to http://minio:9000 would be
unreachable from the browser. At ~30 KB per image that's no burden.
Meilisearch is never passed through to the browser. SearchController takes the request,
DocumentSearch determines the user's granted folder_ids and appends the filter on the
server. The filter depends on nothing that comes from the request.
If a user has no grant at all, no query goes out — an empty filter would be equivalent to "see everything".
Facets come from facetDistribution and build the filter panel. Multiple values of the
same facet are ORed, different facets are ANDed.
Tags live in the database; the index only mirrors them, as a filterable field tag_tokens
holding one entry per assignment — t12 for a global one, u17:t12 for user 17's own.
Meilisearch counts all of them, and DocumentSearch throws away every token the caller may
not see before the response leaves the backend. That is also why maxValuesPerFacet sits
at 1000: the ceiling has to clear the sum across all users, not what one person gets to
look at.
Tokens are keyed by the tag's id, not its slug, so renaming a tag costs no reindex and an
instance tag cannot collide with someone's private one of the same name. TagService
writes them: on the initial index they travel with DocumentDto, later changes go in as a
partial update. Only documents in state indexed are patched — an unknown id would make
Meilisearch create a stub rather than skip it.
Details of scopes and the automation: tags.md.
Meilisearch inserts the highlight markers raw into the text, and that text comes from
foreign files. So the markers are not HTML tags but placeholders: the backend escapes the
whole snippet first and then replaces the placeholders with <mark>. A <script> from an
indexed document therefore never reaches the browser as markup.
The endpoint at /api/mcp is JSON-RPC 2.0 over a single POST route, stateless: no session
id, no server-initiated stream, so GET is answered with 405 as the transport prescribes.
A restart of the container costs a client nothing.
The tools are thin. They translate a model's arguments into calls on the same services the
interface uses — DocumentSearch for the search, BulkTagger for the tagging — and turn
the result into text plus a structured object. Nothing about permissions happens in the
tool layer; it happens where it happened before, which is the point of routing through the
same services.
Authentication accepts two kinds of token, a personal key and an OAuth access token, and resolves both to the same thing: the user, plus the scopes the connection was granted. The 401 for a missing token carries the header that tells a client where to fetch one.
GET /api/documents/{uuid}/raw checks the grant, fetches the file via WebDAV and streams
it to the browser. The Nextcloud credentials stay on the server — whoever searches needs
no Nextcloud account.
Content-Disposition: inline is used only for types that are safe to embed (PDF, common
images, text/plain). HTML and SVG from a foreign Nextcloud would otherwise execute in
the context of the application and reach the user's session; they always go out as a
download, together with X-Content-Type-Options: nosniff.
nextcloud_instances— URL, user, encrypted app password, statewatched_folders— belongs to an instance, path, interval, exclude patternsfolder_user— grants, see permissions.mddocuments— a file's state, not its text: path, hash, size, ETag, keys for preview and text blob, processing stateindex_runs— one row per crawl with counters and an error listtags— the vocabulary: instance tags (owner_idnull) and private ones, with scope, assignment policy and the automation rule, see tags.mddocument_tag— the assignments;user_idnull means global, set means personalapi_tokens— personal API keys, stored as a SHA-256 hash, see api.mdoauth_clients— MCP clients that registered themselves, with their callbacksoauth_authorization_codes— the short-lived codes of the consent flow, with the PKCE challenge they were issued againstoauth_access_tokens·oauth_refresh_tokens— what an MCP client authenticates with, hashed like the personal keys, see mcp.md
The path is stored as text; the uniqueness index runs over path_hash — a Btree index
over arbitrarily long paths would eventually hit Postgres's row-size limit.
tags and document_tag each carry two partial unique indexes rather than one plain one.
Both tables use a nullable column to mean "belongs to nobody in particular", and Postgres
counts NULLs as distinct — so the null case needs an index of its own.
Three separate queues on Redis: crawl, process, preview. The worker serves them in
that order. Failed jobs are retried three times, with growing backoff.