You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

280 lines
9.0 KiB
Rust

//! Sources handlers.
//!
//! - `GET /api/v1/sources` — list user's sources
//! - `POST /api/v1/sources` — add a single source
//! - `DELETE /api/v1/sources/:id` — delete a source (ownership check)
//! - `POST /api/v1/sources/bulk` — bulk import from JSON array
//! - `POST /api/v1/sources/import-csv` — import from CSV file upload
//! - `GET /api/v1/sources/export-csv` — download sources as CSV
use axum::extract::{Multipart, Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::Deserialize;
use uuid::Uuid;
use crate::app_state::AppState;
use crate::db;
use crate::errors::AppError;
use crate::middleware::auth::AuthUser;
use crate::models::source::{
BulkImportRequest, BulkImportResponse, CreateSourceRequest, SourceResponse,
UpdatePreferredRequest,
};
use crate::services::csv as csv_service;
/// Maximum number of sources a user can have.
const MAX_SOURCES_PER_USER: i64 = 100;
/// Query parameters for `GET /api/v1/sources`.
#[derive(Debug, Deserialize)]
pub struct SourceListQuery {
pub theme_id: Option<Uuid>,
}
/// `GET /api/v1/sources`
///
/// Returns all sources belonging to the authenticated user,
/// ordered by creation date (newest first).
/// Optionally filters by `theme_id` query parameter.
pub async fn list(
auth_user: AuthUser,
State(state): State<AppState>,
Query(params): Query<SourceListQuery>,
) -> Result<impl IntoResponse, AppError> {
let sources = db::sources::list_for_user(&state.pool, auth_user.id, params.theme_id).await?;
let response: Vec<SourceResponse> = sources.into_iter().map(SourceResponse::from).collect();
Ok(Json(response))
}
/// `POST /api/v1/sources`
///
/// Creates a single source for the authenticated user.
/// Validates the title and URL, and checks the per-user source limit.
pub async fn create(
auth_user: AuthUser,
State(state): State<AppState>,
Json(body): Json<CreateSourceRequest>,
) -> Result<impl IntoResponse, AppError> {
// Validate request fields
body.validate().map_err(AppError::Validation)?;
// Check source limit
let count = db::sources::count_for_user(&state.pool, auth_user.id).await?;
if count >= MAX_SOURCES_PER_USER {
return Err(AppError::Validation(format!(
"Maximum of {} sources per user reached",
MAX_SOURCES_PER_USER
)));
}
let source = db::sources::create(&state.pool, auth_user.id, &body.title, &body.url, body.theme_id).await?;
tracing::info!(user_id = %auth_user.id, source_id = %source.id, "Source created");
Ok((StatusCode::CREATED, Json(SourceResponse::from(source))))
}
/// `DELETE /api/v1/sources/:id`
///
/// Deletes a source by ID. Returns 404 (not 403) if the source doesn't exist
/// or doesn't belong to the current user, to avoid leaking information about
/// other users' sources.
pub async fn delete(
auth_user: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let deleted = db::sources::delete(&state.pool, id, auth_user.id).await?;
if !deleted {
return Err(AppError::NotFound("Source not found".into()));
}
tracing::info!(user_id = %auth_user.id, source_id = %id, "Source deleted");
Ok(StatusCode::NO_CONTENT)
}
/// `POST /api/v1/sources/bulk`
///
/// Bulk-imports sources from a JSON array. Validates each entry,
/// skips duplicates (same URL for the same user), and returns a summary.
pub async fn bulk_import(
auth_user: AuthUser,
State(state): State<AppState>,
Json(body): Json<BulkImportRequest>,
) -> Result<impl IntoResponse, AppError> {
if body.sources.is_empty() {
return Err(AppError::Validation("No sources provided".into()));
}
let mut valid_sources: Vec<(String, String)> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for (i, source) in body.sources.iter().enumerate() {
if let Err(msg) = source.validate() {
errors.push(format!("Row {}: {}", i + 1, msg));
continue;
}
valid_sources.push((source.title.clone(), source.url.clone()));
}
let response = do_bulk_import(
&state.pool,
auth_user.id,
&mut valid_sources,
&mut errors,
"Bulk import",
)
.await?;
Ok(Json(response))
}
/// Shared logic for bulk and CSV imports: enforce per-user limit,
/// insert into DB, and build the response summary.
async fn do_bulk_import(
pool: &sqlx::PgPool,
user_id: uuid::Uuid,
valid_sources: &mut Vec<(String, String)>,
errors: &mut Vec<String>,
log_label: &str,
) -> Result<BulkImportResponse, AppError> {
let current_count = db::sources::count_for_user(pool, user_id).await?;
let remaining_capacity = (MAX_SOURCES_PER_USER - current_count).max(0) as usize;
if valid_sources.len() > remaining_capacity {
valid_sources.truncate(remaining_capacity);
errors.push(format!(
"Only {} sources could be imported (limit of {} reached)",
remaining_capacity, MAX_SOURCES_PER_USER
));
}
let created = db::sources::bulk_create(pool, user_id, valid_sources, None).await?;
let imported = created.len();
let skipped = valid_sources.len() - imported;
tracing::info!(
user_id = %user_id,
imported = imported,
skipped = skipped,
errors = errors.len(),
"{} completed", log_label
);
Ok(BulkImportResponse {
imported,
skipped,
errors: errors.clone(),
})
}
/// `POST /api/v1/sources/import-csv`
///
/// Imports sources from a CSV file uploaded via multipart form data.
/// Expects a single file field. Parses the CSV, validates each row,
/// skips duplicates, and returns a summary.
pub async fn import_csv(
auth_user: AuthUser,
State(state): State<AppState>,
mut multipart: Multipart,
) -> Result<impl IntoResponse, AppError> {
// Extract the first file field from the multipart upload
let field = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(format!("Failed to read multipart field: {}", e)))?
.ok_or_else(|| AppError::BadRequest("No file field found in upload".into()))?;
// Validate Content-Type if present (allow text/csv, text/plain, or missing)
if let Some(content_type) = field.content_type() {
let ct = content_type.to_string();
if !ct.starts_with("text/csv")
&& !ct.starts_with("text/plain")
&& !ct.starts_with("application/octet-stream")
{
return Err(AppError::BadRequest(format!(
"Invalid file type: {}. Expected a CSV file (text/csv or text/plain).",
ct
)));
}
}
let content = field
.text()
.await
.map_err(|e| AppError::BadRequest(format!("Failed to read file content: {}", e)))?;
// Parse CSV content into (title, url) pairs
let parsed = csv_service::parse_csv(&content)?;
if parsed.is_empty() {
return Err(AppError::Validation(
"No valid rows found in CSV file".into(),
));
}
// Validate each row
let mut valid_sources: Vec<(String, String)> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for (i, (title, url)) in parsed.iter().enumerate() {
if let Err(msg) = crate::models::source::validate_title(title) {
errors.push(format!("Row {}: {}", i + 1, msg));
continue;
}
if let Err(msg) = crate::models::source::validate_url(url) {
errors.push(format!("Row {}: {}", i + 1, msg));
continue;
}
valid_sources.push((title.clone(), url.clone()));
}
let response =
do_bulk_import(&state.pool, auth_user.id, &mut valid_sources, &mut errors, "CSV import")
.await?;
Ok(Json(response))
}
/// `GET /api/v1/sources/export-csv`
///
/// Returns all of the authenticated user's sources as a CSV file download.
/// Sets the appropriate `Content-Type` and `Content-Disposition` headers.
pub async fn export_csv(
auth_user: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, AppError> {
let sources = db::sources::list_for_user(&state.pool, auth_user.id, None).await?;
let csv_content = csv_service::generate_csv(&sources);
Ok((
StatusCode::OK,
[
(
axum::http::header::CONTENT_TYPE,
"text/csv; charset=utf-8",
),
(
axum::http::header::CONTENT_DISPOSITION,
"attachment; filename=\"sources.csv\"",
),
],
csv_content,
))
}
/// `PUT /api/v1/sources/preferred`
///
/// Bulk-update which sources are marked as preferred.
/// Accepts a list of source IDs; all other sources are set to non-preferred.
pub async fn update_preferred(
auth_user: AuthUser,
State(state): State<AppState>,
Json(body): Json<UpdatePreferredRequest>,
) -> Result<impl IntoResponse, AppError> {
db::sources::update_preferred(&state.pool, auth_user.id, &body.source_ids).await?;
Ok(StatusCode::OK)
}