diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index 5bf46a0..b35e27e 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -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" < 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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e6b2c5 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 1b07d58..bd17c2f 100644 --- a/README.md +++ b/README.md @@ -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 }} +
+ +
+ + +{{ 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). diff --git a/docker-compose.yml b/docker-compose.yml index 86f7a6f..4f36e63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/src/env.rs b/src/env.rs index 3222dc5..5b44f38 100644 --- a/src/env.rs +++ b/src/env.rs @@ -3,6 +3,8 @@ use std::env; #[derive(Clone)] pub struct Env { pub postgres_connection_string: String, + pub allowed_origins: Vec, + 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, } } } diff --git a/src/lib.rs b/src/lib.rs index de4b4f6..d8d7450 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 = 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) } diff --git a/src/main.rs b/src/main.rs index 6350267..f7280c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(); } diff --git a/src/test_helpers.rs b/src/test_helpers.rs index 1ea5d68..ba20ce3 100644 --- a/src/test_helpers.rs +++ b/src/test_helpers.rs @@ -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") diff --git a/src/votes/handlers.rs b/src/votes/handlers.rs index 3eeb713..ced29a9 100644 --- a/src/votes/handlers.rs +++ b/src/votes/handlers.rs @@ -141,6 +141,8 @@ mod tests { db, env: Env { postgres_connection_string: String::new(), + allowed_origins: vec![], + port: "3000".to_string(), }, } }