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
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)
|
|
}
|