# 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](https://herman.bearblog.dev/) 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]("https://forge.alexselimov.com/aselimov/hugo-bearcub") 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](#setting-it-up-with-hugo). ## Table of Contents - [Requirements](#requirements) - [Configuration](#configuration) - [Running locally](#running-locally) - [API](#api) - [Running tests](#running-tests) - [Actual deployment](#actual-deployment) - [Setting it up with Hugo](#setting-it-up-with-hugo) - [License](#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: ```sh docker compose up --build ``` The server will be available at `http://localhost:3000`. ### Without Docker Start the database: ```sh docker compose up -d db ``` Run the server: ```sh 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 ```json { "vote_count": 42, "voted": true } ``` ## Running tests Tests require a running PostgreSQL instance at `postgres://uprs:password123@localhost:5432/uprs`. ```sh 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: 1. Install dependencies (rust/postgres) (For debian server). ```bash sudo apt update sudo apt install -y postgresql postgresql-contrib ``` 2. Create `postgres` user to manage postgres and `upvoters` user to run the app: ```bash # 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 ``` 3. Create the votes table as the `upvoters` user ```bash sudo -u upvoters psql upvoters ``` ```sql 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) ); ``` 4. Download the binary (You can also install rust and build yourself if you prefer). Replace `v0.2.0` with the latest release tag. ```bash 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 ``` 5. 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 ``` 6. Enable and start the service ```bash sudo systemctl enable --now upvoters ``` 7. Test from VPS using curl ```bash curl https://example.com/api/posts/tests/votes ``` ## Setting it up with Hugo I set this up in my [Voting Bear Cub Theme](https://forge.alexselimov.com/aselimov/hugo-bearcub/src/branch/main). The components are: 1. Javascript file to handle upvoting. This function is automatically called on page loads ```javascript (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"); } }); })(); ``` 2. 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. ```html {{ if .Site.Params.upvotes }}
{{ end }} ``` 3. Styles for the button: ```css .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](LICENSE).