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

Add auth

+119 -14
API.md
···
## Base URL
`http://localhost:8080`
-
## Endpoints
### Health Check
Check if the service and database are running.
···
```
### Create Short URL
-
Create a new shortened URL with optional custom code.
```bash
POST /api/shorten
···
```bash
curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"source": "curl-test"
···
```json
{
"id": 1,
"original_url": "https://example.com",
"short_code": "Xa7Bc9",
"created_at": "2024-03-01T12:34:56Z",
···
```bash
curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"custom_code": "example",
···
```json
{
"id": 2,
"original_url": "https://example.com",
"short_code": "example",
"created_at": "2024-03-01T12:34:56Z",
···
}
```
### Get All Links
-
Retrieve all shortened URLs.
```bash
GET /api/links
···
Example:
```bash
-
curl http://localhost:8080/api/links
```
Response (200 OK):
···
[
{
"id": 1,
"original_url": "https://example.com",
"short_code": "Xa7Bc9",
"created_at": "2024-03-01T12:34:56Z",
···
},
{
"id": 2,
"original_url": "https://example.org",
"short_code": "example",
"created_at": "2024-03-01T12:35:00Z",
···
```
### Redirect to Original URL
-
Use the shortened URL to redirect to the original URL.
```bash
-
GET /{short_code}
```
Example:
```bash
-
curl -i http://localhost:8080/example
```
Response (307 Temporary Redirect):
···
```
## Custom Code Rules
-
1. Length: 1-32 characters
2. Allowed characters: letters, numbers, underscores, and hyphens
3. Case-sensitive
4. Cannot use reserved words: ["api", "health", "admin", "static", "assets"]
## Rate Limiting
-
Currently, no rate limiting is implemented.
## Notes
-
1. All timestamps are in UTC
2. Click counts are incremented on successful redirects
-
3. Source tracking is optional but recommended for analytics
4. Custom codes are case-sensitive
5. URLs must include protocol (http:// or https://)
## Error Codes
-
- 200: Success
- 201: Created
- 307: Temporary Redirect
- 400: Bad Request (invalid input)
- 404: Not Found
- 503: Service Unavailable
## Database Schema
-
```sql
CREATE TABLE links (
id SERIAL PRIMARY KEY,
original_url TEXT NOT NULL,
short_code VARCHAR(8) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-
clicks BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE clicks (
id SERIAL PRIMARY KEY,
link_id INTEGER REFERENCES links(id),
source TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
···
## Base URL
`http://localhost:8080`
+
## Authentication
+
The API uses JWT tokens for authentication. Include the token in the Authorization header:
+
```
+
Authorization: Bearer <your_token>
+
```
+
+
### Register
+
Create a new user account.
+
+
```bash
+
POST /api/auth/register
+
```
+
+
Request Body:
+
```json
+
{
+
"email": string, // Required: Valid email address
+
"password": string // Required: Password
+
}
+
```
+
+
Example:
+
```bash
+
curl -X POST http://localhost:8080/api/auth/register \
+
-H "Content-Type: application/json" \
+
-d '{
+
"email": "user@example.com",
+
"password": "your_password"
+
}'
+
```
+
+
Response (200 OK):
+
```json
+
{
+
"token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
+
"user": {
+
"id": 1,
+
"email": "user@example.com"
+
}
+
}
+
```
+
+
### Login
+
Authenticate and receive a JWT token.
+
+
```bash
+
POST /api/auth/login
+
```
+
+
Request Body:
+
```json
+
{
+
"email": string, // Required: Registered email address
+
"password": string // Required: Password
+
}
+
```
+
+
Example:
+
```bash
+
curl -X POST http://localhost:8080/api/auth/login \
+
-H "Content-Type: application/json" \
+
-d '{
+
"email": "user@example.com",
+
"password": "your_password"
+
}'
+
```
+
+
Response (200 OK):
+
```json
+
{
+
"token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
+
"user": {
+
"id": 1,
+
"email": "user@example.com"
+
}
+
}
+
```
+
+
## Protected Endpoints
### Health Check
Check if the service and database are running.
···
```
### Create Short URL
+
Create a new shortened URL with optional custom code. Requires authentication.
```bash
POST /api/shorten
···
```bash
curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
+
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"url": "https://example.com",
"source": "curl-test"
···
```json
{
"id": 1,
+
"user_id": 1,
"original_url": "https://example.com",
"short_code": "Xa7Bc9",
"created_at": "2024-03-01T12:34:56Z",
···
```bash
curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \
+
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"url": "https://example.com",
"custom_code": "example",
···
```json
{
"id": 2,
+
"user_id": 1,
"original_url": "https://example.com",
"short_code": "example",
"created_at": "2024-03-01T12:34:56Z",
···
}
```
+
Unauthorized (401 Unauthorized):
+
```json
+
{
+
"error": "Unauthorized"
+
}
+
```
+
### Get All Links
+
Retrieve all shortened URLs for the authenticated user.
```bash
GET /api/links
···
Example:
```bash
+
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8080/api/links
```
Response (200 OK):
···
[
{
"id": 1,
+
"user_id": 1,
"original_url": "https://example.com",
"short_code": "Xa7Bc9",
"created_at": "2024-03-01T12:34:56Z",
···
},
{
"id": 2,
+
"user_id": 1,
"original_url": "https://example.org",
"short_code": "example",
"created_at": "2024-03-01T12:35:00Z",
···
```
### Redirect to Original URL
+
Use the shortened URL to redirect to the original URL. Source tracking via query parameter is supported.
```bash
+
GET /{short_code}?source={source}
```
Example:
```bash
+
curl -i http://localhost:8080/example?source=email
```
Response (307 Temporary Redirect):
···
```
## Custom Code Rules
1. Length: 1-32 characters
2. Allowed characters: letters, numbers, underscores, and hyphens
3. Case-sensitive
4. Cannot use reserved words: ["api", "health", "admin", "static", "assets"]
## Rate Limiting
Currently, no rate limiting is implemented.
## Notes
1. All timestamps are in UTC
2. Click counts are incremented on successful redirects
+
3. Source tracking is supported both at link creation and during redirection via query parameter
4. Custom codes are case-sensitive
5. URLs must include protocol (http:// or https://)
+
6. All create/read operations require authentication
+
7. Users can only see and manage their own links
## Error Codes
- 200: Success
- 201: Created
- 307: Temporary Redirect
- 400: Bad Request (invalid input)
+
- 401: Unauthorized (missing or invalid token)
- 404: Not Found
- 503: Service Unavailable
## Database Schema
+
```sql
+
-- Users table for authentication
+
CREATE TABLE users (
+
id SERIAL PRIMARY KEY,
+
email VARCHAR(255) NOT NULL UNIQUE,
+
password_hash TEXT NOT NULL
+
);
+
-- Links table with user association
CREATE TABLE links (
id SERIAL PRIMARY KEY,
original_url TEXT NOT NULL,
short_code VARCHAR(8) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
clicks BIGINT NOT NULL DEFAULT 0,
+
user_id INTEGER REFERENCES users(id)
);
+
-- Click tracking with source information
CREATE TABLE clicks (
id SERIAL PRIMARY KEY,
link_id INTEGER REFERENCES links(id),
source TEXT,
+
query_source TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+
+
-- Indexes
+
CREATE INDEX idx_short_code ON links(short_code);
+
CREATE INDEX idx_user_id ON links(user_id);
+
CREATE INDEX idx_link_id ON clicks(link_id);
```
+129 -5
Cargo.lock
···
"actix-cors",
"actix-web",
"anyhow",
"base62",
"chrono",
"clap",
"dotenv",
"lazy_static",
"regex",
"serde",
"serde_json",
"sqlx",
-
"thiserror",
"tokio",
"tracing",
"tracing-subscriber",
···
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
···
]
[[package]]
name = "language-tags"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
name = "num-bigint-dig"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rsa"
version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
name = "slab"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"sha2",
"smallvec",
"sqlformat",
-
"thiserror",
"tokio",
"tokio-stream",
"tracing",
···
"smallvec",
"sqlx-core",
"stringprep",
-
"thiserror",
"tracing",
"uuid",
"whoami",
···
"smallvec",
"sqlx-core",
"stringprep",
-
"thiserror",
"tracing",
"uuid",
"whoami",
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
-
"thiserror-impl",
]
[[package]]
···
]
[[package]]
name = "thread_local"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "url"
···
"actix-cors",
"actix-web",
"anyhow",
+
"argon2",
"base62",
"chrono",
"clap",
"dotenv",
+
"jsonwebtoken",
"lazy_static",
"regex",
"serde",
"serde_json",
"sqlx",
+
"thiserror 1.0.69",
"tokio",
"tracing",
"tracing-subscriber",
···
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
[[package]]
+
name = "argon2"
+
version = "0.5.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+
dependencies = [
+
"base64ct",
+
"blake2",
+
"cpufeatures",
+
"password-hash",
+
]
+
+
[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "blake2"
+
version = "0.10.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+
dependencies = [
+
"digest",
+
]
+
+
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
+
"js-sys",
"libc",
"wasi",
+
"wasm-bindgen",
]
[[package]]
···
]
[[package]]
+
name = "jsonwebtoken"
+
version = "9.3.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f"
+
dependencies = [
+
"base64 0.21.7",
+
"js-sys",
+
"pem",
+
"ring",
+
"serde",
+
"serde_json",
+
"simple_asn1",
+
]
+
+
[[package]]
name = "language-tags"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "num-bigint"
+
version = "0.4.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+
dependencies = [
+
"num-integer",
+
"num-traits",
+
]
+
+
[[package]]
name = "num-bigint-dig"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "password-hash"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+
dependencies = [
+
"base64ct",
+
"rand_core",
+
"subtle",
+
]
+
+
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
+
name = "pem"
+
version = "3.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae"
+
dependencies = [
+
"base64 0.22.1",
+
"serde",
+
]
+
+
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
+
name = "ring"
+
version = "0.17.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
+
dependencies = [
+
"cc",
+
"cfg-if",
+
"getrandom",
+
"libc",
+
"spin",
+
"untrusted",
+
"windows-sys 0.52.0",
+
]
+
+
[[package]]
name = "rsa"
version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "simple_asn1"
+
version = "0.6.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
+
dependencies = [
+
"num-bigint",
+
"num-traits",
+
"thiserror 2.0.11",
+
"time",
+
]
+
+
[[package]]
name = "slab"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
"sha2",
"smallvec",
"sqlformat",
+
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tracing",
···
"smallvec",
"sqlx-core",
"stringprep",
+
"thiserror 1.0.69",
"tracing",
"uuid",
"whoami",
···
"smallvec",
"sqlx-core",
"stringprep",
+
"thiserror 1.0.69",
"tracing",
"uuid",
"whoami",
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
+
"thiserror-impl 1.0.69",
+
]
+
+
[[package]]
+
name = "thiserror"
+
version = "2.0.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
+
dependencies = [
+
"thiserror-impl 2.0.11",
]
[[package]]
···
]
[[package]]
+
name = "thiserror-impl"
+
version = "2.0.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"syn 2.0.96",
+
]
+
+
[[package]]
name = "thread_local"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
+
[[package]]
+
name = "untrusted"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
+2
Cargo.toml
···
edition = "2021"
[dependencies]
actix-web = "4.4"
actix-cors = "0.6"
tokio = { version = "1.36", features = ["full"] }
···
chrono = { version = "0.4", features = ["serde"] }
regex = "1.10"
lazy_static = "1.4"
···
edition = "2021"
[dependencies]
+
jsonwebtoken = "9"
actix-web = "4.4"
actix-cors = "0.6"
tokio = { version = "1.36", features = ["full"] }
···
chrono = { version = "0.4", features = ["serde"] }
regex = "1.10"
lazy_static = "1.4"
+
argon2 = "0.5.3"
-18
migrations/20240301000000_initial.sql
···
-
CREATE TABLE links (
-
id SERIAL PRIMARY KEY,
-
original_url TEXT NOT NULL,
-
short_code VARCHAR(8) NOT NULL UNIQUE,
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-
clicks BIGINT NOT NULL DEFAULT 0
-
);
-
-
CREATE INDEX idx_short_code ON links(short_code);
-
-
CREATE TABLE clicks (
-
id SERIAL PRIMARY KEY,
-
link_id INTEGER REFERENCES links(id),
-
source TEXT,
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-
);
-
-
CREATE INDEX idx_link_id ON clicks(link_id);
···
-15
migrations/20240302000000_auth_and_tracking.sql:
···
-
-- Add users table
-
CREATE TABLE users (
-
id SERIAL PRIMARY KEY,
-
email TEXT UNIQUE NOT NULL,
-
password_hash TEXT NOT NULL,
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-
);
-
-
-- Add user_id to links
-
ALTER TABLE links
-
ADD COLUMN user_id INTEGER REFERENCES users(id);
-
-
-- Add query_source to clicks
-
ALTER TABLE clicks
-
ADD COLUMN query_source TEXT;
···
+41
src/auth.rs
···
···
+
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,
+
}
+
+
impl FromRequest for AuthenticatedUser {
+
type Error = AppError;
+
type Future = Ready<Result<Self, Self::Error>>;
+
+
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
+
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());
+
+
match decode::<Claims>(
+
token,
+
&DecodingKey::from_secret(secret.as_bytes()),
+
&Validation::default()
+
) {
+
Ok(token_data) => {
+
return ready(Ok(AuthenticatedUser {
+
user_id: token_data.claims.sub,
+
}));
+
}
+
Err(_) => return ready(Err(AppError::Unauthorized)),
+
}
+
}
+
}
+
+
ready(Err(AppError::Unauthorized))
+
}
+
}
+10 -2
src/error.rs
···
#[error("Invalid input: {0}")]
InvalidInput(String),
}
impl ResponseError for AppError {
fn error_response(&self) -> HttpResponse {
match self {
AppError::NotFound => HttpResponse::NotFound().json("Not found"),
-
AppError::Database(_) => HttpResponse::InternalServerError().json("Internal server error"),
AppError::InvalidInput(msg) => HttpResponse::BadRequest().json(msg),
}
}
-
}
···
#[error("Invalid input: {0}")]
InvalidInput(String),
+
+
#[error("Authentication error: {0}")]
+
Auth(String),
+
+
#[error("Unauthorized")]
+
Unauthorized,
}
impl ResponseError for AppError {
fn error_response(&self) -> HttpResponse {
match self {
AppError::NotFound => HttpResponse::NotFound().json("Not found"),
+
AppError::Database(err) => HttpResponse::InternalServerError().json(format!("Database error: {}", err)), // Show actual error
AppError::InvalidInput(msg) => HttpResponse::BadRequest().json(msg),
+
AppError::Auth(msg) => HttpResponse::BadRequest().json(msg),
+
AppError::Unauthorized => HttpResponse::Unauthorized().json("Unauthorized"),
}
}
+
}
+112 -9
src/handlers.rs
···
use actix_web::{web, HttpResponse, Responder, HttpRequest};
-
use crate::{AppState, error::AppError, models::{CreateLink, Link}};
use regex::Regex;
use lazy_static::lazy_static;
lazy_static! {
static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap();
···
pub async fn create_short_url(
state: web::Data<AppState>,
payload: web::Json<CreateLink>,
-
req: HttpRequest,
) -> Result<impl Responder, AppError> {
validate_url(&payload.url)?;
let short_code = if let Some(ref custom_code) = payload.custom_code {
validate_custom_code(custom_code)?;
// Check if code is already taken
if let Some(_) = sqlx::query_as::<_, Link>(
"SELECT * FROM links WHERE short_code = $1"
···
// Start transaction
let mut tx = state.db.begin().await?;
-
let link = sqlx::query_as::<_, Link>(
-
"INSERT INTO links (original_url, short_code) VALUES ($1, $2) RETURNING *"
)
.bind(&payload.url)
.bind(&short_code)
.fetch_one(&mut *tx)
.await?;
-
if let Some(ref source) = payload.source {
sqlx::query(
"INSERT INTO clicks (link_id, source) VALUES ($1, $2)"
)
···
.execute(&mut *tx)
.await?;
}
-
tx.commit().await?;
Ok(HttpResponse::Created().json(link))
}
···
) -> Result<impl Responder, AppError> {
let short_code = path.into_inner();
let mut tx = state.db.begin().await?;
let link = sqlx::query_as::<_, Link>(
···
match link {
Some(link) => {
-
// Record click with user agent as source
let user_agent = req.headers()
.get("user-agent")
.and_then(|h| h.to_str().ok())
···
.to_string();
sqlx::query(
-
"INSERT INTO clicks (link_id, source) VALUES ($1, $2)"
)
.bind(link.id)
.bind(user_agent)
.execute(&mut *tx)
.await?;
···
pub async fn get_all_links(
state: web::Data<AppState>,
) -> Result<impl Responder, AppError> {
let links = sqlx::query_as::<_, Link>(
-
"SELECT * FROM links ORDER BY created_at DESC"
)
.fetch_all(&state.db)
.await?;
···
let uuid = Uuid::new_v4();
encode(uuid.as_u128() as u64).chars().take(8).collect()
}
···
use actix_web::{web, HttpResponse, Responder, HttpRequest};
+
use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation, errors::Error as JwtError};use crate::{error::AppError, models::{AuthResponse, Claims, CreateLink, Link, LoginRequest, RegisterRequest, User, UserResponse}, AppState};
use regex::Regex;
+
use argon2::{password_hash::{rand_core::OsRng, SaltString}, PasswordVerifier};
use lazy_static::lazy_static;
+
use argon2::{Argon2, PasswordHash, PasswordHasher};
+
use crate::auth::{AuthenticatedUser};
lazy_static! {
static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap();
···
pub async fn create_short_url(
state: web::Data<AppState>,
+
user: AuthenticatedUser,
payload: web::Json<CreateLink>,
) -> Result<impl Responder, AppError> {
+
tracing::debug!("Creating short URL with user_id: {}", user.user_id);
+
validate_url(&payload.url)?;
let short_code = if let Some(ref custom_code) = payload.custom_code {
validate_custom_code(custom_code)?;
+
tracing::debug!("Checking if custom code {} exists", custom_code);
// Check if code is already taken
if let Some(_) = sqlx::query_as::<_, Link>(
"SELECT * FROM links WHERE short_code = $1"
···
// Start transaction
let mut tx = state.db.begin().await?;
+
+
tracing::debug!("Inserting new link with short_code: {}", short_code);
let link = sqlx::query_as::<_, Link>(
+
"INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *"
)
.bind(&payload.url)
.bind(&short_code)
+
.bind(user.user_id)
.fetch_one(&mut *tx)
.await?;
+
if let Some(ref source) = payload.source {
+
tracing::debug!("Adding click source: {}", source);
sqlx::query(
"INSERT INTO clicks (link_id, source) VALUES ($1, $2)"
)
···
.execute(&mut *tx)
.await?;
}
+
tx.commit().await?;
Ok(HttpResponse::Created().json(link))
}
···
) -> Result<impl Responder, AppError> {
let short_code = path.into_inner();
+
// Extract query source if present
+
let query_source = req.uri()
+
.query()
+
.and_then(|q| web::Query::<std::collections::HashMap<String, String>>::from_query(q).ok())
+
.and_then(|params| params.get("source").cloned());
+
let mut tx = state.db.begin().await?;
let link = sqlx::query_as::<_, Link>(
···
match link {
Some(link) => {
+
// Record click with both user agent and query source
let user_agent = req.headers()
.get("user-agent")
.and_then(|h| h.to_str().ok())
···
.to_string();
sqlx::query(
+
"INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)"
)
.bind(link.id)
.bind(user_agent)
+
.bind(query_source)
.execute(&mut *tx)
.await?;
···
pub async fn get_all_links(
state: web::Data<AppState>,
+
user: AuthenticatedUser,
) -> Result<impl Responder, AppError> {
let links = sqlx::query_as::<_, Link>(
+
"SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC"
)
+
.bind(user.user_id)
.fetch_all(&state.db)
.await?;
···
let uuid = Uuid::new_v4();
encode(uuid.as_u128() as u64).chars().take(8).collect()
}
+
+
pub async fn register(
+
state: web::Data<AppState>,
+
payload: web::Json<RegisterRequest>,
+
) -> Result<impl Responder, AppError> {
+
let exists = sqlx::query!(
+
"SELECT id FROM users WHERE email = $1",
+
payload.email
+
)
+
.fetch_optional(&state.db)
+
.await?;
+
+
if exists.is_some() {
+
return Err(AppError::Auth("Email already registered".to_string()));
+
}
+
+
let salt = SaltString::generate(&mut OsRng);
+
let argon2 = Argon2::default();
+
let password_hash = argon2.hash_password(payload.password.as_bytes(), &salt)
+
.map_err(|e| AppError::Auth(e.to_string()))?
+
.to_string();
+
+
let user = sqlx::query_as!(
+
User,
+
"INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *",
+
payload.email,
+
password_hash
+
)
+
.fetch_one(&state.db)
+
.await?;
+
+
let claims = Claims::new(user.id);
+
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
+
let token = encode(
+
&Header::default(),
+
&claims,
+
&EncodingKey::from_secret(secret.as_bytes())
+
).map_err(|e| AppError::Auth(e.to_string()))?;
+
+
Ok(HttpResponse::Ok().json(AuthResponse {
+
token,
+
user: UserResponse {
+
id: user.id,
+
email: user.email,
+
},
+
}))
+
}
+
+
pub async fn login(
+
state: web::Data<AppState>,
+
payload: web::Json<LoginRequest>,
+
) -> Result<impl Responder, AppError> {
+
let user = sqlx::query_as!(
+
User,
+
"SELECT * FROM users WHERE email = $1",
+
payload.email
+
)
+
.fetch_optional(&state.db)
+
.await?
+
.ok_or_else(|| AppError::Auth("Invalid credentials".to_string()))?;
+
+
let argon2 = Argon2::default();
+
let parsed_hash = PasswordHash::new(&user.password_hash)
+
.map_err(|e| AppError::Auth(e.to_string()))?;
+
+
if argon2.verify_password(payload.password.as_bytes(), &parsed_hash).is_err() {
+
return Err(AppError::Auth("Invalid credentials".to_string()));
+
}
+
+
let claims = Claims::new(user.id);
+
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
+
let token = encode(
+
&Header::default(),
+
&claims,
+
&EncodingKey::from_secret(secret.as_bytes())
+
).map_err(|e| AppError::Auth(e.to_string()))?;
+
+
Ok(HttpResponse::Ok().json(AuthResponse {
+
token,
+
user: UserResponse {
+
id: user.id,
+
email: user.email,
+
},
+
}))
+
}
+5 -2
src/main.rs
···
mod error;
mod handlers;
mod models;
#[derive(Clone)]
pub struct AppState {
···
.await?;
// Run database migrations
-
sqlx::migrate!("./migrations").run(&pool).await?;
let state = AppState { db: pool };
···
.service(
web::scope("/api")
.route("/shorten", web::post().to(handlers::create_short_url))
-
.route("/links", web::get().to(handlers::get_all_links)),
)
.service(
···
mod error;
mod handlers;
mod models;
+
mod auth;
#[derive(Clone)]
pub struct AppState {
···
.await?;
// Run database migrations
+
//sqlx::migrate!("./migrations").run(&pool).await?;
let state = AppState { db: pool };
···
.service(
web::scope("/api")
.route("/shorten", web::post().to(handlers::create_short_url))
+
.route("/links", web::get().to(handlers::get_all_links))
+
.route("/auth/register", web::post().to(handlers::register))
+
.route("/auth/login", web::post().to(handlers::login)),
)
.service(
+30
src/migrations/2025125_initial.sql
···
···
+
-- Create users table
+
CREATE TABLE users (
+
id SERIAL PRIMARY KEY,
+
email VARCHAR(255) NOT NULL UNIQUE,
+
password_hash TEXT NOT NULL
+
);
+
+
-- Create links table with user_id from the start
+
CREATE TABLE links (
+
id SERIAL PRIMARY KEY,
+
original_url TEXT NOT NULL,
+
short_code VARCHAR(8) NOT NULL UNIQUE,
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
clicks BIGINT NOT NULL DEFAULT 0,
+
user_id INTEGER REFERENCES users(id)
+
);
+
+
-- Create clicks table for tracking
+
CREATE TABLE clicks (
+
id SERIAL PRIMARY KEY,
+
link_id INTEGER REFERENCES links(id),
+
source TEXT,
+
query_source TEXT,
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+
);
+
+
-- Create indexes
+
CREATE INDEX idx_short_code ON links(short_code);
+
CREATE INDEX idx_user_id ON links(user_id);
+
CREATE INDEX idx_link_id ON clicks(link_id);
+23 -1
src/models.rs
···
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Deserialize)]
pub struct CreateLink {
pub url: String,
···
#[derive(Serialize, FromRow)]
pub struct Link {
pub id: i32,
-
pub user_id: i32,
pub original_url: String,
pub short_code: String,
pub created_at: chrono::DateTime<chrono::Utc>,
···
+
use std::time::{SystemTime, UNIX_EPOCH};
+
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
+
#[derive(Debug, Serialize, Deserialize)]
+
pub struct Claims {
+
pub sub: i32, // user id
+
pub exp: usize,
+
}
+
+
impl Claims {
+
pub fn new(user_id: i32) -> Self {
+
let exp = SystemTime::now()
+
.duration_since(UNIX_EPOCH)
+
.unwrap()
+
.as_secs() as usize + 24 * 60 * 60; // 24 hours from now
+
+
Self {
+
sub: user_id,
+
exp,
+
}
+
}
+
}
+
#[derive(Deserialize)]
pub struct CreateLink {
pub url: String,
···
#[derive(Serialize, FromRow)]
pub struct Link {
pub id: i32,
+
pub user_id: Option<i32>,
pub original_url: String,
pub short_code: String,
pub created_at: chrono::DateTime<chrono::Utc>,