Compare commits

..

No commits in common. "master" and "v0.1.0" have entirely different histories.

12 changed files with 36 additions and 383 deletions

View file

@ -1,4 +1,4 @@
name: Publish Release name: Publish Cargo Package
on: on:
push: push:
@ -8,9 +8,11 @@ on:
jobs: jobs:
publish: publish:
runs-on: ubuntu-22.04 runs-on: docker
env: env:
CARGO_TERM_COLOR: always 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: steps:
- name: Check out repository - name: Check out repository
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
@ -27,6 +29,17 @@ jobs:
rustup default stable rustup default stable
cargo --version 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 - name: Verify tag matches package version
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
run: | run: |
@ -43,17 +56,12 @@ jobs:
exit 1 exit 1
fi fi
- name: Build release binary - name: Check package can be published
run: | run: |
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
cargo build --release --locked cargo publish --dry-run --locked --registry forgejo
mkdir -p dist/release
mv target/release/upvoters dist/release/upvoters-linux-x86_64
- name: Create release - name: Publish package
uses: https://data.forgejo.org/actions/forgejo-release@v2 run: |
with: . "$HOME/.cargo/env"
direction: upload cargo publish --locked --registry forgejo
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" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "upvoters" name = "uprs"
version = "0.2.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",

View file

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

View file

@ -1,15 +0,0 @@
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
View file

@ -1,21 +0,0 @@
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,275 +1,3 @@
# upvoters ☝️ # uprs
upvoters is a basic anonymous voting system that can be added to a blog. Simple Rust backend for supporting anonymous like/upvote functionality in hugo sites.
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,29 +1,16 @@
services: services:
db: db:
image: postgres:16 image: postgres:16
container_name: upvoters_db container_name: uprs_db
restart: always restart: always
environment: environment:
POSTGRES_USER: upvoters POSTGRES_USER: uprs
POSTGRES_PASSWORD: password123 POSTGRES_PASSWORD: password123
POSTGRES_DB: upvoters POSTGRES_DB: uprs
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
- ./db/migrations:/docker-entrypoint-initdb.d # run initial schema - ./db/migrations:/docker-entrypoint-initdb.d # run initial schema
ports: ports:
- "5432:5432" - "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: volumes:
pgdata: pgdata:

View file

@ -3,8 +3,6 @@ use std::env;
#[derive(Clone)] #[derive(Clone)]
pub struct Env { pub struct Env {
pub postgres_connection_string: String, pub postgres_connection_string: String,
pub allowed_origins: Vec<String>,
pub port: String,
} }
impl Env { impl Env {
@ -12,19 +10,8 @@ impl Env {
let postgres_connection_string = env::var("POSTGRES_CONNECTION_STRING") let postgres_connection_string = env::var("POSTGRES_CONNECTION_STRING")
.expect("Missing POSTGRES_CONNECTION_STRING as an environment variable"); .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 { Env {
postgres_connection_string, postgres_connection_string,
allowed_origins,
port,
} }
} }
} }

View file

@ -5,25 +5,9 @@ pub mod test_helpers;
pub mod votes; pub mod votes;
use axum::Router; use axum::Router;
use axum::http::{HeaderValue, Method, header};
use state::AppState; use state::AppState;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
pub fn app(state: AppState) -> Router { pub fn app(state: AppState) -> Router {
let origins: Vec<HeaderValue> = state routes::router(state).layer(TraceLayer::new_for_http())
.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 tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use upvoters::state::AppState; use uprs::state::AppState;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
@ -12,11 +12,9 @@ async fn main() {
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
let state = AppState::new().await; let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
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()); tracing::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, upvoters::app(state)) axum::serve(listener, uprs::app(AppState::new().await))
.await .await
.unwrap(); .unwrap();
} }

View file

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

View file

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