Compare commits

..

9 commits

Author SHA1 Message Date
6979e73307 Update with hugo setup instructions 2026-03-20 12:14:24 -04:00
97e21bcb9b Update README with deployment instructions 2026-03-20 12:02:48 -04:00
f4c7f4dfc2 Fix workflow
All checks were successful
Publish Release / publish (push) Successful in 24s
2026-03-20 11:14:54 -04:00
fb2c6368d8 Bump version
All checks were successful
Publish Release / publish (push) Successful in 1m24s
2026-03-20 11:01:26 -04:00
a3b201fa09 Fix default connection string and set port as environment variable 2026-03-19 23:44:37 -04:00
44c9f0e705 Configure CORS 2026-03-19 23:37:03 -04:00
e5d0219df8 Fix workflow to publish executable and add the missing License 2026-03-19 15:55:25 -04:00
a2c678cdba Rename package 2026-03-19 15:52:48 -04:00
17d0709ed6 Update label to be correct 2026-03-19 15:13:48 -04:00
12 changed files with 383 additions and 36 deletions

View file

@ -1,4 +1,4 @@
name: Publish Cargo Package
name: Publish Release
on:
push:
@ -8,11 +8,9 @@ on:
jobs:
publish:
runs-on: docker
runs-on: ubuntu-22.04
env:
CARGO_TERM_COLOR: always
CARGO_REGISTRIES_FORGEJO_TOKEN: Bearer ${{ secrets.FORGEJO_CARGO_TOKEN }}
FORGEJO_CARGO_INDEX: ${{ github.server_url }}/${{ github.repository_owner }}/_cargo-index.git
steps:
- name: Check out repository
uses: https://data.forgejo.org/actions/checkout@v4
@ -29,17 +27,6 @@ jobs:
rustup default stable
cargo --version
- name: Configure Cargo registry
run: |
mkdir -p "$HOME/.cargo"
cat > "$HOME/.cargo/config.toml" <<EOF
[registries.forgejo]
index = "${FORGEJO_CARGO_INDEX}"
[net]
git-fetch-with-cli = true
EOF
- name: Verify tag matches package version
if: startsWith(github.ref, 'refs/tags/v')
run: |
@ -56,12 +43,17 @@ jobs:
exit 1
fi
- name: Check package can be published
- name: Build release binary
run: |
. "$HOME/.cargo/env"
cargo publish --dry-run --locked --registry forgejo
cargo build --release --locked
mkdir -p dist/release
mv target/release/upvoters dist/release/upvoters-linux-x86_64
- name: Publish package
run: |
. "$HOME/.cargo/env"
cargo publish --locked --registry forgejo
- name: Create release
uses: https://data.forgejo.org/actions/forgejo-release@v2
with:
direction: upload
tag: ${{ github.ref_name }}
release-notes: "Release ${{ github.ref_name }}"
token: ${{ secrets.GITHUB_TOKEN }}

4
Cargo.lock generated
View file

@ -1961,8 +1961,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "uprs"
version = "0.1.0"
name = "upvoters"
version = "0.2.0"
dependencies = [
"anyhow",
"axum",

View file

@ -1,7 +1,8 @@
[package]
name = "uprs"
version = "0.1.0"
name = "upvoters"
version = "0.2.0"
edition = "2024"
license = "MIT"
[dependencies]
anyhow = "1"
@ -19,7 +20,7 @@ sqlx = { version = "0.8.6", features = [
] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace"] }
tower-http = { version = "0.6", features = ["trace", "cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.22.0", features = ["v4"] }

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM rust:1.88-slim AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src
COPY src ./src
RUN touch src/main.rs && cargo build --release --locked
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/upvoters /usr/local/bin/upvoters
CMD ["upvoters"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Alex Selimov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

276
README.md
View file

@ -1,3 +1,275 @@
# uprs
# upvoters ☝️
Simple Rust backend for supporting anonymous like/upvote functionality in hugo sites.
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 }}
<div class="upvote">
<button id="upvote-btn" aria-label="Upvote">
<span id="upvote-chevron">&#8963;</span>
<span id="upvote-count"></span>
</button>
</div>
<script>window.UPVOTE_API = "{{ .Site.Params.upvoteApi }}";</script>
<script src="/upvote.js"></script>
{{ 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).

View file

@ -1,16 +1,29 @@
services:
db:
image: postgres:16
container_name: uprs_db
container_name: upvoters_db
restart: always
environment:
POSTGRES_USER: uprs
POSTGRES_USER: upvoters
POSTGRES_PASSWORD: password123
POSTGRES_DB: uprs
POSTGRES_DB: upvoters
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/migrations:/docker-entrypoint-initdb.d # run initial schema
ports:
- "5432:5432"
app:
build: .
container_name: upvoters_app
restart: always
environment:
POSTGRES_CONNECTION_STRING: postgres://upvoters:password123@db:5432/upvoters
ALLOWED_ORIGINS: http://localhost:1313
ports:
- "3000:3000"
depends_on:
- db
volumes:
pgdata:

View file

@ -3,6 +3,8 @@ use std::env;
#[derive(Clone)]
pub struct Env {
pub postgres_connection_string: String,
pub allowed_origins: Vec<String>,
pub port: String,
}
impl Env {
@ -10,8 +12,19 @@ impl Env {
let postgres_connection_string = env::var("POSTGRES_CONNECTION_STRING")
.expect("Missing POSTGRES_CONNECTION_STRING as an environment variable");
let allowed_origins = env::var("ALLOWED_ORIGINS")
.expect("Missing ALLOWED_ORIGINS as an environment variable")
.split(',')
.map(|s| s.trim().to_string())
.collect();
let port = env::var("PORT")
.unwrap_or_else(|_| "3000".to_string());
Env {
postgres_connection_string,
allowed_origins,
port,
}
}
}

View file

@ -5,9 +5,25 @@ pub mod test_helpers;
pub mod votes;
use axum::Router;
use axum::http::{HeaderValue, Method, header};
use state::AppState;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
pub fn app(state: AppState) -> Router {
routes::router(state).layer(TraceLayer::new_for_http())
let origins: Vec<HeaderValue> = state
.env
.allowed_origins
.iter()
.map(|o| o.parse().expect("Invalid origin in ALLOWED_ORIGINS"))
.collect();
let cors = CorsLayer::new()
.allow_origin(origins)
.allow_methods([Method::GET, Method::POST, Method::DELETE])
.allow_headers([header::CONTENT_TYPE])
.allow_credentials(true);
routes::router(state)
.layer(TraceLayer::new_for_http())
.layer(cors)
}

View file

@ -1,5 +1,5 @@
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use uprs::state::AppState;
use upvoters::state::AppState;
#[tokio::main]
async fn main() {
@ -12,9 +12,11 @@ async fn main() {
.with(tracing_subscriber::fmt::layer())
.init();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
let state = AppState::new().await;
let port = &state.env.port;
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await.unwrap();
tracing::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, uprs::app(AppState::new().await))
axum::serve(listener, upvoters::app(state))
.await
.unwrap();
}

View file

@ -3,7 +3,7 @@ pub mod db {
use sqlx::PgPool;
const DEFAULT_CONNECTION_STRING: &str =
"postgres://uprs:password123@localhost:5432/uprs";
"postgres://upvoters:password123@localhost:5432/upvoters";
pub async fn test_pool() -> PgPool {
let conn = std::env::var("POSTGRES_CONNECTION_STRING")

View file

@ -141,6 +141,8 @@ mod tests {
db,
env: Env {
postgres_connection_string: String::new(),
allowed_origins: vec![],
port: "3000".to_string(),
},
}
}