Skip to main content

document_svg/document/
jsonfeed.rs

1//! Bounded JSON Feed 1.0/1.1 previews.
2//!
3//! JSON Feed items may contain HTML, Markdown, links, images, authors and
4//! attachments. This adapter renders item metadata and attachment counts only;
5//! content and URLs are never fetched, rendered as active markup, or followed.
6
7use serde_json::Value;
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::html::{HtmlBlock, render_blocks_to_pages};
12use crate::error::{Error, Result};
13use crate::table::{TableAlign, TableData};
14
15const MAX_JSONFEED_BYTES: u64 = 64 * 1024 * 1024;
16const MAX_JSONFEED_DEPTH: usize = 100;
17const MAX_JSONFEED_VALUES: usize = 300_000;
18const MAX_JSONFEED_ITEMS: usize = 100_000;
19const MAX_JSONFEED_ATTACHMENTS: usize = 100;
20const MAX_JSONFEED_STRING_BYTES: usize = 2 * 1024 * 1024;
21const MAX_JSONFEED_DISPLAY_BYTES: usize = 512;
22
23pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
24    let text = String::from_utf8_lossy(prefix);
25    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
26    trimmed.starts_with('{')
27        && text.contains("\"version\"")
28        && text.contains("jsonfeed.org/version/")
29        && text.contains("\"items\"")
30        && text.contains("\"title\"")
31}
32
33struct JsonFeedPageSink<'a> {
34    inner: &'a mut dyn PageConsumer,
35    warnings: &'a [String],
36}
37
38impl PageConsumer for JsonFeedPageSink<'_> {
39    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
40        page.source_format = "jsonfeed".into();
41        if page.title.is_empty() {
42            page.title = "JSON Feed".into();
43        }
44        page.description =
45            "JSON Feed item metadata is rendered inertly; content, URLs and attachments are not fetched or executed".into();
46        for warning in self.warnings {
47            page.warn(warning.clone());
48        }
49        self.inner.consume(page)
50    }
51}
52
53pub(crate) fn convert(
54    path: &Path,
55    options: &ConvertOptions,
56    sink: &mut dyn PageConsumer,
57) -> Result<Vec<String>> {
58    let bytes = read_limited_file(
59        path,
60        options.max_input_bytes.min(MAX_JSONFEED_BYTES),
61        "JSON Feed input",
62    )?;
63    let text = String::from_utf8(bytes)
64        .map_err(|error| Error::InvalidInput(format!("JSON Feed must be UTF-8 JSON: {error}")))?;
65    let (table, metadata, warnings) = parse(&text)?;
66    let blocks = vec![
67        HtmlBlock::Heading {
68            level: 1,
69            text: "JSON Feed".into(),
70        },
71        HtmlBlock::Paragraph { text: metadata },
72        HtmlBlock::Table(table),
73    ];
74    let mut page_sink = JsonFeedPageSink {
75        inner: sink,
76        warnings: &warnings,
77    };
78    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
79    Ok(warnings)
80}
81
82fn parse(text: &str) -> Result<(TableData, String, Vec<String>)> {
83    if text.len() as u64 > MAX_JSONFEED_BYTES {
84        return Err(Error::LimitExceeded(format!(
85            "JSON Feed exceeds {MAX_JSONFEED_BYTES} bytes"
86        )));
87    }
88    preflight_depth(text)?;
89    let value: Value = serde_json::from_str(text)
90        .map_err(|error| Error::InvalidInput(format!("invalid JSON Feed: {error}")))?;
91    let mut values = 0usize;
92    count_values(&value, 0, &mut values)?;
93    let root = value
94        .as_object()
95        .ok_or_else(|| Error::InvalidInput("JSON Feed root must be an object".into()))?;
96    let version = root
97        .get("version")
98        .and_then(Value::as_str)
99        .ok_or_else(|| Error::InvalidInput("JSON Feed requires a version URL".into()))?;
100    if !version.starts_with("https://jsonfeed.org/version/")
101        && !version.starts_with("http://jsonfeed.org/version/")
102    {
103        return Err(Error::Unsupported(format!(
104            "JSON Feed version URL '{version}' is unsupported"
105        )));
106    }
107    let title = root.get("title").and_then(Value::as_str).unwrap_or("—");
108    let items = root
109        .get("items")
110        .and_then(Value::as_array)
111        .ok_or_else(|| Error::InvalidInput("JSON Feed requires an items array".into()))?;
112    if items.len() > MAX_JSONFEED_ITEMS {
113        return Err(Error::LimitExceeded(format!(
114            "JSON Feed items exceed {MAX_JSONFEED_ITEMS}"
115        )));
116    }
117    let mut rows = Vec::new();
118    let mut warnings = Vec::new();
119    let mut content_count = 0usize;
120    let mut link_count = 0usize;
121    for (index, item) in items.iter().enumerate() {
122        let object = item.as_object().ok_or_else(|| {
123            Error::InvalidInput(format!("JSON Feed item {} must be an object", index + 1))
124        })?;
125        let id = object.get("id").and_then(Value::as_str).ok_or_else(|| {
126            Error::InvalidInput(format!("JSON Feed item {} requires id", index + 1))
127        })?;
128        let item_title = object
129            .get("title")
130            .and_then(Value::as_str)
131            .unwrap_or("(untitled)");
132        let date = object
133            .get("date_published")
134            .or_else(|| object.get("date_modified"))
135            .and_then(Value::as_str)
136            .map_or_else(
137                || "—".into(),
138                |value| truncate(&value.chars().take(10).collect::<String>()),
139            );
140        let item_type = if object.get("content_html").is_some() {
141            content_count = content_count.saturating_add(1);
142            "html"
143        } else if object.get("content_text").is_some() {
144            content_count = content_count.saturating_add(1);
145            "text"
146        } else {
147            "—"
148        };
149        if object.get("url").is_some() || object.get("external_url").is_some() {
150            link_count = link_count.saturating_add(1);
151        }
152        let attachments = object
153            .get("attachments")
154            .and_then(Value::as_array)
155            .map_or(0, Vec::len);
156        if attachments > MAX_JSONFEED_ATTACHMENTS {
157            return Err(Error::LimitExceeded(format!(
158                "JSON Feed item {} attachments exceed {MAX_JSONFEED_ATTACHMENTS}",
159                index + 1
160            )));
161        }
162        let author = object
163            .get("author")
164            .and_then(Value::as_object)
165            .and_then(|author| author.get("name"))
166            .and_then(Value::as_str)
167            .map_or_else(|| "—".into(), truncate);
168        rows.push(vec![
169            truncate(id),
170            truncate(item_title),
171            item_type.into(),
172            date,
173            author,
174            attachments.to_string(),
175        ]);
176    }
177    if rows.is_empty() {
178        rows.push(vec![
179            "—".into(),
180            "(no items)".into(),
181            "—".into(),
182            "—".into(),
183            "—".into(),
184            "0".into(),
185        ]);
186    }
187    let metadata = format!(
188        "Title: {}\nVersion: {}\nItems: {}\nWith content: {content_count}\nWith links: {link_count}",
189        truncate(title),
190        truncate(version),
191        items.len()
192    );
193    warnings.push("JSON Feed content_text/content_html, summaries, tags, URLs, images and attachment URLs are omitted; no markup, link, feed or network operation is executed".into());
194    warnings.push("JSON Feed item identifiers and dates are shown as metadata without fetching or resolving linked content".into());
195    Ok((
196        TableData {
197            headers: vec![
198                "ID".into(),
199                "Title".into(),
200                "Content".into(),
201                "Date".into(),
202                "Author".into(),
203                "Att".into(),
204            ],
205            rows,
206            alignments: vec![TableAlign::Left; 6],
207            raw_source: String::new(),
208        },
209        metadata,
210        warnings,
211    ))
212}
213
214fn truncate(value: &str) -> String {
215    if value.len() <= MAX_JSONFEED_DISPLAY_BYTES {
216        return value.to_owned();
217    }
218    let mut end = MAX_JSONFEED_DISPLAY_BYTES;
219    while !value.is_char_boundary(end) {
220        end -= 1;
221    }
222    format!("{}…", &value[..end])
223}
224
225fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
226    if depth > MAX_JSONFEED_DEPTH {
227        return Err(Error::LimitExceeded(format!(
228            "JSON Feed nesting exceeds {MAX_JSONFEED_DEPTH} levels"
229        )));
230    }
231    *count = count.saturating_add(1);
232    if *count > MAX_JSONFEED_VALUES {
233        return Err(Error::LimitExceeded(format!(
234            "JSON Feed contains more than {MAX_JSONFEED_VALUES} values"
235        )));
236    }
237    match value {
238        Value::Array(values) => {
239            for item in values {
240                count_values(item, depth + 1, count)?;
241            }
242        }
243        Value::Object(map) => {
244            for item in map.values() {
245                count_values(item, depth + 1, count)?;
246            }
247        }
248        Value::String(value) if value.len() > MAX_JSONFEED_STRING_BYTES => {
249            return Err(Error::LimitExceeded(format!(
250                "JSON Feed string exceeds {MAX_JSONFEED_STRING_BYTES} bytes"
251            )));
252        }
253        _ => {}
254    }
255    Ok(())
256}
257
258fn preflight_depth(text: &str) -> Result<()> {
259    let mut depth = 0usize;
260    let mut quoted = false;
261    let mut escaped = false;
262    for byte in text.bytes() {
263        if quoted {
264            if escaped {
265                escaped = false;
266            } else if byte == b'\\' {
267                escaped = true;
268            } else if byte == b'"' {
269                quoted = false;
270            }
271            continue;
272        }
273        match byte {
274            b'"' => quoted = true,
275            b'{' | b'[' => {
276                depth += 1;
277                if depth > MAX_JSONFEED_DEPTH {
278                    return Err(Error::LimitExceeded(format!(
279                        "JSON Feed nesting exceeds {MAX_JSONFEED_DEPTH} levels"
280                    )));
281                }
282            }
283            b'}' | b']' => depth = depth.saturating_sub(1),
284            _ => {}
285        }
286    }
287    Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn recognizes_json_feed() {
296        assert!(looks_like_prefix(
297            br#"{"version":"https://jsonfeed.org/version/1.1","title":"Feed","items":[]}"#
298        ));
299        assert!(!looks_like_prefix(
300            b"{\"version\":\"1\",\"title\":\"x\",\"items\":[]}"
301        ));
302    }
303
304    #[test]
305    fn summarizes_items_without_content_or_url_payloads() {
306        let (table, metadata, warnings) = parse(
307            r#"{"version":"https://jsonfeed.org/version/1.1","title":"Updates","items":[{"id":"1","title":"Hello","content_html":"<script>very-secret</script>","url":"https://private.example/?token=secret","date_published":"2024-05-12T00:00:00Z","author":{"name":"Alice"},"attachments":[{"url":"https://private.example/file"}]}]}"#,
308        )
309        .unwrap();
310        assert!(metadata.contains("Items: 1"));
311        assert_eq!(table.rows[0][2], "html");
312        assert_eq!(table.rows[0][3], "2024-05-12");
313        assert!(
314            !table
315                .rows
316                .iter()
317                .flatten()
318                .any(|value| value.contains("secret") || value.contains("private"))
319        );
320        assert!(warnings.iter().any(|warning| warning.contains("omitted")));
321    }
322}