Hello world - Serving content

My colleague and co-founder Teodor has for a while tried to couch me into creating my own place to write stuff, like his site play.teod.eu. Now was the time, and naturally I had to write my own publishing platform.

Teodor writes all his content in Org-mode, which looks like a nice format. Rust has more and more become my favorite way to generate machine-readable code, so I wanted to write this thing in Rust. Unfortunately, the few crates I found that could parse orgmode looked abandoned.

I like to write in markdown, and Rust has a pretty nice parser, so markdown it is.

Simple sites like this are often created using static site generation like Jekyll. However I don’t like static site generation particularly. It’s nice to be able to deploy by pushing your html to some static storage place, however I wanted to have a backend to be able to have more dynamic content in the future.

I still wanted to be able to edit content directly in markdown and deploy easily, however that should be doable without having to generate everything up-front.

The rust code

I use axum for the http-server part of Rust. I need to say ok on /health to make our internal “serverless”-system happy. A fallback route will handle everything else by searching the file-system for the correct file.

#[tokio::main]
async fn main() -> Result<()> {
    let app = Router::new()
        .route("/health", get(|| async { "ok" }))
        .fallback(frontend::catchall.into_service());

    let addr = SocketAddr::from(([0, 0, 0, 0], 3123));

    info!("Listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

When we get a request, we need to convert the path to somewhere on the filesystem. Right now it just support markdown, but it should be no problem to extend it in the future.

fn find_path(path: &str) -> Result<Utf8PathBuf, HttpError> {
    // content directory is hardcoded for now. We canonialize to be able to
    // call `starts_with` below.
    let base_path = Utf8Path::new("../content/").canonicalize_utf8()?;

    let serve_path =
        // if base_path is a dir (for instance `/`), serve `index.md` in that dir
        if base_path.join(path).is_dir() && base_path.join(path).join("index.md").exists() {
            base_path.join(path).join("index.md")
        // If base_path is foo and we have a `foo.md`, serve that
        } else if base_path.join(format!("{}.md", path)).exists() {
            base_path.join(format!("{}.md", path))
        // If we did not find any md file, return 1 404 error
        } else {
            return Err(HttpError::not_found());
        };

    // Check that the user has not asked for a
    // nasty path like `../../../../../../../etc/passwd`
    let serve_path = serve_path.canonicalize_utf8()?;
    if !serve_path.starts_with(base_path) {
        return Err(HttpError::not_found());
    }

    Ok(serve_path)
}

Serving the files are done by first parsing the content to markdown, then using liquid to render a html file with the template inserted.

pub async fn catchall(uri: Uri) -> Result<Html<String>, HttpError> {
    // What content should be served?
    let content_path = find_path(&uri.path()[1..])?;

    // Read the content
    let content_md = fs::read_to_string(&content_path)
        .with_context(|| format!("Error reading {}", content_path))
        .map_err(HttpError::not_found_with_cause)?;

    // Convert markdown to html
    let content = markdown::to_html(&content_md);

    // The template is always `base.html`
    let template = fs::read_to_string("../content/base.html").unwrap();
    let template = liquid::ParserBuilder::with_stdlib()
        .build()?
        .parse(&template)?;

    // Send the markdown to the template and render it using `liquid`

    let globals = liquid::object!({ "content": &content });
    let output = template.render(&globals)?;

    Ok(Html(output))
}

base.html looks like this



  
    
    
    
    
    Random ramblings
    
    
  
  
    
{{content}}

Future work

This is just the beginning, however it should be good enough to serve content. A few low hanging fruits I should get to pretty soon.

Caching

Right now the markdown is parsed every time the document is fetched. This makes the code easy to reason about, and should be fast enough. However caching the content will make it even faster. A simple LRU cache should be enough to make this endure lots of traffic.

Syntax highlighting

The code in this article looks pretty colorless. It deserves some syntax highlighting.

Live reload

The backend should detect changes for the content files and reload the browser, to make the editing-experience nicer.