Skip to main content

cloudillo_search/
objects.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Whole-object index rows: `'F'` files, `'P'` profiles, `'A'` actions.
5//!
6//! # Why this is Rust and not a SQL trigger
7//!
8//! A trigger cannot call into Rust, so what an action contributes to the index
9//! would be capped at what `json_extract` can express — a hardcoded action-type
10//! allowlist, and adding an indexable type would mean editing SQL in a storage
11//! adapter. Instead the rules live where the action type is defined, in the
12//! Action DSL's `search` block, and are applied here on the same debounced
13//! scheduler path that serves deep `'D'` document parts.
14//!
15//! Only the text is decided here. `MetaAdapter::replace_search_row` derives the
16//! ACL columns (`content_type`, `owner_tag`, `visibility`, `root_id`,
17//! `created_at`) from the source row in the same statement that writes the index
18//! row, so the index and its source cannot disagree about who may see a hit.
19//!
20//! The cost: a write path can forget to call [`schedule_object`], where a trigger
21//! could not be forgotten. The mitigations are the sweep in [`crate::reindex`],
22//! which converges the index from scratch, and the call sites being one line
23//! each, immediately after the adapter call they follow.
24
25use std::{collections::HashMap, sync::Arc};
26
27use async_trait::async_trait;
28use cloudillo_core::scheduler::{Task, TaskId};
29use cloudillo_types::meta_adapter::{
30	ActionView, FileStatus, FileView, ListProfileOptions, MANAGED_PARENT_ID, Profile, SearchPart,
31	TRASH_PARENT_ID,
32};
33use parking_lot::RwLock;
34use serde::{Deserialize, Serialize};
35
36use crate::{
37	extract::{TextSink, extract_fields},
38	indexer::OBJ_FILE,
39	prelude::*,
40	rules::ActionSearchRules,
41};
42
43/// `obj_tp` for a whole profile row.
44pub const OBJ_PROFILE: char = 'P';
45/// `obj_tp` for a whole action row.
46pub const OBJ_ACTION: char = 'A';
47
48/// Seconds of quiet before a changed object is indexed.
49///
50/// Shorter than the 30s document debounce — an object write is one final state,
51/// not a typing burst — but long enough that an action's create → finalize →
52/// update sequence collapses into a single index run via the scheduler's key
53/// dedup.
54pub const OBJECT_DEBOUNCE_SECS: i64 = 5;
55
56/// Char budget per extracted field. Whole-object text is short by nature (a file
57/// name, a post body), so these only bound the pathological case — and every char
58/// that gets through is stored twice, as the plain-text extract in
59/// `search_docs.body` plus the FTS5 index.
60const MAX_TITLE_CHARS: usize = 1024;
61const MAX_TAGS_CHARS: usize = 1024;
62const MAX_BODY_CHARS: usize = 16_000;
63
64/// Ask for one object to be re-indexed once it goes quiet.
65///
66/// Fire-and-forget, exactly like [`crate::indexer::schedule`]: failures are
67/// logged, never propagated. A missed index run costs a stale search result,
68/// which must not fail the user's write.
69pub fn schedule_object(app: &App, tn_id: TnId, obj_tp: char, obj_id: &str) {
70	let app = app.clone();
71	let obj_id: Box<str> = obj_id.into();
72	tokio::spawn(async move {
73		let key = format!("search.object:{}:{}:{}", tn_id.0, obj_tp, obj_id);
74		let task = IndexObjectTask { tn_id, obj_tp, obj_id: obj_id.clone() };
75		if let Err(e) =
76			app.scheduler.task(Arc::new(task)).key(key).after(OBJECT_DEBOUNCE_SECS).await
77		{
78			warn!(tn_id = %tn_id, %obj_tp, %obj_id, error = %e,
79				"Failed to schedule search object index task");
80		}
81	});
82}
83
84/// Index one object now, bypassing the debounce. Used by the task body and by
85/// the reindex sweep.
86pub async fn index_object(app: &App, tn_id: TnId, obj_tp: char, obj_id: &str) -> ClResult<()> {
87	match obj_tp {
88		OBJ_FILE => index_file(app, tn_id, obj_id).await,
89		OBJ_PROFILE => index_profile(app, tn_id, obj_id).await,
90		OBJ_ACTION => index_action(app, tn_id, obj_id).await,
91		_ => Err(Error::ValidationError(format!("unknown search object type '{obj_tp}'"))),
92	}
93}
94
95/// Index one file's own `'F'` row. Its deep `'D'` parts are
96/// [`crate::indexer`]'s job.
97///
98/// A file's indexable text is server-owned — a name and a tag list — so unlike
99/// an action it needs no manifest and gets a fixed mapping.
100pub async fn index_file(app: &App, tn_id: TnId, file_id: &str) -> ClResult<()> {
101	if let Some(file) = app.meta_adapter.read_file(tn_id, file_id).await? {
102		return index_file_row(app, tn_id, &file).await;
103	}
104	let fts_cl = !crate::store_text(app, tn_id).await;
105	app.meta_adapter
106		.replace_search_row(tn_id, OBJ_FILE, file_id, None, fts_cl)
107		.await
108}
109
110/// Index a file already in hand — what the sweep uses, so paging a tenant's
111/// files does not re-read every one of them.
112///
113/// A file that does not qualify is written as `part = None`, which deletes its
114/// `'F'` row *and* the deep `'D'` rows [`crate::indexer`] built for it — see
115/// `replace_search_row`'s contract. So trashing a document takes its pages out
116/// of the index in the same call.
117pub async fn index_file_row(app: &App, tn_id: TnId, file: &FileView) -> ClResult<()> {
118	// Tags are stored comma-joined; the tokenizer needs whitespace to see one
119	// token per tag.
120	let tags = file.tags.as_ref().map(|t| t.join(" ")).filter(|t| !t.is_empty());
121	let part = file_part(file, tags.as_deref());
122	let fts_cl = !crate::store_text(app, tn_id).await;
123	app.meta_adapter
124		.replace_search_row(tn_id, OBJ_FILE, &file.file_id, part.as_ref(), fts_cl)
125		.await
126}
127
128/// Whether a file should have index rows at all.
129///
130/// Pure, so the rule is testable without an `App`, and shared: both this
131/// module's `'F'` row and [`crate::indexer`]'s deep `'D'` parts have to agree,
132/// or the sweep would delete one and immediately rebuild the other.
133///
134/// A file in the trash is excluded alongside a deleted one — it is out of every
135/// listing, so a hit on it would deep-link nowhere.
136///
137/// Managed files are excluded too, and that one is a disclosure rule rather than
138/// a dead-link rule. `crates/cloudillo-profile/src/media.rs` caches every peer's
139/// avatar into `MANAGED_PARENT_ID` as `"<peer id_tag>-profile-pic.jpg"` with
140/// `visibility: Some('P')`, so indexing them would let an *unauthenticated*
141/// `/api/search` enumerate the tenant's whole contact graph out of the file
142/// names. `GET /api/files` drops managed files from every listing; search must
143/// not be wider than the listing it mirrors.
144///
145/// `hidden` is treated identically: it is the read-only legacy flag from the
146/// pre-managed-folder schema — rows a new write would place in
147/// `MANAGED_PARENT_ID` — so folding it in needs no new column and no migration.
148/// The cost is that those legacy rows stop being searchable even for the tenant
149/// owner; they stay reachable through `GET /api/files`.
150pub fn is_indexable(file: &FileView) -> bool {
151	file.parent_id.as_deref() != Some(TRASH_PARENT_ID)
152		&& file.parent_id.as_deref() != Some(MANAGED_PARENT_ID)
153		&& !file.hidden
154		&& !matches!(file.status, FileStatus::Deleted)
155}
156
157/// What one file contributes to the index, or `None` if it should have no row.
158fn file_part<'a>(file: &'a FileView, tags: Option<&'a str>) -> Option<SearchPart<'a>> {
159	is_indexable(file).then(|| SearchPart {
160		title: Some(&*file.file_name),
161		tags,
162		..Default::default()
163	})
164}
165
166/// Index one profile.
167///
168/// Searching either the display name or the id_tag finds the person, so both are
169/// indexed — the name as the title, the id_tag as the body.
170pub async fn index_profile(app: &App, tn_id: TnId, id_tag: &str) -> ClResult<()> {
171	// Read through the *listing*, not `read_profile`. A relationship-only upsert
172	// leaves a row with a NULL `type` — a placeholder for an unsynced peer, not a
173	// profile — and `read_profile` treats that as a hard error rather than a miss.
174	// The listing filters those out, which also makes this agree with the sweep,
175	// which pages the same query.
176	let opts = ListProfileOptions { id_tag: Some(id_tag.to_owned()), ..Default::default() };
177	let profile = app.meta_adapter.list_profiles(tn_id, &opts).await?.into_iter().next();
178	if let Some(profile) = profile {
179		return index_profile_row(app, tn_id, &profile).await;
180	}
181	let fts_cl = !crate::store_text(app, tn_id).await;
182	app.meta_adapter
183		.replace_search_row(tn_id, OBJ_PROFILE, id_tag, None, fts_cl)
184		.await
185}
186
187/// Index a profile already in hand — what the sweep uses.
188pub async fn index_profile_row(
189	app: &App,
190	tn_id: TnId,
191	profile: &Profile<Box<str>>,
192) -> ClResult<()> {
193	let part = SearchPart {
194		title: Some(&profile.name),
195		body: Some(&profile.id_tag),
196		..Default::default()
197	};
198	let fts_cl = !crate::store_text(app, tn_id).await;
199	app.meta_adapter
200		.replace_search_row(tn_id, OBJ_PROFILE, &profile.id_tag, Some(&part), fts_cl)
201		.await
202}
203
204/// Index one action, according to its type's DSL `search` manifest.
205///
206/// Three conditions drop an action from the index before any manifest is
207/// consulted, because they are platform-wide tombstone conventions rather than
208/// per-type rules: the action is gone, its status is not Active, or its subtype
209/// is `DEL`. After that, a type with no manifest is simply not indexed — the
210/// absence of a `search` block is the only allowlist there is.
211pub async fn index_action(app: &App, tn_id: TnId, action_id: &str) -> ClResult<()> {
212	if let Some(action) = app.meta_adapter.get_action(tn_id, action_id).await? {
213		return index_action_row(app, tn_id, &action).await;
214	}
215	let fts_cl = !crate::store_text(app, tn_id).await;
216	app.meta_adapter
217		.replace_search_row(tn_id, OBJ_ACTION, action_id, None, fts_cl)
218		.await
219}
220
221/// Index an action already in hand — what the sweep uses, so paging a tenant's
222/// actions costs no second read (and no second round of profile hydration) per
223/// row.
224pub async fn index_action_row(app: &App, tn_id: TnId, action: &ActionView) -> ClResult<()> {
225	let text = action_text(app, action);
226	let part = text.as_ref().map(|t| SearchPart {
227		title: t.title.as_deref(),
228		body: t.body.as_deref(),
229		tags: t.tags.as_deref(),
230		..Default::default()
231	});
232	let fts_cl = !crate::store_text(app, tn_id).await;
233	app.meta_adapter
234		.replace_search_row(tn_id, OBJ_ACTION, &action.action_id, part.as_ref(), fts_cl)
235		.await
236}
237
238/// The three text fields one action contributes, or `None` if it contributes
239/// nothing and its row should be deleted.
240#[derive(Debug, Default, PartialEq, Eq)]
241struct ActionText {
242	title: Option<String>,
243	body: Option<String>,
244	tags: Option<String>,
245}
246
247fn action_text(app: &App, action: &ActionView) -> Option<ActionText> {
248	if !is_live(action.status.as_deref(), action.sub_typ.as_deref()) {
249		return None;
250	}
251	let rules = action_rules(app, &action.typ, action.sub_typ.as_deref())?;
252	extract_action(&action_document(action), &rules)
253}
254
255/// Whether an action row is live enough to index at all.
256///
257/// Checked before any manifest, because both conditions are platform-wide
258/// conventions rather than anything a type declares: only an Active row is
259/// visible to clients, and a `DEL` subtype is a tombstone standing in for the
260/// action it retracts. A NULL status means Pending, which is not yet published.
261fn is_live(status: Option<&str>, sub_typ: Option<&str>) -> bool {
262	status == Some("A") && sub_typ != Some("DEL")
263}
264
265/// Apply an action manifest to a wrapper document.
266///
267/// Split out from [`action_text`] so the extraction can be tested without an
268/// `App` or a database.
269fn extract_action(doc: &serde_json::Value, rules: &ActionSearchRules) -> Option<ActionText> {
270	let field = |field_rules: &[crate::rules::FieldRule], budget: usize| {
271		let mut sink = TextSink::new(budget);
272		extract_fields(doc, field_rules, &mut sink);
273		(!sink.is_empty()).then(|| sink.into_string())
274	};
275	let text = ActionText {
276		title: field(&rules.title, MAX_TITLE_CHARS),
277		body: field(&rules.body, MAX_BODY_CHARS),
278		tags: field(&rules.tags, MAX_TAGS_CHARS),
279	};
280	// A row with no text at all would only dilute `bm25()`.
281	(text != ActionText::default()).then_some(text)
282}
283
284/// The document an action manifest's field rules are applied to.
285///
286/// Deliberately wider than the action's `content`: a rule may want the type, the
287/// issuer or an attachment id, and none of those live inside `content`. Field
288/// names match the JSON an action is serialized as on the wire, so a manifest
289/// author writes the paths they already read in the API.
290fn action_document(action: &ActionView) -> serde_json::Value {
291	serde_json::json!({
292		"content": action.content,
293		"type": action.typ,
294		"subType": action.sub_typ,
295		"issuerTag": action.issuer.id_tag,
296		"audienceTag": action.audience.as_ref().map(|a| &a.id_tag),
297		"subject": action.subject,
298		"attachments": action.attachments.as_ref().map(|list| {
299			list.iter().map(|a| &a.file_id).collect::<Vec<_>>()
300		}),
301	})
302}
303
304/// Parsed action manifests, keyed by resolved DSL definition name.
305///
306/// Registered as an `App` extension by the server's app module; a per-`App`
307/// value rather than a static, so two `App`s in one process — integration tests,
308/// embedded or multi-instance hosting — cannot share (and contradict) each
309/// other's definition set.
310///
311/// DSL definitions are immutable after startup, so each type is parsed once per
312/// `App`. Keyed by the *resolved* name rather than the `(type, subType)` pair a
313/// caller passes: resolved names are that `App`'s fixed set of definitions,
314/// whereas a federated action's subtype is unbounded and would let this map grow
315/// without limit. `None` is cached too — the answer for a type with no `search`
316/// block, which is most of them.
317pub type ActionRulesCache = Arc<RwLock<HashMap<Box<str>, Option<Arc<ActionSearchRules>>>>>;
318
319/// Build an empty [`ActionRulesCache`], so the server crate can register one
320/// without taking a `parking_lot` dependency of its own.
321pub fn new_action_rules_cache() -> ActionRulesCache {
322	Arc::default()
323}
324
325/// Resolve and parse an action type's manifest, or `None` if the type is not
326/// indexed.
327fn action_rules(app: &App, typ: &str, sub_typ: Option<&str>) -> Option<Arc<ActionSearchRules>> {
328	// Absent when the search subsystem is used without the action subsystem —
329	// in tests, and in any future build that ships one without the other.
330	let lookup = app.ext::<cloudillo_core::ActionSearchRulesFn>().ok()?;
331	let (key, manifest) = lookup(typ, sub_typ)?;
332
333	// Same "search without the server crate" case as the lookup above: parse
334	// uncached rather than fail, since the cache is an optimization.
335	let cache = app.ext::<ActionRulesCache>().ok();
336	if let Some(cache) = cache
337		&& let Some(cached) = cache.read().get(&key)
338	{
339		return cached.clone();
340	}
341	// A malformed manifest is caught at startup, so reaching the warning here
342	// means a definition was loaded past that check. Cache the failure anyway:
343	// re-parsing a broken manifest on every action of the type would only log.
344	let rules = manifest.as_ref().and_then(|m| {
345		ActionSearchRules::parse(m)
346			.inspect_err(|e| warn!(%key, error = %e, "Invalid action search manifest"))
347			.ok()
348			.map(Arc::new)
349	});
350	if let Some(cache) = cache {
351		cache.write().insert(key, rules.clone());
352	}
353	rules
354}
355
356/// Scheduled per-object index run. See the module docs.
357#[derive(Debug, Serialize, Deserialize)]
358pub struct IndexObjectTask {
359	pub tn_id: TnId,
360	pub obj_tp: char,
361	pub obj_id: Box<str>,
362}
363
364#[async_trait]
365impl Task<App> for IndexObjectTask {
366	fn kind() -> &'static str {
367		"search.object"
368	}
369
370	fn kind_of(&self) -> &'static str {
371		Self::kind()
372	}
373
374	fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
375		Ok(Arc::new(serde_json::from_str::<Self>(ctx)?))
376	}
377
378	fn serialize(&self) -> String {
379		// Built by hand rather than via `to_string().unwrap_or("{}")`: "{}" does
380		// not deserialize back into this type, so a fallback would poison the
381		// persisted task row and log forever on retry.
382		let mut obj = serde_json::Map::with_capacity(3);
383		obj.insert("tn_id".into(), self.tn_id.0.into());
384		obj.insert("obj_tp".into(), self.obj_tp.to_string().into());
385		obj.insert("obj_id".into(), self.obj_id.as_ref().into());
386		serde_json::Value::Object(obj).to_string()
387	}
388
389	async fn run(&self, app: &App) -> ClResult<()> {
390		index_object(app, self.tn_id, self.obj_tp, &self.obj_id).await
391	}
392}
393
394#[cfg(test)]
395mod tests {
396	use super::*;
397
398	fn rules(json: &serde_json::Value) -> ActionSearchRules {
399		ActionSearchRules::parse(json).expect("rules")
400	}
401
402	/// The manifest POST, CMNT and MSG carry.
403	fn body_rules() -> ActionSearchRules {
404		rules(&serde_json::json!({ "v": 1, "body": [{ "field": "content", "extract": "text" }] }))
405	}
406
407	#[test]
408	fn one_content_walk_covers_all_three_legacy_content_shapes() {
409		// A post's content is a bare string, `{text}` or `{content}` depending on
410		// its age. One walk handles all three.
411		for content in [
412			serde_json::json!("bare string post"),
413			serde_json::json!({ "text": "bare string post" }),
414			serde_json::json!({ "content": "bare string post" }),
415		] {
416			let doc = serde_json::json!({ "content": content });
417			let text = extract_action(&doc, &body_rules()).expect("indexable");
418			assert_eq!(text.body.as_deref(), Some("bare string post"));
419			assert_eq!(text.title, None);
420		}
421	}
422
423	#[test]
424	fn conv_takes_its_name_as_the_title() {
425		let conv = rules(&serde_json::json!({
426			"v": 1,
427			"title": ["content.name"],
428			"body": [{ "field": "content", "extract": "text" }]
429		}));
430		let doc = serde_json::json!({ "content": { "name": "Tervezés", "topic": "Q3" } });
431		let text = extract_action(&doc, &conv).expect("indexable");
432		assert_eq!(text.title.as_deref(), Some("Tervezés"));
433		// The body walk sees the name too; that is harmless duplication, and the
434		// alternative — excluding it — would lose a real hit on a name-only CONV.
435		assert!(text.body.as_deref().is_some_and(|b| b.contains("Q3")));
436	}
437
438	#[test]
439	fn an_action_with_no_text_is_not_indexed() {
440		// FSHR's content is `{contentType, fileName, fileTp}` — no prose. With no
441		// `search` block it never reaches here; with a body rule it still yields
442		// nothing.
443		let doc = serde_json::json!({ "content": { "dim": [640, 480] } });
444		assert_eq!(extract_action(&doc, &body_rules()), None);
445	}
446
447	#[test]
448	fn the_wrapper_document_exposes_more_than_content() {
449		let doc = serde_json::json!({
450			"content": { "text": "szia" },
451			"type": "MSG",
452			"issuerTag": "alice.example.com"
453		});
454		let with_issuer =
455			rules(&serde_json::json!({ "v": 1, "body": ["content"], "tags": ["issuerTag"] }));
456		let text = extract_action(&doc, &with_issuer).expect("indexable");
457		assert_eq!(text.body.as_deref(), Some("szia"));
458		assert_eq!(text.tags.as_deref(), Some("alice.example.com"));
459	}
460
461	/// A `FileView` with only the fields the index rule reads.
462	fn file_view(parent_id: Option<&str>, status: &str) -> FileView {
463		serde_json::from_value(serde_json::json!({
464			"fileId": "f1~doc",
465			"fileName": "Jegyzetek",
466			"parentId": parent_id,
467			"createdAt": 0,
468			"status": status,
469		}))
470		.expect("file view")
471	}
472
473	#[test]
474	fn a_live_file_contributes_its_name_and_tags() {
475		let file = file_view(None, "A");
476		let part = file_part(&file, Some("munka projekt")).expect("indexable");
477		assert_eq!(part.title, Some("Jegyzetek"));
478		assert_eq!(part.tags, Some("munka projekt"));
479	}
480
481	#[test]
482	fn a_trashed_file_is_dropped_from_the_index_like_a_deleted_one() {
483		// A hit on either would deep-link nowhere. The sweep pages with
484		// `sweep_all`, so it sees both and takes their rows back out even when the
485		// live hook was forgotten.
486		assert!(file_part(&file_view(Some(TRASH_PARENT_ID), "A"), None).is_none());
487		assert!(file_part(&file_view(None, "D"), None).is_none());
488		// A file in an ordinary folder is unaffected.
489		assert!(file_part(&file_view(Some("f1~folder"), "A"), None).is_some());
490	}
491
492	#[test]
493	fn managed_and_hidden_files_are_not_searchable() {
494		// Cached peer avatars live in the managed folder with `visibility: 'P'`, so
495		// an indexed one leaks the tenant's contact graph to an unauthenticated
496		// search. `hidden` is the legacy spelling of the same thing.
497		let managed = file_view(Some(MANAGED_PARENT_ID), "A");
498		assert!(!is_indexable(&managed));
499		assert!(file_part(&managed, None).is_none());
500		let mut hidden = file_view(None, "A");
501		hidden.hidden = true;
502		assert!(!is_indexable(&hidden));
503		// An ordinary file is unaffected by either exclusion.
504		assert!(is_indexable(&file_view(None, "A")));
505	}
506
507	#[test]
508	fn a_del_tombstone_and_a_non_active_row_are_dropped_before_any_manifest() {
509		assert!(is_live(Some("A"), None));
510		assert!(is_live(Some("A"), Some("TEXT")));
511		assert!(!is_live(Some("A"), Some("DEL")), "a DEL tombstone must not be indexed");
512		assert!(!is_live(Some("P"), None), "a pending action is not published yet");
513		assert!(!is_live(Some("V"), None), "an inbound action mid-verification is not live");
514		assert!(!is_live(None, None));
515	}
516}
517
518// vim: ts=4