Skip to main content

cloudillo_core/
bundled_apps.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! The document formats this *build* ships, read once out of the frontend bundle.
5//!
6//! # Why a global tier exists
7//!
8//! `doc_formats` says which app owns a content type, where its documents live and
9//! how to index them. Written at runtime by the shell — an app starts, says "I
10//! handle `cloudillo/notillo`", the shell PUTs its bundled copy of that app's
11//! manifest — that copies the same constant into one row per tenant per node, and
12//! only for a tenant whose user actually opened the app. A tenant nobody logged
13//! into, a guest arriving on a share link, a manifest changed by an upgrade
14//! nobody has opened yet — all end up with documents stored but not indexed.
15//!
16//! The data is a property of the bundle, so it is loaded from the bundle: the
17//! frontend build serialises each bundled app's manifest into `dist`, and this
18//! registry reads them at startup. Conceptually the `tn_id = 0` tier, but held in
19//! memory and **never written to `doc_formats`** — nothing is duplicated into the
20//! database, and there is nothing to migrate when the bundle changes.
21//!
22//! # Precedence
23//!
24//! A tenant row always wins, whatever its `formatVersion`. A row means the tenant
25//! deliberately installed something — usually a packaged app whose code lives in a
26//! blob — and a backend upgrade must never silently repoint it at a bundled app.
27//! `DELETE /api/doc-formats/{content_type}` drops the row and reverts to the
28//! bundled entry. There is no way to *suppress* a bundled format, only to override
29//! it; see `cloudillo_core::doc_format` for the resolution itself.
30//!
31//! # Failure is not fatal
32//!
33//! `DIST_DIR` is externally supplied data that a deployment can point anywhere,
34//! and dev checkouts frequently have no `dist` at all. A missing directory, an
35//! unreadable file, malformed JSON or an invalid `search` block therefore degrade
36//! — to an empty registry, or to one format fewer — rather than failing startup.
37//! An empty registry leaves resolution entirely to the tenant's own rows.
38
39use std::{collections::HashMap, fmt::Write as _, path::Path};
40
41use serde::Deserialize;
42use sha2::{Digest, Sha256};
43
44use cloudillo_types::meta_adapter::DocFormat;
45
46use crate::prelude::*;
47
48/// Publisher recorded for a bundled manifest that names none. The bundle is the
49/// platform's own build, so its apps are the platform's own publications.
50const DEFAULT_PUBLISHER_TAG: &str = "cloudillo.org";
51
52/// The aggregate file holding the shell's internal apps, which have no
53/// `dist/apps/<id>` directory of their own. A JSON array of manifests.
54const SHELL_MANIFESTS_FILE: &str = "shell-apps.json";
55
56/// Per-app manifest file, `dist/apps/<id>/cloudillo.json`. A single manifest
57/// object, and the same name (and format) an APKG package carries — which is what
58/// will let a packaged app's install feed this same loader later.
59const APP_MANIFEST_FILE: &str = "cloudillo.json";
60
61/// The doc formats the bundle declares, keyed by content type.
62#[derive(Debug, Default)]
63pub struct BundledAppRegistry {
64	formats: HashMap<Box<str>, DocFormat>,
65	/// Stable hash over the `(content_type, search)` pairs only — the staleness
66	/// stamp `cloudillo_search::reindex` folds into `search.index_rev` so a bundle
67	/// whose index rules changed triggers exactly one reindex per tenant. See
68	/// [`rules_hash`].
69	pub rules_hash: Box<str>,
70	/// Manifests that contributed at least one format. Logged at startup.
71	pub app_count: usize,
72}
73
74impl BundledAppRegistry {
75	/// Read every manifest under `dist_dir`. Never fails: see the module docs.
76	///
77	/// `validate_search` checks one `search` block and returns why it is bad. It
78	/// is a callback rather than a direct call because the rules DSL lives in
79	/// `cloudillo-search`, which depends on this crate — validating here would be
80	/// a dependency cycle. A block it rejects costs that one content type its
81	/// entry, nothing else.
82	pub fn load<F>(dist_dir: &Path, validate_search: F) -> Self
83	where
84		F: Fn(&serde_json::Value) -> Result<(), String>,
85	{
86		let mut manifests = read_manifests(dist_dir);
87		if manifests.is_empty() {
88			info!(dist_dir = %dist_dir.display(), "No bundled app manifests found");
89			return Self::default();
90		}
91		// Deterministic order, so a content type two apps both claim without a
92		// `primary` marker resolves the same way on every node and every boot.
93		manifests.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
94
95		// The `bool` is "this entry claimed `primary`", which is what lets a later
96		// app take a content type an earlier one already holds.
97		let mut formats: HashMap<Box<str>, (DocFormat, bool)> = HashMap::new();
98		let mut app_count = 0usize;
99		for LoadedManifest { manifest, updated_at } in &manifests {
100			let publisher = manifest.publisher.as_deref().unwrap_or(DEFAULT_PUBLISHER_TAG);
101			let mut contributed = false;
102			for ct in &manifest.content_types {
103				if let Some(search) = &ct.search
104					&& let Err(e) = validate_search(search)
105				{
106					error!(app = %manifest.id, content_type = %ct.mime_type, error = %e,
107						"Bundled manifest has an invalid search block; skipping this format");
108					continue;
109				}
110				let primary = ct.priority.as_deref() == Some("primary");
111				if let Some((held, held_primary)) = formats.get(ct.mime_type.as_str())
112					&& (*held_primary || !primary)
113				{
114					warn!(content_type = %ct.mime_type, held_by = %held.app_name,
115						challenger = %manifest.id,
116						"Two bundled apps declare the same content type; keeping the first");
117					continue;
118				}
119				let format = DocFormat {
120					content_type: ct.mime_type.as_str().into(),
121					publisher_tag: publisher.into(),
122					app_name: manifest.id.as_str().into(),
123					format_version: ct.format_version.as_deref().and_then(|v| {
124						let encoded = encode_format_version(v);
125						if encoded.is_none() {
126							warn!(app = %manifest.id, content_type = %ct.mime_type,
127								format_version = %v, "Unparseable formatVersion; ignoring it");
128						}
129						encoded
130					}),
131					store_tp: ct.store_tp.as_deref().map(Into::into),
132					nav_param: ct.nav_param.as_deref().map(Into::into),
133					search: ct.search.clone(),
134					x: None,
135					updated_at: *updated_at,
136				};
137				formats.insert(ct.mime_type.as_str().into(), (format, primary));
138				contributed = true;
139			}
140			app_count += usize::from(contributed);
141		}
142
143		let formats: HashMap<Box<str>, DocFormat> =
144			formats.into_iter().map(|(ct, (fmt, _))| (ct, fmt)).collect();
145		let rules_hash = rules_hash(&formats);
146		info!(apps = app_count, formats = formats.len(), rules_hash = %rules_hash,
147			"Bundled app manifests loaded");
148		Self { formats, rules_hash, app_count }
149	}
150
151	pub fn get(&self, content_type: &str) -> Option<&DocFormat> {
152		self.formats.get(content_type)
153	}
154
155	pub fn iter(&self) -> impl Iterator<Item = &DocFormat> {
156		self.formats.values()
157	}
158
159	pub fn is_empty(&self) -> bool {
160		self.formats.is_empty()
161	}
162
163	pub fn len(&self) -> usize {
164		self.formats.len()
165	}
166}
167
168/// One manifest, plus the mtime of the file it came from — the closest thing a
169/// bundled entry has to the `updated_at` a tenant row gets from its write.
170struct LoadedManifest {
171	manifest: BundledManifest,
172	updated_at: Timestamp,
173}
174
175/// The slice of an app manifest the backend cares about.
176///
177/// Deliberately no `deny_unknown_fields`: the same file carries everything the
178/// frontend needs (icons, launch modes, translations, …) and those must stay
179/// free to change without turning into a startup warning here.
180#[derive(Debug, Deserialize)]
181#[serde(rename_all = "camelCase")]
182struct BundledManifest {
183	id: String,
184	/// Publisher `id_tag`. Absent on every manifest today; see
185	/// [`DEFAULT_PUBLISHER_TAG`].
186	publisher: Option<String>,
187	#[serde(default)]
188	content_types: Vec<BundledContentType>,
189}
190
191#[derive(Debug, Deserialize)]
192#[serde(rename_all = "camelCase")]
193struct BundledContentType {
194	mime_type: String,
195	/// `"primary"` wins a content type two bundled apps both declare. Mirrors the
196	/// shell's own `buildMimeMap`.
197	priority: Option<String>,
198	store_tp: Option<String>,
199	nav_param: Option<String>,
200	/// `major.minor.patch`, encoded by [`encode_format_version`].
201	format_version: Option<String>,
202	search: Option<serde_json::Value>,
203}
204
205/// Read `shell-apps.json` and every `apps/*/cloudillo.json` under `dist_dir`.
206fn read_manifests(dist_dir: &Path) -> Vec<LoadedManifest> {
207	let mut out = Vec::new();
208	read_manifest_file(&dist_dir.join(SHELL_MANIFESTS_FILE), &mut out);
209
210	let apps_dir = dist_dir.join("apps");
211	let entries = match std::fs::read_dir(&apps_dir) {
212		Ok(entries) => entries,
213		Err(e) => {
214			info!(dir = %apps_dir.display(), error = %e, "No bundled app directory");
215			return out;
216		}
217	};
218	for entry in entries.flatten() {
219		read_manifest_file(&entry.path().join(APP_MANIFEST_FILE), &mut out);
220	}
221	out
222}
223
224/// Append the manifest(s) in one file. A file that is absent is normal; a file
225/// that is present and broken is worth a warning, but never fatal.
226fn read_manifest_file(path: &Path, out: &mut Vec<LoadedManifest>) {
227	let text = match std::fs::read_to_string(path) {
228		Ok(text) => text,
229		Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
230		Err(e) => {
231			warn!(path = %path.display(), error = %e, "Cannot read bundled app manifest");
232			return;
233		}
234	};
235	let updated_at = file_mtime(path);
236	match parse_manifests(&text) {
237		Ok(manifests) => out
238			.extend(manifests.into_iter().map(|manifest| LoadedManifest { manifest, updated_at })),
239		Err(e) => warn!(path = %path.display(), error = %e, "Invalid bundled app manifest"),
240	}
241}
242
243/// A per-app file holds one manifest object, the shell's aggregate an array of
244/// them. Both go through here so either shape works in either file.
245fn parse_manifests(text: &str) -> ClResult<Vec<BundledManifest>> {
246	let value: serde_json::Value = serde_json::from_str(text)?;
247	match value {
248		serde_json::Value::Array(items) => items
249			.into_iter()
250			.map(|item| serde_json::from_value(item).map_err(Error::from))
251			.collect(),
252		other => Ok(vec![serde_json::from_value(other)?]),
253	}
254}
255
256fn file_mtime(path: &Path) -> Timestamp {
257	std::fs::metadata(path)
258		.and_then(|meta| meta.modified())
259		.ok()
260		.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
261		.map_or(Timestamp(0), |d| Timestamp(d.as_secs().cast_signed()))
262}
263
264/// Hash of everything in this bundle that changes what gets indexed.
265///
266/// Folded into the per-tenant index staleness stamp, so it must cover the
267/// `search` rules and **nothing else**: including `nav_param`, `store_tp`, app
268/// names or versions would make a cosmetic manifest edit re-index every document
269/// of every tenant on the node.
270///
271/// `serde_json::Map` is a `BTreeMap` here, so `to_string` already emits object
272/// keys in a fixed order; sorting the pairs covers the map iteration order.
273fn rules_hash(formats: &HashMap<Box<str>, DocFormat>) -> Box<str> {
274	let mut entries: Vec<(&str, String)> = formats
275		.iter()
276		.filter_map(|(ct, fmt)| fmt.search.as_ref().map(|s| (&**ct, s.to_string())))
277		.collect();
278	entries.sort_unstable();
279
280	let mut hasher = Sha256::new();
281	for (content_type, search) in entries {
282		// Length-prefixed, so no pair of (content type, rules) can be re-split
283		// into a different pair with the same concatenation. `u64` rather than
284		// `usize`: the stamp should not change because the node is 32-bit.
285		hasher.update((content_type.len() as u64).to_le_bytes());
286		hasher.update(content_type.as_bytes());
287		hasher.update((search.len() as u64).to_le_bytes());
288		hasher.update(search.as_bytes());
289	}
290	let digest = hasher.finalize();
291	let mut out = String::with_capacity(16);
292	for b in &digest[..8] {
293		let _ = write!(&mut out, "{b:02x}");
294	}
295	out.into()
296}
297
298/// `"1.0.0"` → `1_000_000`. The inverse of the frontend's `decodeFormatVersion`
299/// and the exact encoding `encodeFormatVersion` produces: three decimal digits
300/// per component, `major * 1_000_000 + minor * 1_000 + patch`, each component
301/// `0..=999`.
302///
303/// `None` for anything else — a two-component version, a component out of range,
304/// anything non-numeric. The caller treats that as "no version stated", which the
305/// registration gate already handles.
306pub fn encode_format_version(s: &str) -> Option<i64> {
307	let mut parts = s.split('.');
308	let mut component = || -> Option<i64> {
309		let part = parts.next()?;
310		// `str::parse` accepts a leading `+`, which is not a version.
311		if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
312			return None;
313		}
314		part.parse::<i64>().ok().filter(|v| *v <= 999)
315	};
316	let major = component()?;
317	let minor = component()?;
318	let patch = component()?;
319	if parts.next().is_some() {
320		return None;
321	}
322	Some(major * 1_000_000 + minor * 1_000 + patch)
323}
324
325#[cfg(test)]
326#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
327mod tests {
328	use super::*;
329
330	/// Stands in for the server's `IndexRules::parse`: a `search` block is good
331	/// unless it says `{"bad": ...}`.
332	fn validate(v: &serde_json::Value) -> Result<(), String> {
333		if v.get("bad").is_some() { Err("bad rules".to_owned()) } else { Ok(()) }
334	}
335
336	#[test]
337	fn a_version_encodes_the_way_the_frontend_encodes_it() {
338		assert_eq!(encode_format_version("0.0.0"), Some(0));
339		assert_eq!(encode_format_version("1.0.0"), Some(1_000_000));
340		assert_eq!(encode_format_version("2.1.42"), Some(2_001_042));
341		// The upper bound `cloudillo_search::format::FORMAT_VERSION_MAX` accepts.
342		assert_eq!(encode_format_version("999.999.999"), Some(999_999_999));
343	}
344
345	#[test]
346	fn anything_the_encoding_cannot_represent_is_no_version_at_all() {
347		// A component over 999 would carry into the next one, so `1000.0.0` and
348		// `1.0.0` would encode identically — worse than having no ordering.
349		assert_eq!(encode_format_version("1000.0.0"), None);
350		assert_eq!(encode_format_version("1.1000.0"), None);
351		assert_eq!(encode_format_version("1.0"), None);
352		assert_eq!(encode_format_version("1.0.0.0"), None);
353		assert_eq!(encode_format_version("x"), None);
354		assert_eq!(encode_format_version(""), None);
355		assert_eq!(encode_format_version("1.0.+0"), None);
356		assert_eq!(encode_format_version("-1.0.0"), None);
357		assert_eq!(encode_format_version("1.0.0-beta"), None);
358	}
359
360	fn rules(title: &str) -> serde_json::Value {
361		serde_json::json!({ "v": 1, "parts": [{ "kind": "p", "title": [title] }] })
362	}
363
364	fn write_app(dist: &Path, id: &str, manifest: &serde_json::Value) {
365		let dir = dist.join("apps").join(id);
366		std::fs::create_dir_all(&dir).expect("create app dir");
367		std::fs::write(dir.join(APP_MANIFEST_FILE), manifest.to_string()).expect("write manifest");
368	}
369
370	fn app_manifest(id: &str, mime: &str, search: Option<&serde_json::Value>) -> serde_json::Value {
371		serde_json::json!({
372			"id": id,
373			"name": id,
374			"version": "1.2.3",
375			"kind": "bundled",
376			"icon": "file",
377			"contentTypes": [{
378				"mimeType": mime,
379				"storeTp": "RTDB",
380				"navParam": "nav",
381				"formatVersion": "1.0.0",
382				"search": search
383			}]
384		})
385	}
386
387	#[test]
388	fn a_missing_dist_tree_is_an_empty_registry_not_a_failure() {
389		// The dev default: `DIST_DIR` pointing at a path that does not exist. Every
390		// doc-format path then resolves from the tenant's own rows alone.
391		let reg = BundledAppRegistry::load(Path::new("/nonexistent/dist"), validate);
392		assert!(reg.is_empty());
393		assert_eq!(reg.app_count, 0);
394	}
395
396	#[test]
397	fn both_the_per_app_files_and_the_shell_aggregate_are_read() {
398		let dir = tempfile::tempdir().expect("tempdir");
399		let dist = dir.path();
400		write_app(
401			dist,
402			"notillo",
403			&app_manifest("notillo", "cloudillo/notillo", Some(&rules("ti"))),
404		);
405		// The shell's internal apps have no `dist/apps/<id>` of their own, so they
406		// arrive as one array.
407		std::fs::write(
408			dist.join(SHELL_MANIFESTS_FILE),
409			serde_json::json!([
410				app_manifest("files", "cloudillo/files", None),
411				app_manifest("feed", "cloudillo/feed", Some(&rules("fe"))),
412			])
413			.to_string(),
414		)
415		.expect("write shell manifests");
416
417		let reg = BundledAppRegistry::load(dist, validate);
418
419		assert_eq!(reg.len(), 3);
420		assert_eq!(reg.app_count, 3);
421		let notillo = reg.get("cloudillo/notillo").expect("notillo missing");
422		assert_eq!(&*notillo.app_name, "notillo");
423		assert_eq!(&*notillo.publisher_tag, DEFAULT_PUBLISHER_TAG);
424		assert_eq!(notillo.format_version, Some(1_000_000));
425		assert_eq!(notillo.nav_param.as_deref(), Some("nav"));
426		assert_eq!(notillo.store_tp.as_deref(), Some("RTDB"));
427		// A content type with no `search` still resolves — `storeTp` and `navParam`
428		// matter to the file and search handlers even when nothing is indexed.
429		assert!(reg.get("cloudillo/files").is_some());
430	}
431
432	#[test]
433	fn a_manifest_may_name_its_own_publisher() {
434		let dir = tempfile::tempdir().expect("tempdir");
435		let mut manifest = app_manifest("thirdillo", "x/thirdillo", None);
436		manifest["publisher"] = serde_json::json!("other.example");
437		write_app(dir.path(), "thirdillo", &manifest);
438
439		let reg = BundledAppRegistry::load(dir.path(), validate);
440
441		let fmt = reg.get("x/thirdillo").expect("thirdillo missing");
442		assert_eq!(&*fmt.publisher_tag, "other.example");
443	}
444
445	#[test]
446	fn a_primary_declaration_wins_a_contested_content_type() {
447		let dir = tempfile::tempdir().expect("tempdir");
448		let dist = dir.path();
449		// `aaa` sorts first and would win on order alone.
450		write_app(dist, "aaa", &app_manifest("aaa", "cloudillo/shared", Some(&rules("a"))));
451		let mut zzz = app_manifest("zzz", "cloudillo/shared", Some(&rules("z")));
452		zzz["contentTypes"][0]["priority"] = serde_json::json!("primary");
453		write_app(dist, "zzz", &zzz);
454
455		let reg = BundledAppRegistry::load(dist, validate);
456
457		assert_eq!(reg.len(), 1);
458		let fmt = reg.get("cloudillo/shared").expect("shared missing");
459		assert_eq!(&*fmt.app_name, "zzz");
460	}
461
462	#[test]
463	fn without_a_primary_the_first_app_in_sorted_order_keeps_it() {
464		// Deterministic across nodes and boots, which `read_dir` order is not.
465		let dir = tempfile::tempdir().expect("tempdir");
466		let dist = dir.path();
467		write_app(dist, "aaa", &app_manifest("aaa", "cloudillo/shared", Some(&rules("a"))));
468		write_app(dist, "zzz", &app_manifest("zzz", "cloudillo/shared", Some(&rules("z"))));
469
470		let reg = BundledAppRegistry::load(dist, validate);
471
472		let fmt = reg.get("cloudillo/shared").expect("shared missing");
473		assert_eq!(&*fmt.app_name, "aaa");
474	}
475
476	#[test]
477	fn an_invalid_search_block_costs_only_its_own_format() {
478		// A stale or hand-edited dist must not brick the node.
479		let dir = tempfile::tempdir().expect("tempdir");
480		let dist = dir.path();
481		write_app(
482			dist,
483			"broken",
484			&app_manifest("broken", "cloudillo/broken", Some(&serde_json::json!({ "bad": true }))),
485		);
486		write_app(
487			dist,
488			"notillo",
489			&app_manifest("notillo", "cloudillo/notillo", Some(&rules("ti"))),
490		);
491
492		let reg = BundledAppRegistry::load(dist, validate);
493
494		assert!(reg.get("cloudillo/broken").is_none());
495		assert!(reg.get("cloudillo/notillo").is_some());
496	}
497
498	#[test]
499	fn unparseable_json_costs_only_its_own_file() {
500		let dir = tempfile::tempdir().expect("tempdir");
501		let dist = dir.path();
502		std::fs::create_dir_all(dist.join("apps").join("junk")).expect("create junk dir");
503		std::fs::write(dist.join("apps").join("junk").join(APP_MANIFEST_FILE), "{ not json")
504			.expect("write junk");
505		write_app(
506			dist,
507			"notillo",
508			&app_manifest("notillo", "cloudillo/notillo", Some(&rules("ti"))),
509		);
510
511		let reg = BundledAppRegistry::load(dist, validate);
512
513		assert_eq!(reg.len(), 1);
514		assert!(reg.get("cloudillo/notillo").is_some());
515	}
516
517	#[test]
518	fn the_rules_hash_tracks_the_search_blocks_and_nothing_else() {
519		let mut a: HashMap<Box<str>, DocFormat> = HashMap::new();
520		let fmt = |ct: &str, nav: &str, search: serde_json::Value| DocFormat {
521			content_type: ct.into(),
522			publisher_tag: "cloudillo.org".into(),
523			app_name: "notillo".into(),
524			format_version: Some(1_000_000),
525			store_tp: Some("RTDB".into()),
526			nav_param: Some(nav.into()),
527			search: Some(search),
528			x: None,
529			updated_at: Timestamp(0),
530		};
531		a.insert("x/one".into(), fmt("x/one", "nav", rules("ti")));
532		a.insert("x/two".into(), fmt("x/two", "nav", rules("tj")));
533
534		// Same content, built in the other order: a `HashMap` iterates differently
535		// but the stamp must not, or every boot would re-index every tenant.
536		let mut b: HashMap<Box<str>, DocFormat> = HashMap::new();
537		b.insert("x/two".into(), fmt("x/two", "nav", rules("tj")));
538		b.insert("x/one".into(), fmt("x/one", "nav", rules("ti")));
539		assert_eq!(rules_hash(&a), rules_hash(&b));
540
541		// A cosmetic edit must not cost a reindex.
542		let mut nav_changed = a.clone();
543		nav_changed.insert("x/one".into(), fmt("x/one", "page", rules("ti")));
544		assert_eq!(rules_hash(&a), rules_hash(&nav_changed));
545
546		// A rules edit must.
547		let mut rules_changed = a.clone();
548		rules_changed.insert("x/one".into(), fmt("x/one", "nav", rules("changed")));
549		assert_ne!(rules_hash(&a), rules_hash(&rules_changed));
550	}
551}
552
553// vim: ts=4