1use 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}