Skip to main content

pebble_cms/web/
state.rs

1use crate::services::analytics::Analytics;
2use crate::services::markdown::MarkdownRenderer;
3use crate::web::security::{CsrfManager, RateLimiter};
4use crate::{Config, Database};
5use anyhow::Result;
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::{Arc, RwLock};
9use tera::{Tera, Value};
10
11pub struct AppState {
12    pub config: RwLock<Config>,
13    pub config_path: PathBuf,
14    pub db: Database,
15    pub templates: Tera,
16    pub markdown: MarkdownRenderer,
17    pub media_dir: PathBuf,
18    pub production_mode: bool,
19    pub csrf: Arc<CsrfManager>,
20    pub rate_limiter: Arc<RateLimiter>,
21    pub upload_rate_limiter: Arc<RateLimiter>,
22    /// Rate limiter for all write endpoints (content create/update/delete, settings, tags, users).
23    /// 30 write operations per 60 seconds, 5-minute lockout.
24    pub write_rate_limiter: Arc<RateLimiter>,
25    pub analytics: Option<Arc<Analytics>>,
26    pub static_assets: HashMap<String, &'static str>,
27}
28
29impl AppState {
30    pub fn new(
31        config: Config,
32        config_path: PathBuf,
33        db: Database,
34        production_mode: bool,
35    ) -> Result<Self> {
36        let mut templates = Tera::default();
37
38        templates.register_filter("format_date", format_date_filter);
39        templates.register_filter("truncate_str", truncate_str_filter);
40        templates.register_filter("str_slice", str_slice_filter);
41        templates.register_filter("strip_md", strip_markdown_filter);
42        templates.register_filter("filesizeformat", filesizeformat_filter);
43        templates.add_raw_templates(vec![
44            (
45                "css/bundle.css",
46                include_str!("../../templates/css/bundle.css"),
47            ),
48            (
49                "css/bundle-admin.css",
50                include_str!("../../templates/css/bundle-admin.css"),
51            ),
52            ("base.html", include_str!("../../templates/base.html")),
53            (
54                "admin/base.html",
55                include_str!("../../templates/admin/base.html"),
56            ),
57            (
58                "admin/login.html",
59                include_str!("../../templates/admin/login.html"),
60            ),
61            (
62                "admin/setup.html",
63                include_str!("../../templates/admin/setup.html"),
64            ),
65            (
66                "admin/dashboard.html",
67                include_str!("../../templates/admin/dashboard.html"),
68            ),
69            (
70                "admin/posts/index.html",
71                include_str!("../../templates/admin/posts/index.html"),
72            ),
73            (
74                "admin/posts/form.html",
75                include_str!("../../templates/admin/posts/form.html"),
76            ),
77            (
78                "admin/pages/index.html",
79                include_str!("../../templates/admin/pages/index.html"),
80            ),
81            (
82                "admin/pages/form.html",
83                include_str!("../../templates/admin/pages/form.html"),
84            ),
85            (
86                "admin/media/index.html",
87                include_str!("../../templates/admin/media/index.html"),
88            ),
89            (
90                "admin/tags/index.html",
91                include_str!("../../templates/admin/tags/index.html"),
92            ),
93            (
94                "admin/settings/index.html",
95                include_str!("../../templates/admin/settings/index.html"),
96            ),
97            (
98                "admin/users/index.html",
99                include_str!("../../templates/admin/users/index.html"),
100            ),
101            (
102                "public/index.html",
103                include_str!("../../templates/public/index.html"),
104            ),
105            (
106                "public/posts.html",
107                include_str!("../../templates/public/posts.html"),
108            ),
109            (
110                "public/post.html",
111                include_str!("../../templates/public/post.html"),
112            ),
113            (
114                "public/page.html",
115                include_str!("../../templates/public/page.html"),
116            ),
117            (
118                "public/tag.html",
119                include_str!("../../templates/public/tag.html"),
120            ),
121            (
122                "public/tags.html",
123                include_str!("../../templates/public/tags.html"),
124            ),
125            (
126                "public/search.html",
127                include_str!("../../templates/public/search.html"),
128            ),
129            (
130                "public/404.html",
131                include_str!("../../templates/public/404.html"),
132            ),
133            (
134                "public/500.html",
135                include_str!("../../templates/public/500.html"),
136            ),
137            (
138                "htmx/preview.html",
139                include_str!("../../templates/htmx/preview.html"),
140            ),
141            (
142                "htmx/flash.html",
143                include_str!("../../templates/htmx/flash.html"),
144            ),
145            (
146                "htmx/analytics_realtime.html",
147                include_str!("../../templates/htmx/analytics_realtime.html"),
148            ),
149            (
150                "htmx/analytics_content.html",
151                include_str!("../../templates/htmx/analytics_content.html"),
152            ),
153            (
154                "admin/analytics/index.html",
155                include_str!("../../templates/admin/analytics/index.html"),
156            ),
157            (
158                "admin/database/index.html",
159                include_str!("../../templates/admin/database/index.html"),
160            ),
161            (
162                "admin/versions/history.html",
163                include_str!("../../templates/admin/versions/history.html"),
164            ),
165            (
166                "admin/versions/view.html",
167                include_str!("../../templates/admin/versions/view.html"),
168            ),
169            (
170                "admin/versions/diff.html",
171                include_str!("../../templates/admin/versions/diff.html"),
172            ),
173            (
174                "admin/audit/index.html",
175                include_str!("../../templates/admin/audit/index.html"),
176            ),
177            (
178                "admin/audit/view.html",
179                include_str!("../../templates/admin/audit/view.html"),
180            ),
181            (
182                "admin/series/index.html",
183                include_str!("../../templates/admin/series/index.html"),
184            ),
185            (
186                "admin/series/form.html",
187                include_str!("../../templates/admin/series/form.html"),
188            ),
189            (
190                "admin/snippets/index.html",
191                include_str!("../../templates/admin/snippets/index.html"),
192            ),
193            (
194                "admin/snippets/form.html",
195                include_str!("../../templates/admin/snippets/form.html"),
196            ),
197            (
198                "public/series.html",
199                include_str!("../../templates/public/series.html"),
200            ),
201            (
202                "admin/tokens/index.html",
203                include_str!("../../templates/admin/tokens/index.html"),
204            ),
205            (
206                "admin/webhooks/index.html",
207                include_str!("../../templates/admin/webhooks/index.html"),
208            ),
209            (
210                "admin/webhooks/deliveries.html",
211                include_str!("../../templates/admin/webhooks/deliveries.html"),
212            ),
213        ])?;
214
215        let media_dir = PathBuf::from(&config.media.upload_dir);
216
217        let mut static_assets = HashMap::new();
218        static_assets.insert(
219            "theme.js".to_string(),
220            include_str!("../../templates/js/theme.js"),
221        );
222        static_assets.insert(
223            "admin.js".to_string(),
224            include_str!("../../templates/js/admin.js"),
225        );
226        static_assets.insert(
227            "htmx.min.js".to_string(),
228            include_str!("../../templates/js/htmx.min.js"),
229        );
230
231        Ok(Self {
232            config: RwLock::new(config),
233            config_path,
234            db,
235            templates,
236            markdown: MarkdownRenderer::new(),
237            media_dir,
238            production_mode,
239            csrf: Arc::new(CsrfManager::default()),
240            rate_limiter: Arc::new(RateLimiter::default()),
241            upload_rate_limiter: Arc::new(RateLimiter::new(
242                20,
243                std::time::Duration::from_secs(60),
244                std::time::Duration::from_secs(300),
245            )),
246            write_rate_limiter: Arc::new(RateLimiter::new(
247                30,
248                std::time::Duration::from_secs(60),
249                std::time::Duration::from_secs(300),
250            )),
251            analytics: None,
252            static_assets,
253        })
254    }
255
256    /// Get a read lock on the config
257    pub fn config(&self) -> std::sync::RwLockReadGuard<'_, Config> {
258        self.config.read().unwrap_or_else(|e| e.into_inner())
259    }
260
261    /// Update the config (writes to file and updates in-memory)
262    pub fn update_config(&self, new_config: Config) -> Result<()> {
263        // Validate new config
264        new_config.validate()?;
265
266        // Write to file using toml_edit to preserve formatting
267        let content = std::fs::read_to_string(&self.config_path)?;
268        let mut doc = content.parse::<toml_edit::DocumentMut>()?;
269
270        // Update all fields
271        doc["site"]["title"] = toml_edit::value(&new_config.site.title);
272        doc["site"]["description"] = toml_edit::value(&new_config.site.description);
273        doc["site"]["url"] = toml_edit::value(&new_config.site.url);
274        doc["site"]["language"] = toml_edit::value(&new_config.site.language);
275
276        doc["content"]["posts_per_page"] =
277            toml_edit::value(new_config.content.posts_per_page as i64);
278        doc["content"]["excerpt_length"] =
279            toml_edit::value(new_config.content.excerpt_length as i64);
280        doc["content"]["auto_excerpt"] = toml_edit::value(new_config.content.auto_excerpt);
281
282        doc["theme"]["name"] = toml_edit::value(&new_config.theme.name);
283
284        // Handle theme.custom
285        if !doc["theme"]
286            .as_table()
287            .map_or(false, |t| t.contains_key("custom"))
288        {
289            doc["theme"]["custom"] = toml_edit::Item::Table(toml_edit::Table::new());
290        }
291        if let Some(ref v) = new_config.theme.custom.primary_color {
292            doc["theme"]["custom"]["primary_color"] = toml_edit::value(v);
293        }
294        if let Some(ref v) = new_config.theme.custom.accent_color {
295            doc["theme"]["custom"]["accent_color"] = toml_edit::value(v);
296        }
297        if let Some(ref v) = new_config.theme.custom.background_color {
298            doc["theme"]["custom"]["background_color"] = toml_edit::value(v);
299        }
300        if let Some(ref v) = new_config.theme.custom.text_color {
301            doc["theme"]["custom"]["text_color"] = toml_edit::value(v);
302        }
303
304        // Handle homepage section
305        if !doc.contains_key("homepage") {
306            doc["homepage"] = toml_edit::Item::Table(toml_edit::Table::new());
307        }
308        doc["homepage"]["show_hero"] = toml_edit::value(new_config.homepage.show_hero);
309        doc["homepage"]["hero_layout"] = toml_edit::value(&new_config.homepage.hero_layout);
310        doc["homepage"]["hero_height"] = toml_edit::value(&new_config.homepage.hero_height);
311        doc["homepage"]["hero_text_align"] = toml_edit::value(&new_config.homepage.hero_text_align);
312        doc["homepage"]["show_posts"] = toml_edit::value(new_config.homepage.show_posts);
313        doc["homepage"]["posts_layout"] = toml_edit::value(&new_config.homepage.posts_layout);
314        doc["homepage"]["posts_columns"] =
315            toml_edit::value(new_config.homepage.posts_columns as i64);
316        doc["homepage"]["show_pages"] = toml_edit::value(new_config.homepage.show_pages);
317        doc["homepage"]["pages_layout"] = toml_edit::value(&new_config.homepage.pages_layout);
318
319        std::fs::write(&self.config_path, doc.to_string())?;
320
321        // Update in-memory config
322        let mut config = self.config.write().unwrap_or_else(|e| e.into_inner());
323        *config = new_config;
324
325        Ok(())
326    }
327
328    pub fn with_analytics(mut self, analytics: Arc<Analytics>) -> Self {
329        self.analytics = Some(analytics);
330        self
331    }
332}
333
334fn format_date_filter(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
335    let date_str = value
336        .as_str()
337        .ok_or_else(|| tera::Error::msg("format_date requires a string"))?;
338
339    let format = args
340        .get("format")
341        .and_then(|v| v.as_str())
342        .unwrap_or("%B %d, %Y");
343
344    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date_str) {
345        return Ok(Value::String(dt.format(format).to_string()));
346    }
347
348    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%dT%H:%M:%S%.f") {
349        return Ok(Value::String(dt.format(format).to_string()));
350    }
351
352    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S") {
353        return Ok(Value::String(dt.format(format).to_string()));
354    }
355
356    Ok(Value::String(date_str.to_string()))
357}
358
359fn truncate_str_filter(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
360    let s = value
361        .as_str()
362        .ok_or_else(|| tera::Error::msg("truncate_str requires a string"))?;
363    let len = args.get("len").and_then(|v| v.as_u64()).unwrap_or(16) as usize;
364    let char_count = s.chars().count();
365    if char_count > len {
366        Ok(Value::String(s.chars().take(len).collect()))
367    } else {
368        Ok(Value::String(s.to_string()))
369    }
370}
371
372fn str_slice_filter(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
373    let s = value
374        .as_str()
375        .ok_or_else(|| tera::Error::msg("str_slice requires a string"))?;
376    let start = args.get("start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
377    let char_count = s.chars().count();
378    let end = args
379        .get("end")
380        .and_then(|v| v.as_u64())
381        .map(|v| v as usize)
382        .unwrap_or(char_count);
383    let start = start.min(char_count);
384    let end = end.min(char_count);
385    Ok(Value::String(
386        s.chars()
387            .skip(start)
388            .take(end.saturating_sub(start))
389            .collect(),
390    ))
391}
392
393fn strip_markdown_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
394    let text = value
395        .as_str()
396        .ok_or_else(|| tera::Error::msg("strip_md requires a string"))?;
397
398    let mut result = text.to_string();
399
400    // Remove inline code using char-safe iteration
401    loop {
402        let chars: Vec<char> = result.chars().collect();
403        if let Some(start) = chars.iter().position(|&c| c == '`') {
404            if let Some(rel_end) = chars[start + 1..].iter().position(|&c| c == '`') {
405                let end = start + 1 + rel_end;
406                let code_content: String = chars[start + 1..end].iter().collect();
407                let before: String = chars[..start].iter().collect();
408                let after: String = chars[end + 1..].iter().collect();
409                result = format!("{}{}{}", before, code_content, after);
410            } else {
411                break;
412            }
413        } else {
414            break;
415        }
416    }
417
418    // Remove images ![alt](url)
419    while let Some(img_start) = result.find("![") {
420        if let Some(bracket_end) = result[img_start + 2..].find("](") {
421            let abs_bracket_end = img_start + 2 + bracket_end;
422            if let Some(paren_end) = result[abs_bracket_end + 2..].find(')') {
423                let before = &result[..img_start];
424                let after = &result[abs_bracket_end + 3 + paren_end..];
425                result = format!("{}{}", before, after);
426            } else {
427                break;
428            }
429        } else {
430            break;
431        }
432    }
433
434    // Remove links [text](url) -> text
435    while let Some(bracket_start) = result.find('[') {
436        if let Some(bracket_end) = result[bracket_start..].find("](") {
437            let abs_bracket_end = bracket_start + bracket_end;
438            if let Some(paren_end) = result[abs_bracket_end + 2..].find(')') {
439                let link_text = &result[bracket_start + 1..abs_bracket_end];
440                let before = &result[..bracket_start];
441                let after = &result[abs_bracket_end + 3 + paren_end..];
442                result = format!("{}{}{}", before, link_text, after);
443            } else {
444                break;
445            }
446        } else {
447            break;
448        }
449    }
450
451    // Remove bold/italic markers
452    result = result.replace("***", "");
453    result = result.replace("**", "");
454    result = result.replace("__", "");
455    result = result.replace('*', "");
456    result = result.replace('_', " ");
457
458    // Remove list markers and table syntax
459    result = result
460        .lines()
461        .filter(|line| {
462            let trimmed = line.trim();
463            // Skip table separator rows (e.g., |---|---|)
464            if trimmed.starts_with('|') && trimmed.contains("---") {
465                return false;
466            }
467            true
468        })
469        .map(|line| {
470            let trimmed = line.trim_start();
471            if trimmed.starts_with("- ") {
472                trimmed.chars().skip(2).collect()
473            } else if trimmed.starts_with("> ") {
474                trimmed.chars().skip(2).collect()
475            } else if trimmed.starts_with('|') && trimmed.ends_with('|') {
476                // Strip table row: extract cell contents
477                trimmed[1..trimmed.len() - 1]
478                    .split('|')
479                    .map(|cell| cell.trim())
480                    .filter(|cell| !cell.is_empty())
481                    .collect::<Vec<_>>()
482                    .join(" ")
483            } else if !trimmed.is_empty() {
484                if let Some(first_char) = trimmed.chars().next() {
485                    if first_char.is_ascii_digit() {
486                        if let Some(dot_pos) = trimmed.find(". ") {
487                            return trimmed.chars().skip(dot_pos + 2).collect();
488                        }
489                    }
490                }
491                line.to_string()
492            } else {
493                line.to_string()
494            }
495        })
496        .collect::<Vec<_>>()
497        .join(" ");
498
499    // Clean up multiple spaces
500    while result.contains("  ") {
501        result = result.replace("  ", " ");
502    }
503
504    Ok(Value::String(result.trim().to_string()))
505}
506
507fn filesizeformat_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
508    let bytes = value
509        .as_i64()
510        .or_else(|| value.as_u64().map(|v| v as i64))
511        .or_else(|| value.as_f64().map(|v| v as i64))
512        .ok_or_else(|| tera::Error::msg("filesizeformat requires a number"))?;
513
514    let units = ["B", "KB", "MB", "GB", "TB"];
515    let mut size = bytes as f64;
516    let mut unit_idx = 0;
517
518    while size >= 1024.0 && unit_idx < units.len() - 1 {
519        size /= 1024.0;
520        unit_idx += 1;
521    }
522
523    let formatted = if unit_idx == 0 {
524        format!("{} {}", bytes, units[unit_idx])
525    } else {
526        format!("{:.1} {}", size, units[unit_idx])
527    };
528
529    Ok(Value::String(formatted))
530}