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 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 pub fn config(&self) -> std::sync::RwLockReadGuard<'_, Config> {
258 self.config.read().unwrap_or_else(|e| e.into_inner())
259 }
260
261 pub fn update_config(&self, new_config: Config) -> Result<()> {
263 new_config.validate()?;
265
266 let content = std::fs::read_to_string(&self.config_path)?;
268 let mut doc = content.parse::<toml_edit::DocumentMut>()?;
269
270 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 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 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 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 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 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 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 result = result.replace("***", "");
453 result = result.replace("**", "");
454 result = result.replace("__", "");
455 result = result.replace('*', "");
456 result = result.replace('_', " ");
457
458 result = result
460 .lines()
461 .filter(|line| {
462 let trimmed = line.trim();
463 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 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 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}