- Rust 63.3%
- HTML 26%
- CSS 10.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| content | ||
| images | ||
| src | ||
| static | ||
| templates | ||
| .gitignore | ||
| Cargo.toml | ||
| config.toml | ||
| README.md | ||
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;/feedfor the RSS feed;/adminfor the dashboard.
Without
MDSERVE_CONFIG, the server reads/etc/topics_proxyauth/config.toml.
/adminhas 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:
- an exact match;
- a prefix match;
- 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_fileis a TSV journal. Older 4- and 6-column journals still load, and duplicates are removed at startup. - Salts: each journal has a
.saltfile 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.tsvand 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:
dateandupdateddefault to the file's modification time;summarydefaults to the first 180 characters;authordefaults tosite_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 forgedHostheader 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.

