Add auth
This commit is contained in:
parent
c048377bcc
commit
9a43049978
11 changed files with 471 additions and 66 deletions
133
API.md
133
API.md
|
@ -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
134
Cargo.lock
generated
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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);
|
|
|
@ -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
41
src/auth.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
12
src/error.rs
12
src/error.rs
|
@ -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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
121
src/handlers.rs
121
src/handlers.rs
|
@ -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"
|
||||||
|
@ -36,16 +42,19 @@ 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)"
|
||||||
)
|
)
|
||||||
|
@ -54,7 +63,7 @@ pub async fn create_short_url(
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(HttpResponse::Created().json(link))
|
Ok(HttpResponse::Created().json(link))
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
|
@ -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(
|
||||||
|
|
30
src/migrations/2025125_initial.sql
Normal file
30
src/migrations/2025125_initial.sql
Normal 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);
|
|
@ -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>,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue