Skip to content

Commit 82c03bf

Browse files
committed
feat(rate-limit): implement comprehensive rate limiting and listen validation
Add multi-tier rate limiting system with IP-based tracking for anonymous users and user-based tracking for authenticated users. Implements global request limits (150 req/min, 10 req/10sec burst) and song-specific listen validation including duration-based cooldowns. Key changes: - Add ListenValidator for validating listen requests before recording - Implement global rate limit middleware with minute and burst protection - Add RateLimited error variant with retry_after_secs support - Extract song/album fetching logic from listen methods for validation - Add ConnectInfo extraction for IP-based rate limiting - Update comprehensive README with features, setup, and API documentation The rate limiting prevents abuse while allowing normal usage patterns, with authenticated users receiving more generous limits than anonymous users.
1 parent 86d8284 commit 82c03bf

File tree

11 files changed

+734
-126
lines changed

11 files changed

+734
-126
lines changed

README.md

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,128 @@
1-
# audio-collection-manager-rust
1+
# Audio Collection Manager API (Rust)
2+
3+
# README WIP
4+
5+
A high-performance audio collection management API built with Rust, Axum, and SurrealDB.
6+
7+
## Features
8+
9+
- 🎵 Song, Album, and Artist management
10+
- 👤 User authentication and authorization
11+
- ⭐ Favorites and playlists
12+
- 🔍 Advanced search capabilities
13+
- 📊 Listen tracking and statistics
14+
- 🏆 Badge system for user achievements
15+
- 🛡️ Rate limiting for authenticated and anonymous users
16+
17+
## Rate Limiting
18+
19+
The API implements two types of rate limiting:
20+
21+
### Authenticated Users
22+
- Maximum 100 listens per hour
23+
- Maximum 10 listens per minute
24+
- Cannot listen to the same song twice within 70% of its duration (minimum 10 seconds)
25+
26+
### Anonymous Users (IP-based)
27+
- Maximum 5 listens per minute per IP address
28+
- Encourages users to sign in for unlimited listening
29+
- Automatic cleanup of old tracking records
30+
31+
## Database Setup
32+
33+
Run the following migration scripts in order:
34+
35+
```bash
36+
# Main database schema
37+
surreal import --conn http://localhost:8000 --user root --pass root --ns your_namespace --db your_database database_schema.surql
38+
39+
# Anonymous listen tracking (for IP-based rate limiting)
40+
surreal import --conn http://localhost:8000 --user root --pass root --ns your_namespace --db your_database database_anonymous_listen_log.surql
41+
42+
# Other migrations as needed
43+
surreal import --conn http://localhost:8000 --user root --pass root --ns your_namespace --db your_database database_events_migration.surql
44+
```
45+
46+
## Environment Variables
47+
48+
Create a `.env` file based on `.env.example`:
49+
50+
```env
51+
DB_URL=http://localhost:8000
52+
DB_NS=your_namespace
53+
DB_NAME=your_database
54+
DB_USER=root
55+
DB_PASSWORD=root
56+
JWT_SECRET=your_secret_key_here
57+
JWT_EXPIRATION=86400
58+
BIND_HOST=0.0.0.0
59+
PORT=8080
60+
```
61+
62+
## Running the API
63+
64+
```bash
65+
# Development
66+
cargo run
67+
68+
# Production build
69+
cargo build --release
70+
./target/release/audio-collection-manager-rust
71+
```
72+
73+
## API Endpoints
74+
75+
### Authentication
76+
- `POST /api/auth/register` - Register new user
77+
- `POST /api/auth/login` - Login user
78+
79+
### Songs
80+
- `POST /api/song/{song_id}/listen` - Record a song listen (supports both authenticated and anonymous users)
81+
- `GET /api/song/recents` - Get user's recent listens (requires auth)
82+
- `GET /api/song/{song_id}/album` - Get album from song
83+
84+
### Albums
85+
- `GET /api/albums` - List all albums
86+
- `GET /api/albums/{album_id}` - Get album details
87+
88+
### Artists
89+
- `GET /api/artists` - List all artists
90+
- `GET /api/artists/{artist_id}` - Get artist details
91+
92+
### Search
93+
- `GET /api/search?q={query}` - Search across songs, albums, and artists
94+
95+
### User (Protected)
96+
- `GET /api/user/profile` - Get user profile
97+
- `GET /api/user/top-songs` - Get user's top songs
98+
- `GET /api/user/badges` - Get user badges
99+
100+
### Playlists (Protected)
101+
- `GET /api/playlist` - List user playlists
102+
- `POST /api/playlist` - Create playlist
103+
- `GET /api/playlist/{playlist_id}` - Get playlist details
104+
105+
### Favorites (Protected)
106+
- `POST /api/favorites/song/{song_id}` - Favorite a song
107+
- `DELETE /api/favorites/song/{song_id}` - Unfavorite a song
108+
109+
## Architecture
110+
111+
- **Framework**: Axum (async web framework)
112+
- **Database**: SurrealDB (multi-model database)
113+
- **Authentication**: JWT tokens
114+
- **Rate Limiting**: In-memory cache + database tracking
115+
- **Logging**: tracing + tracing-subscriber
116+
117+
## Security Features
118+
119+
- JWT-based authentication
120+
- Password hashing with bcrypt
121+
- IP-based rate limiting for anonymous users
122+
- User-based rate limiting for authenticated users
123+
- CORS protection
124+
- Request tracing and logging
125+
126+
## License
127+
128+
See LICENSE file for details.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Create table for anonymous listen tracking with TTL
2+
DEFINE TABLE anonymous_listen_log SCHEMAFULL;
3+
4+
-- Define fields
5+
DEFINE FIELD ip_address ON anonymous_listen_log TYPE string ASSERT $value != NONE;
6+
DEFINE FIELD song_id ON anonymous_listen_log TYPE string ASSERT $value != NONE;
7+
DEFINE FIELD listened_at ON anonymous_listen_log TYPE datetime DEFAULT time::now();
8+
9+
-- Index for efficient querying by IP and timestamp
10+
DEFINE INDEX idx_anonymous_ip_time ON anonymous_listen_log FIELDS ip_address, listened_at;
11+
12+
-- Optional: Event to automatically delete records older than 2 minutes (cleanup)
13+
DEFINE EVENT cleanup_anonymous_logs ON TABLE anonymous_listen_log WHEN true THEN {
14+
DELETE FROM anonymous_listen_log WHERE listened_at < time::now() - 2m;
15+
};

