diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index b35e27e..5bf46a0 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish Release +name: Publish Cargo Package on: push: @@ -8,9 +8,11 @@ on: jobs: publish: - runs-on: ubuntu-22.04 + runs-on: docker 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 @@ -27,6 +29,17 @@ 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 deleted file mode 100644 index 0e6b2c5..0000000 --- a/LICENSE +++ /dev/null @@ -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. diff --git a/README.md b/README.md index bd17c2f..1b07d58 100644 --- a/README.md +++ b/README.md @@ -1,275 +1,3 @@ -# upvoters ☝️ +# uprs -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). +Simple Rust backend for supporting anonymous like/upvote functionality in hugo sites. diff --git a/docker-compose.yml b/docker-compose.yml index 4f36e63..86f7a6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,29 +1,16 @@ services: db: image: postgres:16 - container_name: upvoters_db + container_name: uprs_db restart: always environment: - POSTGRES_USER: upvoters + POSTGRES_USER: uprs POSTGRES_PASSWORD: password123 - POSTGRES_DB: upvoters + POSTGRES_DB: uprs 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 5b44f38..3222dc5 100644 --- a/src/env.rs +++ b/src/env.rs @@ -3,8 +3,6 @@ use std::env; #[derive(Clone)] pub struct Env { pub postgres_connection_string: String, - pub allowed_origins: Vec, - pub port: String, } impl Env { @@ -12,19 +10,8 @@ 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 d8d7450..de4b4f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,25 +5,9 @@ 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 { - 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) + routes::router(state).layer(TraceLayer::new_for_http()) } diff --git a/src/main.rs b/src/main.rs index f7280c3..6350267 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use upvoters::state::AppState; +use uprs::state::AppState; #[tokio::main] async fn main() { @@ -12,11 +12,9 @@ async fn main() { .with(tracing_subscriber::fmt::layer()) .init(); - let state = AppState::new().await; - let port = &state.env.port; - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await.unwrap(); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); tracing::info!("listening on {}", listener.local_addr().unwrap()); - axum::serve(listener, upvoters::app(state)) + axum::serve(listener, uprs::app(AppState::new().await)) .await .unwrap(); } diff --git a/src/test_helpers.rs b/src/test_helpers.rs index ba20ce3..1ea5d68 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://upvoters:password123@localhost:5432/upvoters"; + "postgres://uprs:password123@localhost:5432/uprs"; 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 ced29a9..3eeb713 100644 --- a/src/votes/handlers.rs +++ b/src/votes/handlers.rs @@ -141,8 +141,6 @@ mod tests { db, env: Env { postgres_connection_string: String::new(), - allowed_origins: vec![], - port: "3000".to_string(), }, } }