Skip to content

Commit f554a41

Browse files
committed
Add ability to sign content.
1 parent d001437 commit f554a41

File tree

6 files changed

+137
-32
lines changed

6 files changed

+137
-32
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ jobs:
3535
RUSTTARGET: ${{ matrix.target }}
3636
ARCHIVE_TYPES: ${{ matrix.archive_type }}
3737
ARCHIVE_NAME: ${{ matrix.archive_name }}
38+
TOOLCHAIN_VERSION: stable

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ tide-acme = "0"
3737
tide-rustls = "0"
3838
tide-tera = "0"
3939
tide-websockets = "0"
40+
time = "=0.3.39"
4041
tl = "0"
4142
toml = "0"
4243
walkdir = "2"

docs/content.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,17 @@ Files and directories starting with "." are ignored.
4747
Files and directories starting with "_" have special meaning: `_config.toml`, `_content`.
4848

4949
Anything else will be directly served to the clients requesting it.
50+
51+
## Unsigned content
52+
53+
If you want to use Servus in the same way you would use a traditional SSG, by editing markdown files directly, without using a Nostr client to post, you can still do that. The only thing you need in that case is a **Nostr private key** that you pass using an environment variable in addition to the `--sign-content` flag.
54+
55+
Basically you would start Servus like this:
56+
57+
`$ SERVUS_SECRET_KEY=5f263b4561008922b7efbcbcc9066072246e0b4094f92a016691dfe4c0eba358 ./servus --sign-content`
58+
59+
What happens if you do this is the following... when Servus tries to parse a Nostr event from a `.md` file and it fails due to the event being incomplete (missing ID, signature, etc) it generates a fresh event on the fly and signs it with the provided *secret key*. That new event is only held in memory and served to Nostr and HTTP clients. If you want to edit it, you should edit the original `.md` file and restart Servus. Servus will not write anything back to the `.md` file.
60+
61+
Files that look like `yyyy-mm-dd-slug.md` will become posts and files that look like `slug.md` will become pages.
62+
63+
Note: this `SERVUS_SECRET_KEY` key is different from the `pubkey` present in the `_config.toml` file! In fact the two are mutually exclusive. That is, if you decided to pass `--sign-content` and a secret key, your sites **cannot** also have a pubkey. This essentially means that content managed in this way cannot be edited from Nostr clients and you have to do it by manually editing `.md` files!

src/main.rs

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ struct Cli {
5858
#[clap(short('k'), long)]
5959
ssl_key: Option<String>,
6060

61-
#[clap(short('s'), long)]
61+
#[clap(short('a'), long)]
6262
ssl_acme: bool,
6363

6464
#[clap(long)]
@@ -75,6 +75,9 @@ struct Cli {
7575

7676
#[clap(short('v'), long)]
7777
validate_themes: bool,
78+
79+
#[clap(short('s'), long)]
80+
sign_content: bool,
7881
}
7982

8083
#[derive(Clone)]
@@ -658,7 +661,7 @@ async fn handle_put_site_config(mut request: Request<State>) -> tide::Result<Res
658661
));
659662
};
660663

