From 9a43049978e22802e404c30dbf18adde5386e418 Mon Sep 17 00:00:00 2001 From: waveringana Date: Sat, 25 Jan 2025 03:16:35 -0500 Subject: [PATCH] Add auth --- API.md | 133 +++++++++++++++-- Cargo.lock | 134 +++++++++++++++++- Cargo.toml | 2 + migrations/20240301000000_initial.sql | 18 --- .../20240302000000_auth_and_tracking.sql: | 15 -- src/auth.rs | 41 ++++++ src/error.rs | 12 +- src/handlers.rs | 121 ++++++++++++++-- src/main.rs | 7 +- src/migrations/2025125_initial.sql | 30 ++++ src/models.rs | 24 +++- 11 files changed, 471 insertions(+), 66 deletions(-) delete mode 100644 migrations/20240301000000_initial.sql delete mode 100644 migrations/20240302000000_auth_and_tracking.sql: create mode 100644 src/auth.rs create mode 100644 src/migrations/2025125_initial.sql diff --git a/API.md b/API.md index d9bcd1f..70603b4 100644 --- a/API.md +++ b/API.md @@ -3,7 +3,85 @@ ## Base URL `http://localhost:8080` -## Endpoints +## Authentication +The API uses JWT tokens for authentication. Include the token in the Authorization header: +``` +Authorization: Bearer +``` + +### 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. @@ -28,7 +106,7 @@ Response (503 Service Unavailable): ``` ### Create Short URL -Create a new shortened URL with optional custom code. +Create a new shortened URL with optional custom code. Requires authentication. ```bash POST /api/shorten @@ -49,6 +127,7 @@ Examples: ```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" @@ -59,6 +138,7 @@ Response (201 Created): ```json { "id": 1, + "user_id": 1, "original_url": "https://example.com", "short_code": "Xa7Bc9", "created_at": "2024-03-01T12:34:56Z", @@ -70,6 +150,7 @@ Response (201 Created): ```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", @@ -81,6 +162,7 @@ Response (201 Created): ```json { "id": 2, + "user_id": 1, "original_url": "https://example.com", "short_code": "example", "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 -Retrieve all shortened URLs. +Retrieve all shortened URLs for the authenticated user. ```bash GET /api/links @@ -120,7 +209,7 @@ GET /api/links Example: ```bash -curl http://localhost:8080/api/links +curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8080/api/links ``` Response (200 OK): @@ -128,6 +217,7 @@ Response (200 OK): [ { "id": 1, + "user_id": 1, "original_url": "https://example.com", "short_code": "Xa7Bc9", "created_at": "2024-03-01T12:34:56Z", @@ -135,6 +225,7 @@ Response (200 OK): }, { "id": 2, + "user_id": 1, "original_url": "https://example.org", "short_code": "example", "created_at": "2024-03-01T12:35:00Z", @@ -144,15 +235,15 @@ Response (200 OK): ``` ### 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 -GET /{short_code} +GET /{short_code}?source={source} ``` Example: ```bash -curl -i http://localhost:8080/example +curl -i http://localhost:8080/example?source=email ``` Response (307 Temporary Redirect): @@ -169,48 +260,62 @@ Error Response (404 Not Found): ``` ## 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 +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 + 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); ``` diff --git a/Cargo.lock b/Cargo.lock index d51e22f..863f7ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,16 +9,18 @@ dependencies = [ "actix-cors", "actix-web", "anyhow", + "argon2", "base62", "chrono", "clap", "dotenv", + "jsonwebtoken", "lazy_static", "regex", "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "tracing-subscriber", @@ -352,6 +354,18 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" 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" @@ -418,6 +432,15 @@ dependencies = [ "serde", ] +[[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" @@ -915,8 +938,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1249,6 +1274,21 @@ dependencies = [ "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]] name = "language-tags" version = "0.3.2" @@ -1418,6 +1458,16 @@ dependencies = [ "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]] name = "num-bigint-dig" version = "0.8.4" @@ -1559,12 +1609,33 @@ dependencies = [ "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]] 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" @@ -1726,6 +1797,21 @@ version = "0.8.5" 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" @@ -1930,6 +2016,18 @@ dependencies = [ "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]] name = "slab" version = "0.4.9" @@ -2031,7 +2129,7 @@ dependencies = [ "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-stream", "tracing", @@ -2116,7 +2214,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "uuid", "whoami", @@ -2156,7 +2254,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "uuid", "whoami", @@ -2269,7 +2367,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 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]] @@ -2283,6 +2390,17 @@ dependencies = [ "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]] name = "thread_local" version = "1.1.8" @@ -2505,6 +2623,12 @@ 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" version = "2.5.4" diff --git a/Cargo.toml b/Cargo.toml index 6c22c8e..7b3a07e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +jsonwebtoken = "9" actix-web = "4.4" actix-cors = "0.6" tokio = { version = "1.36", features = ["full"] } @@ -21,3 +22,4 @@ dotenv = "0.15" chrono = { version = "0.4", features = ["serde"] } regex = "1.10" lazy_static = "1.4" +argon2 = "0.5.3" diff --git a/migrations/20240301000000_initial.sql b/migrations/20240301000000_initial.sql deleted file mode 100644 index 297aebd..0000000 --- a/migrations/20240301000000_initial.sql +++ /dev/null @@ -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); diff --git a/migrations/20240302000000_auth_and_tracking.sql: b/migrations/20240302000000_auth_and_tracking.sql: deleted file mode 100644 index 37d5786..0000000 --- a/migrations/20240302000000_auth_and_tracking.sql: +++ /dev/null @@ -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; diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..b4db6a2 --- /dev/null +++ b/src/auth.rs @@ -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>; + + 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::( + 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)) + } +} \ No newline at end of file diff --git a/src/error.rs b/src/error.rs index 7c4982b..0b9b95c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,14 +11,22 @@ pub enum AppError { #[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(_) => 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::Auth(msg) => HttpResponse::BadRequest().json(msg), + AppError::Unauthorized => HttpResponse::Unauthorized().json("Unauthorized"), } } -} +} \ No newline at end of file diff --git a/src/handlers.rs b/src/handlers.rs index 3fb8376..4214a80 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,7 +1,10 @@ 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 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(); @@ -9,14 +12,17 @@ lazy_static! { pub async fn create_short_url( state: web::Data, + user: AuthenticatedUser, payload: web::Json, - req: HttpRequest, ) -> Result { + 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" @@ -36,16 +42,19 @@ pub async fn create_short_url( // 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) VALUES ($1, $2) RETURNING *" + "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)" ) @@ -54,7 +63,7 @@ pub async fn create_short_url( .execute(&mut *tx) .await?; } - + tx.commit().await?; Ok(HttpResponse::Created().json(link)) } @@ -94,6 +103,12 @@ pub async fn redirect_to_url( ) -> Result { let short_code = path.into_inner(); + // Extract query source if present + let query_source = req.uri() + .query() + .and_then(|q| web::Query::>::from_query(q).ok()) + .and_then(|params| params.get("source").cloned()); + let mut tx = state.db.begin().await?; let link = sqlx::query_as::<_, Link>( @@ -105,7 +120,7 @@ pub async fn redirect_to_url( match link { Some(link) => { - // Record click with user agent as source + // Record click with both user agent and query source let user_agent = req.headers() .get("user-agent") .and_then(|h| h.to_str().ok()) @@ -113,10 +128,11 @@ pub async fn redirect_to_url( .to_string(); 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(user_agent) + .bind(query_source) .execute(&mut *tx) .await?; @@ -132,10 +148,12 @@ pub async fn redirect_to_url( pub async fn get_all_links( state: web::Data, + user: AuthenticatedUser, ) -> Result { 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) .await?; @@ -158,3 +176,88 @@ fn generate_short_code() -> String { let uuid = Uuid::new_v4(); encode(uuid.as_u128() as u64).chars().take(8).collect() } + +pub async fn register( + state: web::Data, + payload: web::Json, +) -> Result { + 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, + payload: web::Json, +) -> Result { + 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, + }, + })) +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 5016f85..bb7d97b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use tracing::info; mod error; mod handlers; mod models; +mod auth; #[derive(Clone)] pub struct AppState { @@ -35,7 +36,7 @@ async fn main() -> Result<()> { .await?; // Run database migrations - sqlx::migrate!("./migrations").run(&pool).await?; + //sqlx::migrate!("./migrations").run(&pool).await?; let state = AppState { db: pool }; @@ -55,7 +56,9 @@ async fn main() -> Result<()> { .service( web::scope("/api") .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( diff --git a/src/migrations/2025125_initial.sql b/src/migrations/2025125_initial.sql new file mode 100644 index 0000000..0662f15 --- /dev/null +++ b/src/migrations/2025125_initial.sql @@ -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); \ No newline at end of file diff --git a/src/models.rs b/src/models.rs index 20063b4..de9a92c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,6 +1,28 @@ +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, @@ -11,7 +33,7 @@ pub struct CreateLink { #[derive(Serialize, FromRow)] pub struct Link { pub id: i32, - pub user_id: i32, + pub user_id: Option, pub original_url: String, pub short_code: String, pub created_at: chrono::DateTime,