This commit is contained in:
waveringana 2025-01-25 03:16:35 -05:00
parent c048377bcc
commit 9a43049978
11 changed files with 471 additions and 66 deletions

133
API.md
View file

@ -3,7 +3,85 @@
## Base URL ## Base URL
`http://localhost:8080` `http://localhost:8080`
## Endpoints ## 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 ### Health Check
Check if the service and database are running. Check if the service and database are running.
@ -28,7 +106,7 @@ Response (503 Service Unavailable):
``` ```
### Create Short URL ### Create Short URL
Create a new shortened URL with optional custom code. Create a new shortened URL with optional custom code. Requires authentication.
```bash ```bash
POST /api/shorten POST /api/shorten
@ -49,6 +127,7 @@ Examples:
```bash ```bash
curl -X POST http://localhost:8080/api/shorten \ curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{ -d '{
"url": "https://example.com", "url": "https://example.com",
"source": "curl-test" "source": "curl-test"
@ -59,6 +138,7 @@ Response (201 Created):
```json ```json
{ {
"id": 1, "id": 1,
"user_id": 1,
"original_url": "https://example.com", "original_url": "https://example.com",
"short_code": "Xa7Bc9", "short_code": "Xa7Bc9",
"created_at": "2024-03-01T12:34:56Z", "created_at": "2024-03-01T12:34:56Z",
@ -70,6 +150,7 @@ Response (201 Created):
```bash ```bash
curl -X POST http://localhost:8080/api/shorten \ curl -X POST http://localhost:8080/api/shorten \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{ -d '{
"url": "https://example.com", "url": "https://example.com",
"custom_code": "example", "custom_code": "example",
@ -81,6 +162,7 @@ Response (201 Created):
```json ```json
{ {
"id": 2, "id": 2,
"user_id": 1,
"original_url": "https://example.com", "original_url": "https://example.com",
"short_code": "example", "short_code": "example",
"created_at": "2024-03-01T12:34:56Z", "created_at": "2024-03-01T12:34:56Z",
@ -111,8 +193,15 @@ Invalid custom code (400 Bad Request):
} }
``` ```
Unauthorized (401 Unauthorized):
```json
{
"error": "Unauthorized"
}
```
### Get All Links ### Get All Links
Retrieve all shortened URLs. Retrieve all shortened URLs for the authenticated user.
```bash ```bash
GET /api/links GET /api/links
@ -120,7 +209,7 @@ GET /api/links
Example: Example:
```bash ```bash
curl http://localhost:8080/api/links curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8080/api/links
``` ```
Response (200 OK): Response (200 OK):
@ -128,6 +217,7 @@ Response (200 OK):
[ [
{ {
"id": 1, "id": 1,
"user_id": 1,
"original_url": "https://example.com", "original_url": "https://example.com",
"short_code": "Xa7Bc9", "short_code": "Xa7Bc9",
"created_at": "2024-03-01T12:34:56Z", "created_at": "2024-03-01T12:34:56Z",
@ -135,6 +225,7 @@ Response (200 OK):
}, },
{ {
"id": 2, "id": 2,
"user_id": 1,
"original_url": "https://example.org", "original_url": "https://example.org",
"short_code": "example", "short_code": "example",
"created_at": "2024-03-01T12:35:00Z", "created_at": "2024-03-01T12:35:00Z",
@ -144,15 +235,15 @@ Response (200 OK):
``` ```
### Redirect to Original URL ### Redirect to Original URL
Use the shortened URL to redirect to the original URL. Use the shortened URL to redirect to the original URL. Source tracking via query parameter is supported.
```bash ```bash
GET /{short_code} GET /{short_code}?source={source}
``` ```
Example: Example:
```bash ```bash
curl -i http://localhost:8080/example curl -i http://localhost:8080/example?source=email
``` ```
Response (307 Temporary Redirect): Response (307 Temporary Redirect):
@ -169,48 +260,62 @@ Error Response (404 Not Found):
``` ```
## Custom Code Rules ## Custom Code Rules
1. Length: 1-32 characters 1. Length: 1-32 characters
2. Allowed characters: letters, numbers, underscores, and hyphens 2. Allowed characters: letters, numbers, underscores, and hyphens
3. Case-sensitive 3. Case-sensitive
4. Cannot use reserved words: ["api", "health", "admin", "static", "assets"] 4. Cannot use reserved words: ["api", "health", "admin", "static", "assets"]
## Rate Limiting ## Rate Limiting
Currently, no rate limiting is implemented. Currently, no rate limiting is implemented.
## Notes ## Notes
1. All timestamps are in UTC 1. All timestamps are in UTC
2. Click counts are incremented on successful redirects 2. Click counts are incremented on successful redirects
3. Source tracking is optional but recommended for analytics 3. Source tracking is supported both at link creation and during redirection via query parameter
4. Custom codes are case-sensitive 4. Custom codes are case-sensitive
5. URLs must include protocol (http:// or https://) 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 ## Error Codes
- 200: Success - 200: Success
- 201: Created - 201: Created
- 307: Temporary Redirect - 307: Temporary Redirect
- 400: Bad Request (invalid input) - 400: Bad Request (invalid input)
- 401: Unauthorized (missing or invalid token)
- 404: Not Found - 404: Not Found
- 503: Service Unavailable - 503: Service Unavailable
## Database Schema ## Database Schema
```sql ```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 ( CREATE TABLE links (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
original_url TEXT NOT NULL, original_url TEXT NOT NULL,
short_code VARCHAR(8) NOT NULL UNIQUE, short_code VARCHAR(8) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
clicks BIGINT NOT NULL DEFAULT 0 clicks BIGINT NOT NULL DEFAULT 0,
user_id INTEGER REFERENCES users(id)
); );
-- Click tracking with source information
CREATE TABLE clicks ( CREATE TABLE clicks (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
link_id INTEGER REFERENCES links(id), link_id INTEGER REFERENCES links(id),
source TEXT, source TEXT,
query_source TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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);
``` ```

134
Cargo.lock generated
View file

@ -9,16 +9,18 @@ dependencies = [
"actix-cors", "actix-cors",
"actix-web", "actix-web",
"anyhow", "anyhow",
"argon2",
"base62", "base62",
"chrono", "chrono",
"clap", "clap",
"dotenv", "dotenv",
"jsonwebtoken",
"lazy_static", "lazy_static",
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@ -352,6 +354,18 @@ version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" 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]] [[package]]
name = "atoi" name = "atoi"
version = "2.0.0" version = "2.0.0"
@ -418,6 +432,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -915,8 +938,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1249,6 +1274,21 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[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]] [[package]]
name = "language-tags" name = "language-tags"
version = "0.3.2" version = "0.3.2"
@ -1418,6 +1458,16 @@ dependencies = [
"winapi", "winapi",
] ]
[[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]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.4" version = "0.8.4"
@ -1559,12 +1609,33 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[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]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 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]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@ -1726,6 +1797,21 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 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]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.7" version = "0.9.7"
@ -1930,6 +2016,18 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[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]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.9"
@ -2031,7 +2129,7 @@ dependencies = [
"sha2", "sha2",
"smallvec", "smallvec",
"sqlformat", "sqlformat",
"thiserror", "thiserror 1.0.69",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@ -2116,7 +2214,7 @@ dependencies = [
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror", "thiserror 1.0.69",
"tracing", "tracing",
"uuid", "uuid",
"whoami", "whoami",
@ -2156,7 +2254,7 @@ dependencies = [
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror", "thiserror 1.0.69",
"tracing", "tracing",
"uuid", "uuid",
"whoami", "whoami",
@ -2269,7 +2367,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [ dependencies = [
"thiserror-impl", "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]]
@ -2283,6 +2390,17 @@ dependencies = [
"syn 2.0.96", "syn 2.0.96",
] ]
[[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]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.8" version = "1.1.8"
@ -2505,6 +2623,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.4" version = "2.5.4"

View file

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
jsonwebtoken = "9"
actix-web = "4.4" actix-web = "4.4"
actix-cors = "0.6" actix-cors = "0.6"
tokio = { version = "1.36", features = ["full"] } tokio = { version = "1.36", features = ["full"] }
@ -21,3 +22,4 @@ dotenv = "0.15"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
regex = "1.10" regex = "1.10"
lazy_static = "1.4" lazy_static = "1.4"
argon2 = "0.5.3"

View file

@ -1,18 +0,0 @@
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);

View file

@ -1,15 +0,0 @@
-- 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 Normal file
View file

@ -0,0 +1,41 @@
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))
}
}

View file

@ -11,14 +11,22 @@ pub enum AppError {
#[error("Invalid input: {0}")] #[error("Invalid input: {0}")]
InvalidInput(String), InvalidInput(String),
#[error("Authentication error: {0}")]
Auth(String),
#[error("Unauthorized")]
Unauthorized,
} }
impl ResponseError for AppError { impl ResponseError for AppError {
fn error_response(&self) -> HttpResponse { fn error_response(&self) -> HttpResponse {
match self { match self {
AppError::NotFound => HttpResponse::NotFound().json("Not found"), AppError::NotFound => HttpResponse::NotFound().json("Not found"),
AppError::Database(_) => HttpResponse::InternalServerError().json("Internal server error"), AppError::Database(err) => HttpResponse::InternalServerError().json(format!("Database error: {}", err)), // Show actual error
AppError::InvalidInput(msg) => HttpResponse::BadRequest().json(msg), AppError::InvalidInput(msg) => HttpResponse::BadRequest().json(msg),
AppError::Auth(msg) => HttpResponse::BadRequest().json(msg),
AppError::Unauthorized => HttpResponse::Unauthorized().json("Unauthorized"),
} }
} }
} }

View file

@ -1,7 +1,10 @@
use actix_web::{web, HttpResponse, Responder, HttpRequest}; use actix_web::{web, HttpResponse, Responder, HttpRequest};
use crate::{AppState, error::AppError, models::{CreateLink, Link}}; 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 regex::Regex;
use argon2::{password_hash::{rand_core::OsRng, SaltString}, PasswordVerifier};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use argon2::{Argon2, PasswordHash, PasswordHasher};
use crate::auth::{AuthenticatedUser};
lazy_static! { lazy_static! {
static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap(); static ref VALID_CODE_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_-]{1,32}$").unwrap();
@ -9,14 +12,17 @@ lazy_static! {
pub async fn create_short_url( pub async fn create_short_url(
state: web::Data<AppState>, state: web::Data<AppState>,
user: AuthenticatedUser,
payload: web::Json<CreateLink>, payload: web::Json<CreateLink>,
req: HttpRequest,
) -> Result<impl Responder, AppError> { ) -> Result<impl Responder, AppError> {
tracing::debug!("Creating short URL with user_id: {}", user.user_id);
validate_url(&payload.url)?; validate_url(&payload.url)?;
let short_code = if let Some(ref custom_code) = payload.custom_code { let short_code = if let Some(ref custom_code) = payload.custom_code {
validate_custom_code(custom_code)?; validate_custom_code(custom_code)?;
tracing::debug!("Checking if custom code {} exists", custom_code);
// Check if code is already taken // Check if code is already taken
if let Some(_) = sqlx::query_as::<_, Link>( if let Some(_) = sqlx::query_as::<_, Link>(
"SELECT * FROM links WHERE short_code = $1" "SELECT * FROM links WHERE short_code = $1"
@ -37,15 +43,18 @@ pub async fn create_short_url(
// Start transaction // Start transaction
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
tracing::debug!("Inserting new link with short_code: {}", short_code);
let link = sqlx::query_as::<_, Link>( let link = sqlx::query_as::<_, Link>(
"INSERT INTO links (original_url, short_code) VALUES ($1, $2) RETURNING *" "INSERT INTO links (original_url, short_code, user_id) VALUES ($1, $2, $3) RETURNING *"
) )
.bind(&payload.url) .bind(&payload.url)
.bind(&short_code) .bind(&short_code)
.bind(user.user_id)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .await?;
if let Some(ref source) = payload.source { if let Some(ref source) = payload.source {
tracing::debug!("Adding click source: {}", source);
sqlx::query( sqlx::query(
"INSERT INTO clicks (link_id, source) VALUES ($1, $2)" "INSERT INTO clicks (link_id, source) VALUES ($1, $2)"
) )
@ -94,6 +103,12 @@ pub async fn redirect_to_url(
) -> Result<impl Responder, AppError> { ) -> Result<impl Responder, AppError> {
let short_code = path.into_inner(); 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 mut tx = state.db.begin().await?;
let link = sqlx::query_as::<_, Link>( let link = sqlx::query_as::<_, Link>(
@ -105,7 +120,7 @@ pub async fn redirect_to_url(
match link { match link {
Some(link) => { Some(link) => {
// Record click with user agent as source // Record click with both user agent and query source
let user_agent = req.headers() let user_agent = req.headers()
.get("user-agent") .get("user-agent")
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
@ -113,10 +128,11 @@ pub async fn redirect_to_url(
.to_string(); .to_string();
sqlx::query( sqlx::query(
"INSERT INTO clicks (link_id, source) VALUES ($1, $2)" "INSERT INTO clicks (link_id, source, query_source) VALUES ($1, $2, $3)"
) )
.bind(link.id) .bind(link.id)
.bind(user_agent) .bind(user_agent)
.bind(query_source)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
@ -132,10 +148,12 @@ pub async fn redirect_to_url(
pub async fn get_all_links( pub async fn get_all_links(
state: web::Data<AppState>, state: web::Data<AppState>,
user: AuthenticatedUser,
) -> Result<impl Responder, AppError> { ) -> Result<impl Responder, AppError> {
let links = sqlx::query_as::<_, Link>( let links = sqlx::query_as::<_, Link>(
"SELECT * FROM links ORDER BY created_at DESC" "SELECT * FROM links WHERE user_id = $1 ORDER BY created_at DESC"
) )
.bind(user.user_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await?; .await?;
@ -158,3 +176,88 @@ fn generate_short_code() -> String {
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
encode(uuid.as_u128() as u64).chars().take(8).collect() 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,
},
}))
}

View file

@ -7,6 +7,7 @@ use tracing::info;
mod error; mod error;
mod handlers; mod handlers;
mod models; mod models;
mod auth;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@ -35,7 +36,7 @@ async fn main() -> Result<()> {
.await?; .await?;
// Run database migrations // Run database migrations
sqlx::migrate!("./migrations").run(&pool).await?; //sqlx::migrate!("./migrations").run(&pool).await?;
let state = AppState { db: pool }; let state = AppState { db: pool };
@ -55,7 +56,9 @@ async fn main() -> Result<()> {
.service( .service(
web::scope("/api") web::scope("/api")
.route("/shorten", web::post().to(handlers::create_short_url)) .route("/shorten", web::post().to(handlers::create_short_url))
.route("/links", web::get().to(handlers::get_all_links)), .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( .service(

View file

@ -0,0 +1,30 @@
-- 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);

View file

@ -1,6 +1,28 @@
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; 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)] #[derive(Deserialize)]
pub struct CreateLink { pub struct CreateLink {
pub url: String, pub url: String,
@ -11,7 +33,7 @@ pub struct CreateLink {
#[derive(Serialize, FromRow)] #[derive(Serialize, FromRow)]
pub struct Link { pub struct Link {
pub id: i32, pub id: i32,
pub user_id: i32, pub user_id: Option<i32>,
pub original_url: String, pub original_url: String,
pub short_code: String, pub short_code: String,
pub created_at: chrono::DateTime<chrono::Utc>, pub created_at: chrono::DateTime<chrono::Utc>,