| .forgejo/workflows | ||
| db/migrations | ||
| src | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| README.md | ||
upvoters ☝️
upvoters is a basic anonymous voting system that can be added to a blog. I recently reworked my personal blog with the Hugo Bear blog theme. I came across the creator's (Herman Martinus) blogging site which had this really cool anonymous upvote system. I wanted something similar and decided to implement it myself. The goal is maximum simplicity so that I can use it with my Hugo static site. It's directly integrated in my theme which attempts to mimic Herman Martinus' bear blog style. I've added steps on how I set it up in my theme at the bottom of this README.
Table of Contents
- Requirements
- Configuration
- Running locally
- API
- Running tests
- Actual deployment
- Setting it up with Hugo
- License
Requirements
- Rust (stable)
- PostgreSQL 16
Configuration
The following environment variables are required:
| Variable | Description |
|---|---|
POSTGRES_CONNECTION_STRING |
PostgreSQL connection string (e.g. postgres://user:password@localhost:5432/upvoters) |
ALLOWED_ORIGINS |
Comma-separated list of allowed CORS origins (e.g. localhost:1313,example.com) |
PORT |
Port to listen on (default: 3000) |
Running locally
With Docker Compose
Builds and starts both the database and app:
docker compose up --build
The server will be available at http://localhost:3000.
Without Docker
Start the database:
docker compose up -d db
Run the server:
POSTGRES_CONNECTION_STRING=postgres://uprs:password123@localhost:5432/uprs cargo run
The server listens on port 3000.
API
| Method | Endpoint | Description |
|---|---|---|
GET |
/health |
Health check |
POST |
/posts/{slug}/vote |
Upvote a post |
DELETE |
/posts/{slug}/vote |
Remove a vote from a post |
GET |
/posts/{slug}/votes |
Get vote count and whether the current user has voted |
Voter identity is tracked via a voter_id cookie. One is set automatically on first request.
GET /posts/{slug}/votes response
{
"vote_count": 42,
"voted": true
}
Running tests
Tests require a running PostgreSQL instance at postgres://uprs:password123@localhost:5432/uprs.
cargo test
Actual deployment
Here are the instructions for how I deployed this service. You might be able to just run it through the docker-compose on a VPS but I already had a postgres server set up:
- Install dependencies (rust/postgres) (For debian server).
sudo apt update
sudo apt install -y postgresql postgresql-contrib
- Create
postgresuser to manage postgres andupvotersuser to run the app:
# Create the system user that will run the app
sudo useradd --system --home /opt/upvoters --create-home upvoters
# Create the PostgreSQL role with peer auth (no password needed)
sudo -u postgres psql -c "CREATE USER upvoters;"
sudo -u postgres psql -c "CREATE DATABASE upvoters OWNER upvoters;"
# Enable peer auth for the upvoters role (append to pg_hba.conf)
echo "local upvoters upvoters peer" | sudo tee -a /etc/postgresql/16/main/pg_hba.conf
sudo systemctl reload postgresql
- Create the votes table as the
upvotersuser
sudo -u upvoters psql upvoters
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS votes (
slug text not null,
voter_id uuid not null,
created_at timestamptz NOT NULL DEFAULT timezone('utc'::text, now()),
primary key (slug, voter_id)
);
- Download the binary (You can also install rust and build yourself if you prefer). Replace
v0.2.0with the latest release tag.
sudo curl https://forge.alexselimov.com/aselimov/upvoters/releases/download/v0.2.0/upvoters-linux-x86_64 --output /opt/upvoters/upvoters
sudo chmod +x /opt/upvoters/upvoters
sudo chown upvoters /opt/upvoters/upvoters
- Create a systemd service at /etc/systemd/system/upvoters.service
[Unit]
Description=upvoters rust backend
After=network.target postgresql.service
[Service]
User=upvoters
Group=upvoters
WorkingDirectory=/opt/upvoters
ExecStart=/opt/upvoters/upvoters
Restart=on-failure
# The below connection string works if you have peer auth enabled and have postgresql running on
# a socket
Environment='POSTGRES_CONNECTION_STRING=postgresql:///upvoters?host=/var/run/postgresql'
Environment='ALLOWED_ORIGINS=https://example.com'
# Specify a PORT if the default 3000 is already taken
Environment='PORT=3000'
[Install]
WantedBy=multi-user.target
- Enable and start the service
sudo systemctl enable --now upvoters
- Test from VPS using curl
curl https://example.com/api/posts/tests/votes
Setting it up with Hugo
I set this up in my Voting Bear Cub Theme. The components are:
- Javascript file to handle upvoting. This function is automatically called on page loads
(async function () {
// Define the slug
const slug = location.pathname.replace(/^\/|\/$/g, "").replaceAll("/", "-");
const API = window.UPVOTE_API;
const btn = document.getElementById("upvote-btn");
const count = document.getElementById("upvote-count");
// On load fetch curent vote count and whether the button should be clicked on or off
const res = await fetch(`${API}/posts/${slug}/votes`, {
credentials: "include",
});
const data = await res.json();
count.textContent = data.vote_count;
if (data.voted) btn.setAttribute("voted", true);
btn.addEventListener("click", async () => {
const alreadyVoted = btn.hasAttribute("voted");
const method = alreadyVoted ? "DELETE" : "POST";
const r = await fetch(`${API}/posts/${slug}/vote`, {
method,
credentials: "include",
});
if (r.ok) {
const updated = await fetch(`${API}/posts/${slug}/votes`, {
credentials: "include",
});
const d = await updated.json();
count.textContent = d.vote_count;
if (d.voted) btn.setAttribute("voted", false);
else btn.removeAttribute("voted");
}
});
})();
- Button component in single.html template placed after the main post body. Note: I guard this behind a config check so users can use my theme without setting up upvoters.
{{ if .Site.Params.upvotes }}
<div class="upvote">
<button id="upvote-btn" aria-label="Upvote">
<span id="upvote-chevron">⌃</span>
<span id="upvote-count">—</span>
</button>
</div>
<script>window.UPVOTE_API = "{{ .Site.Params.upvoteApi }}";</script>
<script src="/upvote.js"></script>
{{ end }}
- Styles for the button:
.upvote {
display: flex;
flex-direction: column;
align-items: center;
width: fit-content;
gap: 0.2rem;
margin-block: var(--spacing);
}
#upvote-btn {
background: none;
border: none;
cursor: pointer;
color: currentColor;
font-size: calc(var(--size) * 4);
line-height: 0.8;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
opacity: 0.4;
transition: opacity 0.15s, color 0.15s;
}
#upvote-btn:hover {
opacity: 0.8;
}
#upvote-btn[voted] {
color: var(--color-primary);
opacity: 1;
}
#upvote-chevron {
display: block;
line-height: 1;
margin-bottom: -0.3em;
}
#upvote-count {
font-size: calc(var(--size) * 1.6);
line-height: 1;
}
License
MIT — see LICENSE.