fix: P1 audit items — CSV export theme filter, theme validation, ownership checks, history enums, i18n

- export_csv now accepts optional theme_id query param and filters accordingly
- Add UpdateThemeRequest::validate() with bounds checking; call it in the update handler
- Verify theme ownership in sources::create when theme_id is provided
- Update STATUS_OPTIONS (add filtered_too_old, filtered_not_article; remove filtered_duplicate) and SOURCE_TYPE_OPTIONS (add brave_search; remove overflow) in ArticleHistory
- Replace hardcoded French strings ('Confirmer', 'Erreur inconnue') with t() calls; add settings.apiKeys.unknownError key to fr.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
oabrivard 2 months ago
parent d5d624b896
commit b124d73c2a

@ -60,6 +60,14 @@ pub async fn create(
// Validate request fields
body.validate().map_err(AppError::Validation)?;
// Verify theme ownership if theme_id is provided
if let Some(tid) = body.theme_id {
let theme = db::themes::get_by_id(&state.pool, auth_user.id, tid).await?;
if theme.is_none() {
return Err(AppError::NotFound("Theme not found".into()));
}
}
// Check source limit
let count = db::sources::count_for_user(&state.pool, auth_user.id).await?;
if count >= MAX_SOURCES_PER_USER {
@ -264,15 +272,23 @@ pub async fn import_csv(
Ok(Json(response))
}
/// Query parameters for `GET /api/v1/sources/export-csv`.
#[derive(Debug, Deserialize)]
pub struct CsvExportQuery {
pub theme_id: Option<Uuid>,
}
/// `GET /api/v1/sources/export-csv`
///
/// Returns all of the authenticated user's sources as a CSV file download.
/// Returns the authenticated user's sources as a CSV file download.
/// Optionally filters by `theme_id` query parameter.
/// Sets the appropriate `Content-Type` and `Content-Disposition` headers.
pub async fn export_csv(
auth_user: AuthUser,
State(state): State<AppState>,
Query(params): Query<CsvExportQuery>,
) -> Result<impl IntoResponse, AppError> {
let sources = db::sources::list_for_user(&state.pool, auth_user.id, None).await?;
let sources = db::sources::list_for_user(&state.pool, auth_user.id, params.theme_id).await?;
let csv_content = csv_service::generate_csv(&sources);
Ok((

@ -75,6 +75,8 @@ pub async fn update(
Path(id): Path<Uuid>,
Json(body): Json<UpdateThemeRequest>,
) -> Result<impl IntoResponse, AppError> {
body.validate().map_err(AppError::Validation)?;
let categories = body
.categories
.as_ref()

@ -84,6 +84,50 @@ pub struct UpdateThemeRequest {
pub summary_length: Option<i32>,
}
impl UpdateThemeRequest {
pub fn validate(&self) -> Result<(), String> {
if let Some(ref name) = self.name {
if name.trim().is_empty() {
return Err("Theme name cannot be empty".into());
}
if name.len() > 200 {
return Err("Theme name must be at most 200 characters".into());
}
}
if let Some(ref theme) = self.theme {
if theme.trim().is_empty() {
return Err("Theme search topic cannot be empty".into());
}
}
if let Some(ref categories) = self.categories {
if categories.len() > 20 {
return Err("At most 20 categories are allowed".into());
}
for (i, cat) in categories.iter().enumerate() {
if cat.trim().is_empty() {
return Err(format!("Category at index {} cannot be empty", i));
}
}
}
if let Some(max) = self.max_items_per_category {
if !(1..=50).contains(&max) {
return Err("max_items_per_category must be between 1 and 50".into());
}
}
if let Some(days) = self.max_age_days {
if !(1..=365).contains(&days) {
return Err("max_age_days must be between 1 and 365".into());
}
}
if let Some(sl) = self.summary_length {
if !(1..=3).contains(&sl) {
return Err("summary_length must be between 1 and 3".into());
}
}
Ok(())
}
}
/// Response shape for theme API.
#[derive(Debug, Serialize)]
pub struct ThemeResponse {

@ -129,7 +129,7 @@ const ProviderKeyCard: Component<ProviderKeyCardProps> = (props) => {
});
}
} catch (err) {
const message = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: 'Erreur inconnue' });
const message = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: t('settings.apiKeys.unknownError') });
addToast({ type: 'error', message, duration: 5000 });
} finally {
setTesting(false);

@ -55,7 +55,7 @@ const SettingsBraveSearch: Component<SettingsBraveSearchProps> = (props) => {
addToast({ type: 'error', message: t('settings.apiKeys.testFailure', { message: result.message }), duration: 6000 });
}
} catch (err) {
const msg = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: 'Erreur inconnue' });
const msg = isApiError(err) ? err.message : t('settings.apiKeys.testFailure', { message: t('settings.apiKeys.unknownError') });
addToast({ type: 'error', message: msg, duration: 5000 });
} finally {
setBraveTesting(false);

@ -287,6 +287,7 @@ const fr = {
'settings.apiKeys.deleted': 'Cle API supprimee avec succes.',
'settings.apiKeys.deleteError': 'Erreur lors de la suppression de la cle API.',
'settings.apiKeys.loadError': 'Erreur lors du chargement des cles API.',
'settings.apiKeys.unknownError': 'Erreur inconnue',
'settings.apiKeys.noWebSearch':
'Ce fournisseur ne supporte pas la recherche web native. Le scraping backend sera utilise.',

@ -18,17 +18,18 @@ const PAGE_SIZE = 50;
const STATUS_OPTIONS = [
'used',
'filtered_empty',
'filtered_too_old',
'filtered_history',
'filtered_diversity',
'filtered_homepage',
'filtered_duplicate',
'filtered_not_article',
'filtered_cross_phase_dedup',
];
const SOURCE_TYPE_OPTIONS = [
'personalized_source',
'web_search',
'overflow',
'brave_search',
];
function statusBadgeClass(status: string): string {
@ -137,7 +138,7 @@ const ArticleHistory: Component = () => {
: 'bg-white text-red-600 border-red-300 hover:bg-red-50'
}`}
>
{confirming() ? 'Confirmer' : t('articleHistory.clearAll')}
{confirming() ? t('common.confirm') : t('articleHistory.clearAll')}
</button>
</div>

Loading…
Cancel
Save