Markdown blog server in Rust with fuzzy search, RSS, reader votes and a privacy-friendly analytics dashboard.
  • Rust 63.3%
  • HTML 26%
  • CSS 10.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-16 16:21:17 +02:00
content first commit 2026-09-16 16:04:09 +02:00
images Add images 2026-09-16 16:14:58 +02:00
src change directory templates admin 2026-09-16 16:10:27 +02:00
static first commit 2026-09-16 16:04:09 +02:00
templates first commit 2026-09-16 16:04:09 +02:00
.gitignore first commit 2026-09-16 16:04:09 +02:00
Cargo.toml first commit 2026-09-16 16:04:09 +02:00
config.toml first commit 2026-09-16 16:04:09 +02:00
README.md change position title readme 2026-09-16 16:21:17 +02:00

mdserve

A small Rust web server that turns a folder of Markdown files into a blog.

It includes an article list with fuzzy search and category filters, an RSS feed, reader votes, a privacy-aware visitor counter and an analytics dashboard at /admin.

There is no database and no build step for content: drop a .md file in the content folder and it is online.

demo: Topics ProxyAuth

Quickstart

cargo build --release

Lay out a site folder:

site/
├── config.toml
├── content/        your .md files  (hello-world.md → /hello-world)
├── static/         served on /assets
└── templates/      page.html and list.html

Write a minimal config.toml:

bind = "127.0.0.1:9005"
content_dir = "content"
static_dir = "static"
template = "templates/page.html"
list_template = "templates/list.html"
site_url = "https://blog.example.com"
views_file = "data/views.tsv"
votes_file = "data/votes.tsv"
mask_ips = true

Run the server:

cd site
MDSERVE_CONFIG=config.toml /path/to/mdserve

Then open:

  • http://127.0.0.1:9005/ for the article list;
  • /feed for the RSS feed;
  • /admin for the dashboard.

Without MDSERVE_CONFIG, the server reads /etc/topics_proxyauth/config.toml.

/admin has no login of its own: protect it before going public.

Features

Feature In short
Markdown pages content/<slug>.md is served at /<slug>, with an optional front-matter block. Pages are cached until the file changes.
List and search / lists articles newest first, paginated. ?q= runs a fuzzy search, so beyondcrop finds "BeyondCorp".
Categories meta: Security / Zero Trust / IAM is a path. The list page shows it as chained dropdown filters.
RSS feed /feed publishes the full article content, categories, cover and update date.
Covers The front-matter image, else static/img/<slug>.*, else a cover generated from the slug.
Votes +1 / −1 per article, one vote per reader. Only salted hashes are stored.
Visitor counter One visit per reader, article and day. Articles show their all-time number of unique visitors.
Bot detection Crawlers, feed readers and scripts are recognised and hidden from the dashboard by default.
IP masking 203.0.113.42 becomes 203.xxx.xxx.xxx and 2001:db8::1 becomes 2001:db8:..., everywhere.
How search works

The catalogue is indexed with Tantivy, entirely in memory. The index is rebuilt whenever a .md file is added, changed or removed.

Each query word is matched in three ways, from strongest to weakest:

  1. an exact match;
  2. a prefix match;
  3. a fuzzy match (Levenshtein distance 0, 1 or 2, depending on word length).

Matching covers the title, label, summary and body. Words are combined with AND first, then with OR if nothing matches.

If the index cannot be built, the server falls back to a plain substring search. /suggest?q= returns up to 8 results as JSON.

How categories work

With meta: Security / Zero Trust / IAM, picking "Security" shows a second dropdown that lists only what follows "Security".

The selection is kept in the URL, for example ?category1=Security&category2=Zero%20Trust, and it combines with search and pagination.

category_depth limits how many levels become filters (default 3; 0 disables them).

Visitor counting, salts and upgrading
  • What is counted: a visitor is identified by IP address plus User-Agent, salted and hashed. Reading an article again the same day does not count again; coming back the next day does.
  • Where it is stored: views_file is a TSV journal. Older 4- and 6-column journals still load, and duplicates are removed at startup.
  • Salts: each journal has a .salt file next to it (views.salt, votes.salt). Keep them. Regenerating a salt makes returning readers count as new.
  • Upgrading: earlier versions stored the salt inside views.tsv and changed it on every restart. The original salt is recovered automatically on first start. Visits recorded under the old, changing salts cannot be re-linked, so older totals may be slightly high.
IP masking in detail
mask_ips = true
mask_ipv4_octets = 3   # octets replaced with xxx, from the end (1–4)
mask_ipv6_groups = 2   # groups kept, from the start (1–7)

IPv6 addresses always end in :..., so the masked form reveals neither the address length nor where a :: was. IPv4-mapped addresses (::ffff:1.2.3.4) are masked as IPv4.

The mask applies to:

  • the visit journal;
  • /admin;
  • the CSV export;
  • the server access log.

At startup, full addresses already in views.tsv are masked and the file is rewritten. This cannot be undone.

Counting is unaffected: the visitor hash is computed from the full address, in memory, before masking.

Admin dashboard

/admin is a single page built into the binary.

  • Period: today, 7, 30 or 90 days, 12 months, or all time.
  • Filters:
    • pick one article, or select a title in the table;
    • include or exclude bots;
    • export the current view as CSV.
  • Key figures:
    • visits and unique visitors, compared with the previous period;
    • new visitors;
    • daily average and busiest day;
    • today's figures and the all-time total.
  • Chart:
    • bars for visits, lines for unique and new visitors, and a 7-day average;
    • a tooltip on hover, and series that can be hidden;
    • hourly view on "today", and weekly or monthly grouping on long periods.
  • Reading habits and audience:
    • visits by hour and by day of the week;
    • devices, browsers and operating systems.
  • Articles:
    • sortable ranking with visits, share of traffic, 30-day trend and all-time visitors;
    • includes articles nobody has read yet.
  • Visitor log: paginated and searchable by IP, browser, article or date.

The view is kept in the URL (/admin?days=90&slug=hello-world) and refreshes every minute.

JSON API and CSV export
GET /admin/api/stats      ?days=1–3650|all  &bots=0|1  &slug=
GET /admin/api/visitors   ?slug=  &q=  &bots=0|1  &page=  &per_page=
GET /admin/export.csv     ?days=1–3650|all  &bots=0|1  &slug=

In the CSV, fields starting with =, +, - or @ are neutralised so spreadsheets never evaluate them as formulas.

Protecting /admin

/admin has no authentication and shows visitor data. Put it behind an authenticating proxy, for example ProxyAuth:

- prefix: "/admin"
  target: "http://127.0.0.1:9005"
  required_login: true
  groups: ["admin"]
  preserve_prefix: true

Alternatively, bind the blog to a private address only.

Writing an article

---
title: Hello, world
label: Blog / News
meta: News / Announcements
author: Jane
date: 2026-09-16
summary: One sentence shown on the card.
---

The introduction.

## A section

Text, **emphasis**, `code`.

:::ending
A closing box.
:::

All fields are optional:

  • date and updated default to the file's modification time;
  • summary defaults to the first 180 characters;
  • author defaults to site_name.

The file name is the slug, and only A–Z a–z 0–9 - _ are allowed.

All front-matter fields and rendering rules
Field Used for
title page <h1> and card title
label badge above the title
meta category path
author byline and feed dc:creator
date sort order and card date
updated "Updated on …" and feed atom:updated
image cover image
summary card and feed description

Rendering rules:

  • A # heading in the body is turned into ##.
  • Each ## opens a <section>.
  • Raw HTML is allowed.

Configuration

Settings are read from MDSERVE_CONFIG, or from /etc/topics_proxyauth/config.toml. Every key is optional.

All settings
Key Default Purpose
bind 0.0.0.0:9005 listen address
content_dir /etc/topics_proxyauth/content .md files
static_dir /etc/topics_proxyauth/static served on /assets
template / list_template …/templates/*.html page templates
cache true keep rendered pages in memory
refresh_interval_secs 300 background content check; 0 disables it
site_name / site_tagline ProxyAuth / … navigation
nav_code_url / nav_docs_url ProxyAuth links navigation links
list_title / list_label Blog Posts / … heading of /
intro intro slug shown above the list
per_page 8 articles per page
search_limit 60 maximum search results
category_depth 3 filter levels
site_url empty public origin for feed URLs — set it behind a proxy
feed_title / feed_description from the list settings feed metadata
feed_language / feed_limit en / 20 feed settings
votes_file /var/lib/topics_proxyauth/votes.tsv "" disables votes
views_file /var/lib/topics_proxyauth/views.tsv "" disables counting and /admin
mask_ips false mask IP addresses
mask_ipv4_octets / mask_ipv6_groups 3 / 2 mask shape

Environment overrides: MDSERVE_BIND, MDSERVE_CONTENT_DIR, MDSERVE_STATIC_DIR, MDSERVE_TEMPLATE, MDSERVE_LIST_TEMPLATE, MDSERVE_INTRO, MDSERVE_PER_PAGE, MDSERVE_CACHE, MDSERVE_SITE_URL, MDSERVE_FEED_LIMIT, MDSERVE_CATEGORY_DEPTH, MDSERVE_REFRESH_INTERVAL_SECS, MDSERVE_MASK_IPS.

Template placeholders

Both templates:

  • {{page_title}}, {{site_name}}, {{site_tagline}};
  • {{nav_code_url}}, {{nav_docs_url}};
  • {{wave}}, {{title}}, {{label_html}}.

page.html:

  • {{meta_html}}, {{cover_html}};
  • {{content}}, {{votes_html}}.

list.html:

  • {{intro_html}}, {{search_value}}, {{categories_html}};
  • {{note_html}}, {{articles}}, {{pagination}}.
All routes
GET  /                     list    (?q=  ?page=  ?category1=…)
GET  /suggest?q=           search suggestions (JSON)
GET  /<slug>               article, records a visit
GET  /feed                 RSS 2.0 (also /feed.xml, /rss.xml)
GET  /cover/<slug>.svg     generated cover
GET  /assets/…             static files
GET  /votes/<slug>         vote tally (JSON)
POST /vote/<slug>          cast a vote: value=1|-1|0
POST /view/<slug>          total unique visitors (JSON)
GET  /admin                dashboard

Security notes

  • Slugs: limited to [A-Za-z0-9-_], so no path traversal.
  • Raw HTML: allowed in Markdown, so only serve trusted content.
  • site_url: set it behind a proxy, so a forged Host header never reaches the feed.
  • /admin: must be protected. See Protecting /admin.

Development

cargo test
Project layout
src/main.rs       server, routes, page cache, access log
src/config.rs     configuration
src/store.rs      content scan, metadata, sorting
src/search.rs     Tantivy index and fuzzy queries
src/markdown.rs   front-matter and Markdown rendering
src/render.rs     templates, cards, covers, pagination
src/feed.rs       RSS feed
src/votes.rs      votes, salts and hashes
src/views.rs      visitor counter and statistics
src/privacy.rs    IP masking
src/admin.rs      dashboard routes, JSON API, CSV export
src/admin.html    dashboard page, embedded in the binary

tantivy = "0.22" is pinned on purpose: the stored-document API changed between major versions. If you upgrade, review the end of SearchIndex::search.

Last Versions

Show All Versions