Skip to main content

Module handler

Module handler 

Source
Expand description

GET /api/search — the single full-text query surface.

§Authorization

The endpoint is reachable unauthenticated — it sits under optional_auth in routes/public.rs, and a tokenless caller is treated as the "guest" subject, which the derivation below turns into SubjectAccessLevel::Public, the level GET /api/files gives the same caller. Three layers, in order:

  1. Scoped tokens. A share-link holder carries file:{id}:{R|C|W}. Only that variant is accepted — an unparseable or apkg:publish scope is a hard 403, never a widening. The token confines the query to its own document tree and to file/document rows: a link recipient has no business searching the tenant’s posts or profiles.

    A scope narrows the subtree; the visibility derivation below still runs for every caller and the scope applies on top of it. A share link to a folder is not a grant over that folder’s Direct and Connected children, and GET /api/files does not treat it as one either. The one exception is SearchOptions::scope_grant_file_id: the shared file’s own row and the deep 'D' parts of its tree bypass the level filter, because the share is permission to read that document — without it a link to a private note would search to zero hits inside a note its holder can open. Child 'F' rows in the tree keep the level filter.

    A scoped token is never the owner, whatever its subject says: share-link tokens are minted with sub: None and iss = the tenant, and validation resolves id_tag as sub.unwrap_or(iss). See [subject_level].

  2. SQL prefilter. Per object type, the same rule the corresponding list endpoint applies, so pagination counts only rows the caller could see:

    • 'P' profiles are not filtered — tenant-scoped, and any authenticated caller in the tenant may find them, exactly as GET /api/profiles allows. That does not extend to an anonymous caller, so an unauthenticated request has 'P' dropped from its obj_tp filter outright (see [guest_obj_tp]); otherwise anyone could enumerate the tenant’s whole contact graph, cached remote profiles included.
    • 'F' files and 'D' deep parts are filtered by visible_levels, derived from the caller’s relationship to the tenant precisely as GET /api/files derives it — the tenant owns both.
    • 'A' actions use the canonical predicate shared with GET /api/actions, keyed on the issuer, not the tenant: following the tenant does not make the caller a follower of every issuer whose posts it federated in.

    An owner — caller id_tag == tenant id_tag and no scope — gets visible_levels = None, and then no predicate is emitted at all.

  3. Redundant post-check. The SQL prefilter above is the authorization. file_access::check_scope_allows_file is exactly file_id == scope || root_id == scope, the same predicate the adapter already pushed down for scope_file_id, so under a correct prefilter it never drops a row. Kept as a cross-check against future drift in either half, and loud: a non-zero drop count logs at warn!.

§Pagination

Results are relevance-ordered, so this endpoint uses limit/offset rather than the keyset cursor the rest of the API uses — a cursor over a bm25() ordering has nothing stable to anchor on. offset is capped.

pagination.total is the only has-more signal the response carries: derived from the page alone it would equal offset + len and every page would look like the last one. It normally comes from a second adapter call, cloudillo_types::meta_adapter::MetaAdapter::count_search, over the same SQL filters as the page itself.

That second call is skipped when the page answers the question by itself: a first page (offset == 0) shorter than limit is the whole match set, so its length is the total. This is the common case on a route reachable with no token at all, where ?q=a — a "a"* prefix matching most of the corpus — would otherwise cost two full ranked FTS scans, each re-running the per-row correlated EXISTS over actions. The decision reads the raw adapter row count, before the post-check, so it stays consistent with what the SQL matched. Not tokio::join!ed with the page: that would undo the skip.

When it does run, the count saturates at SEARCH_MAX_OFFSET + SEARCH_MAX_LIMIT. offset is clamped to SEARCH_MAX_OFFSET, so no page a caller can reach lies past it and the has-more signal stays exact where a caller can act on it. Uncapped, such a request would walk the tenant’s whole match set.

Structs§

SearchHit
One result row on the wire.
SearchQuery
Query parameters for GET /api/search.

Functions§

get_search