rs_web/markdown/
encrypted_blocks.rs

1use once_cell::sync::Lazy;
2use regex::Regex;
3
4/// Represents an extracted encrypted block
5#[derive(Debug, Clone)]
6pub struct EncryptedBlock {
7    /// The markdown content inside the block
8    pub content: String,
9    /// Unique identifier for this block
10    pub id: usize,
11    /// Optional per-block password (overrides global)
12    pub password: Option<String>,
13}
14
15/// Result of pre-processing markdown for encrypted blocks
16#[derive(Debug)]
17pub struct PreprocessResult {
18    /// The markdown with encrypted blocks replaced by placeholders
19    pub markdown: String,
20    /// The extracted encrypted blocks
21    pub blocks: Vec<EncryptedBlock>,
22}
23
24/// Regex for matching :::encrypted blocks with optional password attribute (Markdown)
25static ENCRYPTED_BLOCK_RE: Lazy<Regex> = Lazy::new(|| {
26    Regex::new(r#"(?ms)^:::encrypted(?:\s+password="([^"]*)")?\s*\n(.*?)\n:::(?:\s*$|\n)"#)
27        .expect("Invalid encrypted block regex pattern")
28});
29
30/// Regex for matching <encrypted>...</encrypted> blocks (HTML)
31static HTML_ENCRYPTED_BLOCK_RE: Lazy<Regex> = Lazy::new(|| {
32    Regex::new(r#"(?ms)<encrypted(?:\s+password="([^"]*)")?>(.*?)</encrypted>"#)
33        .expect("Invalid HTML encrypted block regex pattern")
34});
35
36/// Extract `:::encrypted ... :::` blocks from markdown
37/// Supports optional password attribute: `:::encrypted password="secret"`
38/// Returns the modified markdown with placeholders and the extracted blocks
39pub fn extract_encrypted_blocks(content: &str) -> PreprocessResult {
40    let mut blocks = Vec::new();
41    let mut block_id = 0;
42
43    let markdown = ENCRYPTED_BLOCK_RE
44        .replace_all(content, |caps: &regex::Captures| {
45            let password = caps.get(1).map(|m| m.as_str().to_string());
46            let block_content = caps.get(2).map_or("", |m| m.as_str()).to_string();
47            let placeholder = format!("<!-- ENCRYPTED_BLOCK_{} -->", block_id);
48
49            blocks.push(EncryptedBlock {
50                content: block_content,
51                id: block_id,
52                password,
53            });
54
55            block_id += 1;
56            placeholder
57        })
58        .to_string();
59
60    PreprocessResult { markdown, blocks }
61}
62
63/// Extract `<encrypted>...</encrypted>` blocks from HTML content
64/// Supports optional password attribute: `<encrypted password="secret">`
65/// Returns the modified HTML with placeholders and the extracted blocks
66/// Note: This should be called AFTER Tera rendering so the content inside can use Tera
67pub fn extract_html_encrypted_blocks(content: &str) -> PreprocessResult {
68    let mut blocks = Vec::new();
69    let mut block_id = 0;
70
71    let html = HTML_ENCRYPTED_BLOCK_RE
72        .replace_all(content, |caps: &regex::Captures| {
73            let password = caps.get(1).map(|m| m.as_str().to_string());
74            let block_content = caps.get(2).map_or("", |m| m.as_str()).to_string();
75            let placeholder = format!("<!-- ENCRYPTED_BLOCK_{} -->", block_id);
76
77            blocks.push(EncryptedBlock {
78                content: block_content,
79                id: block_id,
80                password,
81            });
82
83            block_id += 1;
84            placeholder
85        })
86        .to_string();
87
88    PreprocessResult {
89        markdown: html,
90        blocks,
91    }
92}
93
94/// Replace placeholders in HTML with encrypted content divs
95/// encrypted_htmls: Vec of (id, ciphertext, salt, nonce, has_own_password)
96pub fn replace_placeholders(
97    html: &str,
98    encrypted_htmls: &[(usize, String, String, String, bool)],
99    slug: &str,
100) -> String {
101    let mut result = html.to_string();
102
103    for (id, ciphertext, salt, nonce, has_own_password) in encrypted_htmls {
104        let placeholder = format!("<!-- ENCRYPTED_BLOCK_{} -->", id);
105        let own_password_attr = if *has_own_password {
106            "\n     data-own-password=\"true\""
107        } else {
108            ""
109        };
110        let replacement = format!(
111            r#"<div class="encrypted-content encrypted-block"
112     data-encrypted="{}"
113     data-salt="{}"
114     data-nonce="{}"
115     data-slug="{}"
116     data-block-id="{}"{}>
117    <div class="decrypt-prompt">
118        <p class="encrypted-message">This section is encrypted.</p>
119        <input type="password" placeholder="Enter password..." aria-label="Password">
120        <label class="remember-label">
121            <input type="checkbox" class="remember">
122            Remember for this post
123        </label>
124        <button type="button">Decrypt</button>
125    </div>
126</div>"#,
127            ciphertext, salt, nonce, slug, id, own_password_attr
128        );
129        result = result.replace(&placeholder, &replacement);
130    }
131
132    result
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_extract_single_block() {
141        let content = r#"# Hello
142
143Some public content.
144
145:::encrypted
146This is secret content.
147It has multiple lines.
148:::
149
150More public content.
151"#;
152
153        let result = extract_encrypted_blocks(content);
154
155        assert_eq!(result.blocks.len(), 1);
156        assert_eq!(result.blocks[0].id, 0);
157        assert!(result.blocks[0].content.contains("This is secret content."));
158        assert!(result.blocks[0].password.is_none());
159        assert!(result.markdown.contains("<!-- ENCRYPTED_BLOCK_0 -->"));
160        assert!(!result.markdown.contains(":::encrypted"));
161    }
162
163    #[test]
164    fn test_extract_multiple_blocks() {
165        let content = r#"# Hello
166
167:::encrypted
168First secret.
169:::
170
171Public part.
172
173:::encrypted
174Second secret.
175:::
176
177End.
178"#;
179
180        let result = extract_encrypted_blocks(content);
181
182        assert_eq!(result.blocks.len(), 2);
183        assert_eq!(result.blocks[0].id, 0);
184        assert_eq!(result.blocks[1].id, 1);
185        assert!(result.blocks[0].content.contains("First secret."));
186        assert!(result.blocks[1].content.contains("Second secret."));
187        assert!(result.markdown.contains("<!-- ENCRYPTED_BLOCK_0 -->"));
188        assert!(result.markdown.contains("<!-- ENCRYPTED_BLOCK_1 -->"));
189    }
190
191    #[test]
192    fn test_extract_block_with_password() {
193        let content = r#"# Hello
194
195:::encrypted password="secret123"
196This is secret content.
197:::
198
199More content.
200"#;
201
202        let result = extract_encrypted_blocks(content);
203
204        assert_eq!(result.blocks.len(), 1);
205        assert_eq!(result.blocks[0].password, Some("secret123".to_string()));
206        assert!(result.blocks[0].content.contains("This is secret content."));
207    }
208
209    #[test]
210    fn test_extract_blocks_with_different_passwords() {
211        let content = r#"# Hello
212
213:::encrypted password="pass1"
214First secret.
215:::
216
217:::encrypted password="pass2"
218Second secret.
219:::
220
221:::encrypted
222Third secret (no password).
223:::
224"#;
225
226        let result = extract_encrypted_blocks(content);
227
228        assert_eq!(result.blocks.len(), 3);
229        assert_eq!(result.blocks[0].password, Some("pass1".to_string()));
230        assert_eq!(result.blocks[1].password, Some("pass2".to_string()));
231        assert_eq!(result.blocks[2].password, None);
232    }
233
234    #[test]
235    fn test_no_encrypted_blocks() {
236        let content = "# Hello\n\nJust regular content.\n";
237
238        let result = extract_encrypted_blocks(content);
239
240        assert!(result.blocks.is_empty());
241        assert_eq!(result.markdown, content);
242    }
243
244    #[test]
245    fn test_replace_placeholders() {
246        let html = "<p>Hello</p>\n<!-- ENCRYPTED_BLOCK_0 -->\n<p>World</p>";
247        let encrypted = vec![(
248            0,
249            "cipher".to_string(),
250            "salt".to_string(),
251            "nonce".to_string(),
252            false,
253        )];
254
255        let result = replace_placeholders(html, &encrypted, "test-post");
256
257        assert!(result.contains("data-encrypted=\"cipher\""));
258        assert!(result.contains("data-salt=\"salt\""));
259        assert!(result.contains("data-nonce=\"nonce\""));
260        assert!(result.contains("data-slug=\"test-post\""));
261        assert!(result.contains("data-block-id=\"0\""));
262        assert!(!result.contains("data-own-password"));
263        assert!(!result.contains("<!-- ENCRYPTED_BLOCK_0 -->"));
264    }
265
266    #[test]
267    fn test_replace_placeholders_with_own_password() {
268        let html = "<!-- ENCRYPTED_BLOCK_0 -->";
269        let encrypted = vec![(
270            0,
271            "cipher".to_string(),
272            "salt".to_string(),
273            "nonce".to_string(),
274            true,
275        )];
276
277        let result = replace_placeholders(html, &encrypted, "test-post");
278
279        assert!(result.contains("data-own-password=\"true\""));
280    }
281
282    #[test]
283    fn test_html_extract_single_block() {
284        let content = r#"<h1>Hello</h1>
285<p>Some public content.</p>
286<encrypted>
287This is secret content.
288It has multiple lines.
289</encrypted>
290<p>More public content.</p>"#;
291
292        let result = extract_html_encrypted_blocks(content);
293
294        assert_eq!(result.blocks.len(), 1);
295        assert_eq!(result.blocks[0].id, 0);
296        assert!(result.blocks[0].content.contains("This is secret content."));
297        assert!(result.blocks[0].password.is_none());
298        assert!(result.markdown.contains("<!-- ENCRYPTED_BLOCK_0 -->"));
299        assert!(!result.markdown.contains("<encrypted>"));
300    }
301
302    #[test]
303    fn test_html_extract_multiple_blocks() {
304        let content = r#"<h1>Hello</h1>
305<encrypted>First secret.</encrypted>
306<p>Public part.</p>
307<encrypted>Second secret.</encrypted>
308<p>End.</p>"#;
309
310        let result = extract_html_encrypted_blocks(content);
311
312        assert_eq!(result.blocks.len(), 2);
313        assert_eq!(result.blocks[0].id, 0);
314        assert_eq!(result.blocks[1].id, 1);
315        assert!(result.blocks[0].content.contains("First secret."));
316        assert!(result.blocks[1].content.contains("Second secret."));
317        assert!(result.markdown.contains("<!-- ENCRYPTED_BLOCK_0 -->"));
318        assert!(result.markdown.contains("<!-- ENCRYPTED_BLOCK_1 -->"));
319    }
320
321    #[test]
322    fn test_html_extract_block_with_password() {
323        let content = r#"<h1>Hello</h1>
324<encrypted password="secret123">This is secret content.</encrypted>
325<p>More content.</p>"#;
326
327        let result = extract_html_encrypted_blocks(content);
328
329        assert_eq!(result.blocks.len(), 1);
330        assert_eq!(result.blocks[0].password, Some("secret123".to_string()));
331        assert!(result.blocks[0].content.contains("This is secret content."));
332    }
333
334    #[test]
335    fn test_html_extract_blocks_with_different_passwords() {
336        let content = r#"<encrypted password="pass1">First secret.</encrypted>
337<encrypted password="pass2">Second secret.</encrypted>
338<encrypted>Third secret (no password).</encrypted>"#;
339
340        let result = extract_html_encrypted_blocks(content);
341
342        assert_eq!(result.blocks.len(), 3);
343        assert_eq!(result.blocks[0].password, Some("pass1".to_string()));
344        assert_eq!(result.blocks[1].password, Some("pass2".to_string()));
345        assert_eq!(result.blocks[2].password, None);
346    }
347
348    #[test]
349    fn test_html_no_encrypted_blocks() {
350        let content = "<h1>Hello</h1>\n<p>Just regular content.</p>\n";
351
352        let result = extract_html_encrypted_blocks(content);
353
354        assert!(result.blocks.is_empty());
355        assert_eq!(result.markdown, content);
356    }
357}