From 17d0709ed61ce6306caa79010da25a6617e26bd4 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 15:13:48 -0400 Subject: [PATCH 1/9] Update label to be correct --- .forgejo/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index 5bf46a0..41dcaf5 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -8,7 +8,7 @@ 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 }} From a2c678cdba0b2b75fb1ca86cb572552b44559c19 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 15:52:48 -0400 Subject: [PATCH 2/9] Rename package --- Cargo.lock | 2 +- Cargo.toml | 3 +- Dockerfile | 15 ++++++++ README.md | 87 ++++++++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 12 +++++++ src/main.rs | 4 +-- 6 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 Dockerfile diff --git a/Cargo.lock b/Cargo.lock index cf454fd..3210271 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1961,7 +1961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "uprs" +name = "upvoters" version = "0.1.0" dependencies = [ "anyhow", diff --git a/Cargo.toml b/Cargo.toml index f0e1c53..70a8350 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [package] -name = "uprs" +name = "upvoters" version = "0.1.0" edition = "2024" +license = "MIT" [dependencies] anyhow = "1" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fbd5f56 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 1b07d58..c6ce19b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,86 @@ -# 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. + +## Important Notes + +This service does NOT have any authentication/authorization, so you should NOT serve this to the public web. +I'm using this by just serving it on the same VPS as my blog. + + +## 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`) | + +## 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 +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/docker-compose.yml b/docker-compose.yml index 86f7a6f..5a55b89 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,5 +12,17 @@ services: - ./db/migrations:/docker-entrypoint-initdb.d # run initial schema ports: - "5432:5432" + + app: + build: . + container_name: uprs_app + restart: always + environment: + POSTGRES_CONNECTION_STRING: postgres://uprs:password123@db:5432/uprs + ports: + - "3000:3000" + depends_on: + - db + volumes: pgdata: diff --git a/src/main.rs b/src/main.rs index 6350267..0514583 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() { @@ -14,7 +14,7 @@ async fn main() { let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); tracing::info!("listening on {}", listener.local_addr().unwrap()); - axum::serve(listener, uprs::app(AppState::new().await)) + axum::serve(listener, upvoters::app(AppState::new().await)) .await .unwrap(); } From e5d0219df8da6d83b97f5b61eee679c1cfd603e3 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 15:55:25 -0400 Subject: [PATCH 3/9] Fix workflow to publish executable and add the missing License --- .forgejo/workflows/publish.yml | 32 ++++++++++++-------------------- LICENSE | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 20 deletions(-) create mode 100644 LICENSE diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index 41dcaf5..28230e8 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish Cargo Package +name: Publish Release on: push: @@ -11,8 +11,6 @@ jobs: 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" < Date: Thu, 19 Mar 2026 23:37:03 -0400 Subject: [PATCH 4/9] Configure CORS --- Cargo.toml | 2 +- docker-compose.yml | 11 ++++++----- src/env.rs | 8 ++++++++ src/lib.rs | 18 +++++++++++++++++- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 70a8350..e46bf28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,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"] } diff --git a/docker-compose.yml b/docker-compose.yml index 5a55b89..4f36e63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,12 @@ 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 @@ -15,10 +15,11 @@ services: app: build: . - container_name: uprs_app + container_name: upvoters_app restart: always environment: - POSTGRES_CONNECTION_STRING: postgres://uprs:password123@db:5432/uprs + POSTGRES_CONNECTION_STRING: postgres://upvoters:password123@db:5432/upvoters + ALLOWED_ORIGINS: http://localhost:1313 ports: - "3000:3000" depends_on: diff --git a/src/env.rs b/src/env.rs index 3222dc5..110cdbb 100644 --- a/src/env.rs +++ b/src/env.rs @@ -3,6 +3,7 @@ use std::env; #[derive(Clone)] pub struct Env { pub postgres_connection_string: String, + pub allowed_origins: Vec, } impl Env { @@ -10,8 +11,15 @@ 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(); + Env { postgres_connection_string, + allowed_origins, } } } 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) } From a3b201fa098319b4b16af7006a357a121fe4f768 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 23:44:37 -0400 Subject: [PATCH 5/9] Fix default connection string and set port as environment variable --- src/env.rs | 5 +++++ src/main.rs | 6 ++++-- src/test_helpers.rs | 2 +- src/votes/handlers.rs | 2 ++ 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/env.rs b/src/env.rs index 110cdbb..5b44f38 100644 --- a/src/env.rs +++ b/src/env.rs @@ -4,6 +4,7 @@ use std::env; pub struct Env { pub postgres_connection_string: String, pub allowed_origins: Vec, + pub port: String, } impl Env { @@ -17,9 +18,13 @@ impl Env { .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/main.rs b/src/main.rs index 0514583..f7280c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, upvoters::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(), }, } } From fb2c6368d8017d4c2c906ed1bab34ec942cf4b60 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Fri, 20 Mar 2026 11:01:26 -0400 Subject: [PATCH 6/9] Bump version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3210271..7d922bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1962,7 +1962,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "upvoters" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index e46bf28..0554cad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "upvoters" -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "MIT" From f4c7f4dfc2ad8183e28d7e10ce54ef869231fbca Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Fri, 20 Mar 2026 11:14:54 -0400 Subject: [PATCH 7/9] Fix workflow --- .forgejo/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index 28230e8..b35e27e 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -47,7 +47,8 @@ jobs: run: | . "$HOME/.cargo/env" cargo build --release --locked - mv target/release/upvoters upvoters-linux-x86_64 + mkdir -p dist/release + mv target/release/upvoters dist/release/upvoters-linux-x86_64 - name: Create release uses: https://data.forgejo.org/actions/forgejo-release@v2 @@ -55,5 +56,4 @@ jobs: direction: upload tag: ${{ github.ref_name }} release-notes: "Release ${{ github.ref_name }}" - files: upvoters-linux-x86_64 token: ${{ secrets.GITHUB_TOKEN }} From 97e21bcb9bdb8dd7d0c603bbf5a8fce2600f0de7 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Fri, 20 Mar 2026 12:02:48 -0400 Subject: [PATCH 8/9] Update README with deployment instructions --- README.md | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c6ce19b..2e85e42 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,16 @@ I came across the [creator's (Herman Martinus) blogging site](https://herman.bea I wanted something similar and decided to implement it myself. The goal is maximum simplicity. -## Important Notes -This service does NOT have any authentication/authorization, so you should NOT serve this to the public web. -I'm using this by just serving it on the same VPS as my blog. +## Table of Contents +- [Requirements](#requirements) +- [Configuration](#configuration) +- [Running locally](#running-locally) +- [API](#api) +- [Running tests](#running-tests) +- [Actual deployment](#actual-deployment) +- [License](#license) ## Requirements @@ -24,6 +29,8 @@ 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 @@ -81,6 +88,79 @@ Tests require a running PostgreSQL instance at `postgres://uprs:password123@loca 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 +``` + ## License MIT — see [LICENSE](LICENSE). From 6979e73307fe26ec2bfd2bb75ef1f36bbc5930d0 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Fri, 20 Mar 2026 12:14:24 -0400 Subject: [PATCH 9/9] Update with hugo setup instructions --- README.md | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2e85e42..bd17c2f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ 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. +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 @@ -15,6 +17,7 @@ The goal is maximum simplicity. - [API](#api) - [Running tests](#running-tests) - [Actual deployment](#actual-deployment) +- [Setting it up with Hugo](#setting-it-up-with-hugo) - [License](#license) ## Requirements @@ -161,6 +164,112 @@ sudo systemctl enable --now upvoters 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).