A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.

Compare changes

Choose any two refs to compare.

+2 -2
.github/workflows/docker-image.yml
···
- name: Install cosign
if: github.event_name != 'pull_request'
-
uses: sigstore/cosign-installer@v3.7.0
+
uses: sigstore/cosign-installer@v3.8.1
with:
-
cosign-release: "v2.4.1"
+
cosign-release: "v2.4.3"
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
+9 -39
Cargo.lock
···
[[package]]
name = "nu-ansi-term"
-
version = "0.46.0"
+
version = "0.50.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
+
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
dependencies = [
-
"overload",
-
"winapi",
+
"windows-sys 0.52.0",
[[package]]
···
version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
-
-
[[package]]
-
name = "overload"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "parking"
···
[[package]]
name = "ring"
-
version = "0.17.8"
+
version = "0.17.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
+
checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
-
"spin",
"untrusted",
"windows-sys 0.52.0",
···
[[package]]
name = "tokio"
-
version = "1.43.0"
+
version = "1.43.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e"
+
checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c"
dependencies = [
"backtrace",
"bytes",
···
[[package]]
name = "tracing-subscriber"
-
version = "0.3.19"
+
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
+
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
dependencies = [
"nu-ansi-term",
"sharded-slab",
···
[[package]]
-
name = "winapi"
-
version = "0.3.9"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-
dependencies = [
-
"winapi-i686-pc-windows-gnu",
-
"winapi-x86_64-pc-windows-gnu",
-
]
-
-
[[package]]
-
name = "winapi-i686-pc-windows-gnu"
-
version = "0.4.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-
[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
dependencies = [
"windows-sys 0.59.0",
-
-
[[package]]
-
name = "winapi-x86_64-pc-windows-gnu"
-
version = "0.4.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
+1 -1
Cargo.toml
···
actix-web = "4.4"
actix-files = "0.6"
actix-cors = "0.6"
-
tokio = { version = "1.36", features = ["rt-multi-thread", "macros"] }
+
tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "chrono"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+1 -1
Dockerfile
···
WORKDIR /usr/src/frontend
# Copy frontend files
-
COPY frontend/package*.json ./
+
COPY frontend/package.json ./
RUN bun install
COPY frontend/ ./
+29 -13
README.md
···
# SimpleLink
-
A very performant and light (2MB in memory) link shortener and tracker. Written in Rust and React and uses Postgres.
+
A very performant and light (2MB in memory) link shortener and tracker. Written in Rust and React and uses Postgres or SQLite.
![MainView](readme_img/mainview.jpg)
···
## How to Run
-
### From Docker:
+
### From Docker
-
```Bash
+
```bash
docker run -p 8080:8080 \
-e JWT_SECRET=change-me-in-production \
+
-e SIMPLELINK_USER=admin@example.com \
+
-e SIMPLELINK_PASS=your-secure-password \
-v simplelink_data:/data \
-
ghcr.io/waveringana/simplelink:v2
+
ghcr.io/waveringana/simplelink:v2.2
```
-
Find the admin-setup-token pasted into the terminal output, or in admin-setup-token.txt in the container's root.
+
### Environment Variables
-
This is needed to register with the frontend. (TODO, register admin account with ENV)
+
- `JWT_SECRET`: Required. Used for JWT token generation
+
- `SIMPLELINK_USER`: Optional. If set along with SIMPLELINK_PASS, creates an admin user on first run
+
- `SIMPLELINK_PASS`: Optional. Admin user password
+
- `DATABASE_URL`: Optional. Postgres connection string. If not set, uses SQLite
+
- `INITIAL_LINKS`: Optional. Semicolon-separated list of initial links in format "url,code;url2,code2"
+
- `SERVER_HOST`: Optional. Default: "127.0.0.1"
+
- `SERVER_PORT`: Optional. Default: "8080"
-
### From Docker Compose:
+
If `SIMPLELINK_USER` and `SIMPLELINK_PASS` are not passed, an admin-setup-token is pasted to the console and as a text file in the project root.
+
+
### From Docker Compose
-
Edit the docker-compose.yml file. It comes included with a postgressql db for use
+
Edit the docker-compose.yml file. It comes included with a PostgreSQL db configuration.
## Build
···
First configure .env.example and save it to .env
-
If DATABASE_URL is set, it will connect to a Postgres DB. If blank, it will use an sqlite db in /data
-
```bash
git clone https://github.com/waveringana/simplelink && cd simplelink
./build.sh
cargo run
```
-
On an empty database, an admin-setup-token.txt is created as well as pasted into the terminal output. This is needed to make the admin account.
-
-
Alternatively if you want a binary form
+
Alternatively for a binary build:
```bash
./build.sh --binary
···
docker build -t simplelink .
docker run -p 8080:8080 \
-e JWT_SECRET=change-me-in-production \
+
-e SIMPLELINK_USER=admin@example.com \
+
-e SIMPLELINK_PASS=your-secure-password \
-v simplelink_data:/data \
simplelink
```
···
### From Docker Compose
Adjust the included docker-compose.yml to your liking; it includes a postgres config as well.
+
+
## Features
+
+
- Support for both PostgreSQL and SQLite databases
+
- Initial links can be configured via environment variables
+
- Admin user can be created on first run via environment variables
+
- Link click tracking and statistics
+
- Lightweight and performant
+1 -1
docker-compose.yml
···
- shortener-network
app:
-
image: ghcr.io/waveringana/simplelink:v2
+
image: ghcr.io/waveringana/simplelink:v2.2
container_name: shortener-app
ports:
- "8080:8080"
+3
migrations/20250219000000_extend_short_code.sql
···
+
-- PostgreSQL migration
+
ALTER TABLE links ALTER COLUMN short_code TYPE VARCHAR(32);
+
+8 -7
src/auth.rs
···
+
use crate::{error::AppError, models::Claims};
use actix_web::{dev::Payload, FromRequest, HttpRequest};
use jsonwebtoken::{decode, DecodingKey, Validation};
use std::future::{ready, Ready};
-
use crate::{error::AppError, models::Claims};
pub struct AuthenticatedUser {
pub user_id: i32,
···
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
-
let auth_header = req.headers()
+
let auth_header = req
+
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok());
if let Some(auth_header) = auth_header {
if auth_header.starts_with("Bearer ") {
let token = &auth_header[7..];
-
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
-
+
let secret =
+
std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
match decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
-
&Validation::default()
+
&Validation::default(),
) {
Ok(token_data) => {
return ready(Ok(AuthenticatedUser {
···
}
}
}
-
ready(Err(AppError::Unauthorized))
}
-
}
+
}
+
+1 -5
src/handlers.rs
···
Ok(())
}
-
fn validate_url(url: &String) -> Result<(), AppError> {
+
fn validate_url(url: &str) -> Result<(), AppError> {
if url.is_empty() {
return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
}
···
WHERE link_id = $1
GROUP BY DATE(created_at)
ORDER BY DATE(created_at) ASC
-
LIMIT 30
"#,
)
.bind(link_id)
···
WHERE link_id = ?
GROUP BY DATE(created_at)
ORDER BY DATE(created_at) ASC
-
LIMIT 30
"#,
)
.bind(link_id)
···
AND query_source != ''
GROUP BY DATE(created_at), query_source
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
-
LIMIT 300
"#,
)
.bind(link_id)
···
AND query_source != ''
GROUP BY DATE(created_at), query_source
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
-
LIMIT 300
"#,
)
.bind(link_id)
+158 -1
src/main.rs
···
use actix_cors::Cors;
use actix_web::{web, App, HttpResponse, HttpServer};
use anyhow::Result;
+
use clap::Parser;
use rust_embed::RustEmbed;
use simplelink::check_and_generate_admin_token;
+
use simplelink::models::DatabasePool;
use simplelink::{create_db_pool, run_migrations};
use simplelink::{handlers, AppState};
-
use tracing::info;
+
use sqlx::{Postgres, Sqlite};
+
use tracing::{error, info};
+
#[derive(Parser, Debug)]
+
#[command(author, version, about, long_about = None)]
#[derive(RustEmbed)]
#[folder = "static/"]
struct Asset;
···
}
}
+
async fn create_initial_links(pool: &DatabasePool) -> Result<()> {
+
if let Ok(links) = std::env::var("INITIAL_LINKS") {
+
for link_entry in links.split(';') {
+
let parts: Vec<&str> = link_entry.split(',').collect();
+
if parts.len() >= 2 {
+
let url = parts[0];
+
let code = parts[1];
+
+
match pool {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query(
+
"INSERT INTO links (original_url, short_code, user_id)
+
VALUES ($1, $2, $3)
+
ON CONFLICT (short_code)
+
DO UPDATE SET short_code = EXCLUDED.short_code
+
WHERE links.original_url = EXCLUDED.original_url",
+
)
+
.bind(url)
+
.bind(code)
+
.bind(1)
+
.execute(pool)
+
.await?;
+
}
+
DatabasePool::Sqlite(pool) => {
+
// First check if the exact combination exists
+
let exists = sqlx::query_scalar::<_, bool>(
+
"SELECT EXISTS(
+
SELECT 1 FROM links
+
WHERE original_url = ?1
+
AND short_code = ?2
+
)",
+
)
+
.bind(url)
+
.bind(code)
+
.fetch_one(pool)
+
.await?;
+
+
// Only insert if the exact combination doesn't exist
+
if !exists {
+
sqlx::query(
+
"INSERT INTO links (original_url, short_code, user_id)
+
VALUES (?1, ?2, ?3)",
+
)
+
.bind(url)
+
.bind(code)
+
.bind(1)
+
.execute(pool)
+
.await?;
+
info!("Created initial link: {} -> {} for user_id: 1", code, url);
+
} else {
+
info!("Skipped existing link: {} -> {} for user_id: 1", code, url);
+
}
+
}
+
}
+
}
+
}
+
}
+
Ok(())
+
}
+
+
async fn create_admin_user(pool: &DatabasePool, email: &str, password: &str) -> Result<()> {
+
use argon2::{
+
password_hash::{rand_core::OsRng, SaltString},
+
Argon2, PasswordHasher,
+
};
+
+
let salt = SaltString::generate(&mut OsRng);
+
let argon2 = Argon2::default();
+
let password_hash = argon2
+
.hash_password(password.as_bytes(), &salt)
+
.map_err(|e| anyhow::anyhow!("Password hashing error: {}", e))?
+
.to_string();
+
+
match pool {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query(
+
"INSERT INTO users (email, password_hash)
+
VALUES ($1, $2)
+
ON CONFLICT (email) DO NOTHING",
+
)
+
.bind(email)
+
.bind(&password_hash)
+
.execute(pool)
+
.await?;
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query(
+
"INSERT OR IGNORE INTO users (email, password_hash)
+
VALUES (?1, ?2)",
+
)
+
.bind(email)
+
.bind(&password_hash)
+
.execute(pool)
+
.await?;
+
}
+
}
+
info!("Created admin user: {}", email);
+
Ok(())
+
}
+
#[actix_web::main]
async fn main() -> Result<()> {
// Load environment variables from .env file
···
// Create database connection pool
let pool = create_db_pool().await?;
run_migrations(&pool).await?;
+
+
// First check if admin credentials are provided in environment variables
+
let admin_credentials = match (
+
std::env::var("SIMPLELINK_USER"),
+
std::env::var("SIMPLELINK_PASS"),
+
) {
+
(Ok(user), Ok(pass)) => Some((user, pass)),
+
_ => None,
+
};
+
+
if let Some((email, password)) = admin_credentials {
+
// Now check for existing users
+
let user_count = match &pool {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let count =
+
sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users")
+
.fetch_one(&mut *tx)
+
.await?
+
.0;
+
tx.commit().await?;
+
count
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let count = sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users")
+
.fetch_one(&mut *tx)
+
.await?
+
.0;
+
tx.commit().await?;
+
count
+
}
+
};
+
+
if user_count == 0 {
+
info!("No users found, creating admin user: {}", email);
+
match create_admin_user(&pool, &email, &password).await {
+
Ok(_) => info!("Successfully created admin user"),
+
Err(e) => {
+
error!("Failed to create admin user: {}", e);
+
return Err(anyhow::anyhow!("Failed to create admin user: {}", e));
+
}
+
}
+
}
+
} else {
+
info!(
+
"No admin credentials provided in environment variables, skipping admin user creation"
+
);
+
}
+
+
// Create initial links from environment variables
+
create_initial_links(&pool).await?;
let admin_token = check_and_generate_admin_token(&pool).await?;
+1 -1
src/models.rs
···
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize
-
+ 24 * 60 * 60; // 24 hours from now
+
+ 14 * 24 * 60 * 60; // 2 weeks from now
Self { sub: user_id, exp }
}