661-
match site::load_site(&request.state().root_path, &site.domain, &themes) {
664+
match site::load_site(&request.state().root_path, &site.domain, &themes, &None) {
662665
Ok(new_site) => {
663666
let state = request.state();
664667
let sites = &mut state.sites.write().unwrap();
@@ -1110,33 +1113,37 @@ fn download_themes(root_path: &str, url: &str, validate: bool) -> Result<()> {
11101113
Ok(())
11111114
}
11121115

1113-
fn load_or_create_sites(root_path: &str, themes: &HashMap<String, Theme>) -> HashMap<String, Site> {
1114-
let existing_sites = site::load_sites(root_path, themes);
1116+
fn load_or_create_sites(
1117+
root_path: &str,
1118+
themes: &HashMap<String, Theme>,
1119+
secret_key: &Option<String>,
1120+
) -> Result<HashMap<String, Site>> {
1121+
let existing_sites = site::load_sites(root_path, themes, secret_key)?;
11151122

11161123
if existing_sites.len() == 0 {
11171124
let stdin = io::stdin();
11181125
let mut response = String::new();
11191126
while response != "n" && response != "y" {
11201127
print!("No sites found. Create a default site [y/n]? ");
1121-
io::stdout().flush().unwrap();
1122-
response = stdin.lock().lines().next().unwrap().unwrap().to_lowercase();
1128+
io::stdout().flush()?;
1129+
response = stdin.lock().lines().next().unwrap()?.to_lowercase();
11231130
}
11241131

11251132
if response == "y" {
11261133
print!("Domain: ");
1127-
io::stdout().flush().unwrap();
1128-
let domain = stdin.lock().lines().next().unwrap().unwrap().to_lowercase();
1134+
io::stdout().flush()?;
1135+
let domain = stdin.lock().lines().next().unwrap()?.to_lowercase();
11291136
print!("Admin pubkey: ");
1130-
io::stdout().flush().unwrap();
1131-
let admin_pubkey = stdin.lock().lines().next().unwrap().unwrap().to_lowercase();
1132-
let site = site::create_site(root_path, &domain, Some(admin_pubkey), themes).unwrap();
1137+
io::stdout().flush()?;
1138+
let admin_pubkey = stdin.lock().lines().next().unwrap()?.to_lowercase();
1139+
let site = site::create_site(root_path, &domain, Some(admin_pubkey), themes)?;
11331140

1134-
[(domain, site)].iter().cloned().collect()
1141+
Ok([(domain, site)].iter().cloned().collect())
11351142
} else {
1136-
HashMap::new()
1143+
Ok(HashMap::new())
11371144
}
11381145
} else {
1139-
existing_sites
1146+
Ok(existing_sites)
11401147
}
11411148
}
11421149

@@ -1150,6 +1157,14 @@ async fn main() -> Result<(), std::io::Error> {
11501157

11511158
femme::with_level(log::LevelFilter::Info);
11521159

1160+
let mut secret_key: Option<String> = None;
1161+
if args.sign_content {
1162+
let env_secret_key = std::env::var("SERVUS_SECRET_KEY")
1163+
.expect("SERVUS_SECRET_KEY is required if --sign-content was passed");
1164+
secp256k1::SecretKey::from_str(&env_secret_key).expect("Cannot parse SERVUS_SECRET_KEY");
1165+
secret_key = Some(env_secret_key);
1166+
}
1167+
11531168
let cache_path = "./cache";
11541169

11551170
let themes = load_or_download_themes(
@@ -1166,7 +1181,8 @@ async fn main() -> Result<(), std::io::Error> {
11661181
panic!("No themes!");
11671182
}
11681183

1169-
let sites = load_or_create_sites(&DEFAULT_ROOT_PATH, &themes);
1184+
let sites = load_or_create_sites(&DEFAULT_ROOT_PATH, &themes, &secret_key)
1185+
.expect("Failed to load sites");
11701186
let site_count = sites.len();
11711187

11721188
let app = server(
@@ -1702,7 +1718,7 @@ mod tests {
17021718
download_test_themes(root_path).unwrap();
17031719

17041720
let themes = theme::load_themes(root_path);
1705-
let mut sites = site::load_sites(root_path, &themes);
1721+
let mut sites = site::load_sites(root_path, &themes, &None)?;
17061722

17071723
// generate some keys
17081724

src/nostr.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ pub struct BareEvent {
2525
pub content: String,
2626
}
2727

28-
#[cfg(test)]
2928
impl BareEvent {
3029
pub fn new(kind: u64, tags: Vec<Vec<String>>, content: &str) -> Self {
3130
Self {

src/site.rs

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use anyhow::{anyhow, bail, Context, Result};
2+
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
23
use serde::{Deserialize, Serialize};
34
use std::{
45
collections::HashMap,
5-
fs,
6+
fmt, fs,
67
fs::File,
78
io::BufReader,
89
path::Path,
@@ -24,6 +25,18 @@ use crate::{
2425
theme::Theme,
2526
};
2627

28+
#[derive(Debug)]
29+
pub struct DuplicateKeyError {}
30+
31+
impl fmt::Display for DuplicateKeyError {
32+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
33+
write!(
34+
f,
35+
"Cannot have a pubkey in _config.toml when a secret key was also passed"
36+
)
37+
}
38+
}
39+
2740
#[derive(Clone, Serialize, Deserialize)]
2841
pub struct ServusMetadata {
2942
pub version: String,
@@ -143,7 +156,7 @@ impl Site {
143156
self
144157
}
145158

146-
fn load_resources(&self, root_path: &str) -> Result<()> {
159+
fn load_resources(&self, root_path: &str, secret_key: &Option<String>) -> Result<()> {
147160
let content_root = Path::new(root_path)
148161
.join("sites")
149162
.join(self.domain.to_string())
@@ -178,12 +191,56 @@ impl Site {
178191

179192
let (front_matter, content) = content::read(&mut reader)?;
180193

181-
let Some(event) = nostr::parse_event(&front_matter, &content) else {
182-
log::warn!("Cannot parse event from {}", path.display());
183-
// TODO: if requested, we should actually generate an event
184-
// and sign it with some key that comes from env (or is generated)!
185-
// perhaps take 'title' into account?
186-
continue;
194+
let event = match nostr::parse_event(&front_matter, &content) {
195+
Some(e) => e,
196+
_ => {
197+
log::warn!("Cannot parse event from {}", path.display());
198+
199+
let Some(secret_key) = &secret_key else {
200+
continue;
201+
};
202+
203+
let file_stem = path.file_stem().unwrap().to_str().unwrap().to_string();
204+
205+
let mut bare_event =
206+
nostr::BareEvent::new(nostr::EVENT_KIND_LONG_FORM, vec![], &content);
207+
208+
let mut date: Option<NaiveDateTime> = None;
209+
if file_stem.len() > 11 {
210+
let date_part = &file_stem[0..10];
211+
if let Ok(d) = NaiveDate::parse_from_str(date_part, "%Y-%m-%d") {
212+
let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
213+
date = Some(NaiveDateTime::new(d, midnight));
214+
}
215+
}
216+
217+
if let Some(date) = date {
218+
bare_event.created_at = date.and_utc().timestamp();
219+
bare_event
220+
.tags
221+
.push(vec!["d".to_string(), file_stem[11..].to_string()]);
222+
log::info!("Generated new event for post: {}", path.display());
223+
} else {
224+
bare_event
225+
.tags
226+
.push(vec!["d".to_string(), file_stem.clone()]);
227+
bare_event
228+
.tags
229+
.push(vec![String::from("t"), String::from("page")]);
230+
log::info!("Generated new event for page: {}", path.display());
231+
}
232+
bare_event.tags.push(vec![
233+
String::from("title"),
234+
front_matter
235+
.get("title")
236+
.unwrap()
237+
.as_str()
238+
.unwrap()
239+
.to_string(),
240+
]);
241+
242+
bare_event.sign(&secret_key)
243+
}
187244
};
188245

189246
log::info!("Event: id={}", &event.id);
@@ -431,12 +488,21 @@ pub fn load_config(config_path: &str) -> Result<SiteConfig> {
431488
Ok(toml::from_str(&fs::read_to_string(config_path)?)?)
432489
}
433490

434-
pub fn load_site(root_path: &str, domain: &str, themes: &HashMap<String, Theme>) -> Result<Site> {
491+
pub fn load_site(
492+
root_path: &str,
493+
domain: &str,
494+
themes: &HashMap<String, Theme>,
495+
secret_key: &Option<String>,
496+
) -> Result<Site> {
435497
let path = format!("{}/sites/{}", root_path, domain);
436498

437499
let mut config =
438500
load_config(&format!("{}/_config.toml", path)).context("Cannot load site config")?;
439501

502+
if config.pubkey.is_some() && secret_key.is_some() {
503+
bail!(DuplicateKeyError {});
504+
}
505+
440506
let theme_path = format!("{}/themes/{}", root_path, config.theme);
441507
if !Path::new(&theme_path).exists() {
442508
bail!(format!("Cannot load site theme: {}", config.theme));
@@ -461,7 +527,7 @@ pub fn load_site(root_path: &str, domain: &str, themes: &HashMap<String, Theme>)
461527
tera: Arc::new(RwLock::new(tera)),
462528
};
463529

464-
site.load_resources(root_path)?;
530+
site.load_resources(root_path, secret_key)?;
465531

466532
return Ok(site);
467533
}
@@ -471,7 +537,11 @@ pub fn load_site(root_path: &str, domain: &str, themes: &HashMap<String, Theme>)
471537
}
472538
}
473539

474-
pub fn load_sites(root_path: &str, themes: &HashMap<String, Theme>) -> HashMap<String, Site> {
540+
pub fn load_sites(
541+
root_path: &str,
542+
themes: &HashMap<String, Theme>,
543+
secret_key: &Option<String>,
544+
) -> Result<HashMap<String, Site>> {
475545
let paths = match fs::read_dir(format!("{}/sites", root_path)) {
476546
Ok(paths) => paths.map(|r| r.unwrap()).collect(),
477547
_ => vec![],
@@ -483,20 +553,24 @@ pub fn load_sites(root_path: &str, themes: &HashMap<String, Theme>) -> HashMap<S
483553
let domain = file_name.to_str().unwrap();
484554

485555
log::info!("Found site: {}!", domain);
486-
match load_site(root_path, &domain, themes) {
556+
match load_site(root_path, &domain, themes, secret_key) {
487557
Ok(site) => {
488558
sites.insert(path.file_name().to_str().unwrap().to_string(), site);
489559
log::debug!("Site loaded!");
490560
}
491561
Err(e) => {
492-
log::warn!("Error loading site {}: {}", domain, e);
562+
if let Some(_) = e.downcast_ref::<DuplicateKeyError>() {
563+
bail!(e);
564+
} else {
565+
log::warn!("Error loading site {}: {}", domain, e);
566+
}
493567
}
494568
}
495569
}
496570

497571
log::info!("{} sites loaded!", sites.len());
498572

499-
sites
573+
Ok(sites)
500574
}
501575

502576
pub fn create_site(
@@ -520,7 +594,7 @@ pub fn create_site(
520594

521595
save_config(&config_path, &config)?;
522596

523-
load_site(root_path, domain, themes)
597+
load_site(root_path, domain, themes, &None)
524598
}
525599

526600
fn get_resource_kind(event: &nostr::Event) -> Option<ResourceKind> {

0 commit comments

Comments
 (0)