Echo Writes Code

main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
mod content;
mod errors;
mod handlers;
mod renderer;
mod state;

use tracing_subscriber::prelude::{ * };

use crate::errors::{ Result };
use crate::state::{ ServerState };
use axum::{ Router };
use axum::routing::{ get, post };
use axum_messages::{ MessagesManagerLayer };
use clap::{ Parser };
use include_dir::{ Dir, include_dir };
use kdl::{ KdlDocument };
use tokio::net::{ TcpListener };
use tower_http::services::{ ServeDir };
use tower_http::trace::{ TraceLayer };
use tower_sessions::{ Expiry, MemoryStore, SessionManagerLayer };
use tracing_subscriber::filter::{ EnvFilter, LevelFilter };
use std::path::{ PathBuf };
use std::process::{ ExitCode };
use std::sync::{ Arc };
use time::{ Duration };

static DEFAULT_THEME: Dir = include_dir!("themes/simple");

/// The server program for the Limetree CMS.
#[derive(Debug, Parser)]
#[command(about, version)]
struct CommandLine {
  /// Path to the limetree.kdl configuration file.
  #[arg(short, long)]
  configuration_file: PathBuf,

  /// Run in development mode. This is only useful if you are working on Limetree itself.
  #[arg(short, long)]
  development_mode: bool,
}

#[tokio::main]
async fn main() -> ExitCode {
  let result = run_server().await;

  match result {
    Ok(_) => ExitCode::SUCCESS,
    Err(e) => {
      tracing::error!("{}", e);
      ExitCode::FAILURE
    },
  }
}

async fn run_server() -> Result<()> {
  let command_line = CommandLine::parse();
  let configuration_text = std::fs::read_to_string(&command_line.configuration_file)?;
  let configuration = KdlDocument::parse(&configuration_text)?;

  let filter_level = if command_line.development_mode {
    LevelFilter::TRACE
  } else {
    LevelFilter::INFO
  };

  let filter_layer = EnvFilter::builder()
    .with_default_directive(filter_level.into())
    .from_env_lossy();

  tracing_subscriber::registry()
    .with(tracing_subscriber::fmt::layer())
    .with(filter_layer)
    .init();

  let site_title = configuration
    .get("frontend")
    .and_then(|node| node.children())
    .and_then(|document| document.get_arg("title"))
    .and_then(|value| value.as_string())
    .unwrap_or("Limetree CMS")
    .to_string();

  let theme_name = configuration
    .get("frontend")
    .and_then(|node| node.children())
    .and_then(|document| document.get_arg("theme"))
    .and_then(|value| value.as_string())
    .unwrap_or("simple")
    .to_string();

  let limetree_root = configuration
    .get("backend")
    .and_then(|node| node.children())
    .and_then(|document| document.get_arg("root"))
    .and_then(|value| value.as_string())
    .map(PathBuf::from)
    .unwrap_or(std::env::current_dir()?.join("limetree_data"));

  let content_path = configuration
    .get("backend")
    .and_then(|node| node.children())
    .and_then(|document| document.get_arg("content_path"))
    .and_then(|value| value.as_string())
    .map(PathBuf::from)
    .unwrap_or(limetree_root.join("content"));

  let metadata_path = configuration
    .get("backend")
    .and_then(|node| node.children())
    .and_then(|document| document.get_arg("metadata_path"))
    .and_then(|value| value.as_string())
    .map(PathBuf::from)
    .unwrap_or(limetree_root.join("metadata.json"));

  let themes_path = configuration
    .get("backend")
    .and_then(|node| node.children())
    .and_then(|document| document.get_arg("themes_path"))
    .and_then(|value| value.as_string())
    .map(PathBuf::from)
    .unwrap_or(limetree_root.join("themes"));

  let theme_path = themes_path.join(&theme_name);
  let theme_static_path = theme_path.join("static");

  // Avoid having to delete the bundled `simple` theme every time we `cargo run` the development
  // server
  if theme_name == "simple" && command_line.development_mode {
    std::fs::remove_dir_all(&theme_path)?;
  }

  if theme_name == "simple" && !theme_path.is_dir() {
    DEFAULT_THEME.extract(&theme_path)?;
  }

  let session_store = MemoryStore::default();
  let session_layer = SessionManagerLayer::new(session_store)
    .with_secure(false)
    .with_expiry(Expiry::OnInactivity(Duration::seconds(10)));

  let server_state = ServerState::new(&site_title, &content_path, &metadata_path, &theme_path)?;

  let router = Router::new()
    .layer(TraceLayer::new_for_http())
    .route("/", get(handlers::index))
    .route("/api/authenticate", post(handlers::authenticate_api))
    .route("/api/page/new", post(handlers::new_page_api))
    .route("/api/page/edit/{slug}", post(handlers::edit_page_api))
    .route("/authenticate", get(handlers::authenticate))
    .route("/page/new", get(handlers::new_page))
    .route("/page/edit/{slug}", get(handlers::edit_page))
    .route("/page/view/{slug}", get(handlers::view_page))
    .route("/tags/all", get(handlers::all_tags))
    .route("/tags/in/{tag}", get(handlers::in_tags))
    .layer(MessagesManagerLayer)
    .layer(session_layer)
    .with_state(Arc::new(server_state))
    .nest_service("/static", ServeDir::new(theme_static_path));

  const HOST: &str = "localhost";
  const PORT: u16 = 8081;

  let address = format!("{}:{}", HOST, PORT);
  let listener = TcpListener::bind(address).await?;

  let bound_address = listener.local_addr()?;
  tracing::info!("{} now listening on {}", env!("CARGO_BIN_NAME"), bound_address);

  axum::serve(listener, router).await?;
  Ok(())
}