src/controllers/album_controller.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@ use crate::{
33
models::album::{AlbumWithArtists, AlbumWithRelations, AlbumsMetaResponse},
44
models::database_helpers::CountResult,
55
services::album_service::AlbumService,
6+
validators::listen_validator::{ListenValidator, ValidationResult},
67
middlewares::mw_auth::Ctx,
78
AppState,
89
Result,
910
};
1011
use axum::{
11-
extract::{Path, State, Query},
12+
extract::{ConnectInfo, Path, State, Query},
1213
Extension,
1314
Json,
1415
};
1516
use serde::Deserialize;
17+
use std::net::SocketAddr;
1618

1719
pub struct AlbumController;
1820

@@ -125,9 +127,37 @@ impl AlbumController {
125127
pub async fn listen_to_album(
126128
State(state): State<AppState>,
127129
Path(album_id): Path<String>,
130+
ConnectInfo(addr): ConnectInfo<SocketAddr>,
128131
ctx: Option<Extension<Ctx>>,
129132
) -> Result<Json<bool>> {
130133
let user_id = ctx.as_ref().map(|c| c.user_id.as_str());
134+
let client_ip = addr.ip().to_string();
135+
136+
let album = AlbumService::get_album(&state.db, &album_id)
137+
.await?
138+
.ok_or_else(|| Error::AlbumNotFound {
139+
id: album_id.clone(),
140+
})?;
141+
142+
let validation_result = ListenValidator::validate_album_listen(
143+
&state.db,
144+
&album_id,
145+
user_id,
146+
Some(&client_ip),
147+
album.total_duration.as_secs(),
148+
)
149+
.await?;
150+
151+
if let ValidationResult::RateLimited {
152+
reason,
153+
retry_after_secs,
154+
} = validation_result
155+
{
156+
return Err(Error::RateLimited {
157+
reason,
158+
retry_after_secs,
159+
});
160+
}
131161

132162
let success = AlbumService::listen_to_album(&state.db, &album_id, user_id).await?;
133163

src/controllers/song_controller.rs

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,52 @@
11
use crate::{
2-
error::Result,
3-
models::{
2+
error::Result, middlewares::mw_auth::Ctx, models::{
43
album::AlbumWithRelations,
54
pagination::{PaginatedResponse, PaginationQuery},
6-
song::SongWithRelations,
7-
},
8-
services::song_service::{SongService, ListenResult},
9-
middlewares::mw_auth::Ctx,
10-
AppState, Error,
5+
song::{SongWithRelations},
6+
}, services::song_service::{ListenResult, SongService}, validators::listen_validator::{ListenValidator, ValidationResult}, AppState, Error
117
};
128
use axum::{
13-
extract::{Path, Query, State},
9+
extract::{ConnectInfo, Path, Query, State},
1410
Extension, Json,
1511
};
12+
use std::net::SocketAddr;
1613

1714
pub struct SongController;
1815

1916
impl SongController {
2017
pub async fn listen_to_song(
2118
State(state): State<AppState>,
2219
Path(song_id): Path<String>,
20+
ConnectInfo(addr): ConnectInfo<SocketAddr>,
2321
ctx: Option<Extension<Ctx>>,
2422
) -> Result<Json<ListenResult>> {
2523
let user_id = ctx.as_ref().map(|c| c.user_id.as_str());
24+
let client_ip = addr.ip().to_string();
2625

27-
let result = SongService::listen_to_song(&state.db, &song_id, user_id).await?;
26+
let song = SongService::get_song_by_id(&state.db, &song_id)
27+
.await?
28+
.ok_or_else(|| Error::SongNotFound {
29+
id: song_id.clone(),
30+
})?;
31+
32+
let validation_result =
33+
ListenValidator::validate_listen(&state.db, &song_id, user_id, Some(&client_ip), song.duration.as_secs())
34+
.await?;
35+
36+
if let ValidationResult::RateLimited {
37+
reason,
38+
retry_after_secs,
39+
} = validation_result
40+
{
41+
return Err(Error::RateLimited {
42+
reason,
43+
retry_after_secs,
44+
});
45+
}
46+
47+
let result =
48+
SongService::listen_to_song(&state.db, &song_id, user_id, song.duration)
49+
.await?;
2850

2951
Ok(Json(result))
3052
}

src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ pub enum Error {
6060
InvalidInput {
6161
reason: String,
6262
},
63+
RateLimited {
64+
reason: String,
65+
retry_after_secs: u64,
66+
},
6367
}
6468

6569
impl core::fmt::Display for Error {

src/main.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ mod models;
3737
mod routes;
3838
mod services;
3939
mod middlewares;
40+
mod validators;
4041

4142
#[derive(Clone)]
4243
struct AppState {
@@ -87,7 +88,11 @@ async fn main() -> Result<()> {
8788
.nest("/albums", AlbumRoutes::routes())
8889
.nest("/artists", ArtistRoutes::routes())
8990
.nest("/song", SongRoutes::routes())
90-
.nest("/search", SearchRoutes::routes());
91+
.nest("/search", SearchRoutes::routes())
92+
.route_layer(middleware::from_fn_with_state(
93+
app_state.clone(),
94+
middlewares::mw_rate_limit::rate_limit_middleware,
95+
));
9196

9297
let protected_routes = Router::new()
9398
.nest("/user", UserRoutes::routes())
@@ -96,6 +101,10 @@ async fn main() -> Result<()> {
96101
.route_layer(middleware::from_fn_with_state(
97102
app_state.clone(),
98103
middlewares::mw_auth::mw_auth,
104+
))
105+
.route_layer(middleware::from_fn_with_state(
106+
app_state.clone(),
107+
middlewares::mw_rate_limit::rate_limit_middleware,
99108
));
100109

101110
let routes_all = Router::new()

src/middlewares/mw_rate_limit.rs

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,71 @@ use std::net::SocketAddr;
22

33
use axum::{
44
body::Body,
5-
extract::{ConnectInfo, MatchedPath, State},
5+
extract::{ConnectInfo, State},
66
http::{Request, StatusCode},
77
middleware::Next,
88
response::Response,
99
};
1010

1111
use crate::{middlewares::mw_auth::Ctx, AppState};
1212

13+
/// Global rate limiting middleware that only blocks heavy spammers
14+
///
15+
/// Limits:
16+
/// - 150 requests per minute per user/IP (very generous for normal usage)
17+
/// - 10 requests per 10 seconds (prevents rapid bursts)
18+
///
19+
/// This allows users to listen to multiple songs quickly without issues
20+
/// while still protecting against abuse.
21+
///
22+
/// Uses a simple counter-based approach with the moka cache which has TTL support.
1323
pub async fn rate_limit_middleware(
1424
State(app_state): State<AppState>,
1525
ConnectInfo(ip): ConnectInfo<SocketAddr>,
16-
matched_path: MatchedPath,
1726
req: Request<Body>,
1827
next: Next,
1928
) -> Result<Response, StatusCode> {
20-
let path = matched_path.as_str();
21-
let song_id = if let Some(id) = path.split('/').nth(2) {
22-
id
23-
} else {
24-
// This case should ideally not be reached if routes are set up correctly.
25-
// Returning a server error because it indicates a configuration issue.
26-
return Err(StatusCode::INTERNAL_SERVER_ERROR);
27-
};
28-
29-
let key = req
29+
// Identify the requester (user or IP)
30+
let identifier = req
3031
.extensions()
3132
.get::<Ctx>()
32-
.map(|ctx| format!("{}:{}", ctx.user_id, song_id))
33-
.unwrap_or_else(|| format!("{}:{}", ip, song_id));
33+
.map(|ctx| format!("user:{}", ctx.user_id))
34+
.unwrap_or_else(|| format!("ip:{}", ip.ip()));
3435

35-
if app_state.rate_limit_cache.get(&key).await.is_some() {
36+
// Generate unique request ID for this request
37+
let request_id = uuid::Uuid::new_v4();
38+
39+
// Minute-based rate limit (150 requests per minute)
40+
let minute_key = format!("rl:min:{}:{}", identifier, request_id);
41+
app_state.rate_limit_cache.insert(minute_key.clone(), ()).await;
42+
43+
// Count requests in the last minute
44+
let minute_prefix = format!("rl:min:{}:", identifier);
45+
let minute_reqs = app_state.rate_limit_cache
46+
.iter()
47+
.filter(|(k, _)| k.starts_with(&minute_prefix))
48+
.count();
49+
50+
const MAX_REQUESTS_PER_MINUTE: usize = 150;
51+
if minute_reqs > MAX_REQUESTS_PER_MINUTE {
52+
return Err(StatusCode::TOO_MANY_REQUESTS);
53+
}
54+
55+
// 10-second burst limit (10 requests per 10 seconds)
56+
// Only check burst if we're under minute limit
57+
let burst_key = format!("rl:burst:{}:{}", identifier, request_id);
58+
app_state.rate_limit_cache.insert(burst_key, ()).await;
59+
60+
let burst_prefix = format!("rl:burst:{}:", identifier);
61+
let burst_reqs = app_state.rate_limit_cache
62+
.iter()
63+
.filter(|(k, _)| k.starts_with(&burst_prefix))
64+
.count();
65+
66+
const MAX_REQUESTS_PER_10SEC: usize = 10;
67+
if burst_reqs > MAX_REQUESTS_PER_10SEC {
3668
return Err(StatusCode::TOO_MANY_REQUESTS);
3769
}
38-
39-
app_state.rate_limit_cache.insert(key, ()).await;
4070

4171
Ok(next.run(req).await)
4272
}

0 commit comments

Comments
 (0)