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
`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
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);
```

134
Cargo.lock generated
View file

@ -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"

View file

@ -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"

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}")]
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"),
}
}
}
}

View file

@ -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<AppState>,
user: AuthenticatedUser,
payload: web::Json<CreateLink>,
req: HttpRequest,
) -> 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"
@ -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<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>(
@ -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<AppState>,
user: AuthenticatedUser,
) -> Result<impl Responder, AppError> {
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<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 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(

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 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<i32>,
pub original_url: String,
pub short_code: String,
pub created_at: chrono::DateTime<chrono::Utc>,