fix: audit fixes — tier-quota bypass, webhook SSRF, auth hardening, pagination, a11y
Full audit (bugs/UI-UX/backend/feature-gap) turned up a money-leak AI quota bypass, webhook SSRF, and a long tail of missing pagination/auth/a11y work. Fixes land together since HANDOFF.md tracked them as one backlog. - AI routes charge tier quota before generating; nutrition POST is author-only - Webhook dispatch re-validates URL per delivery (SSRF/DNS-rebinding), treats redirects as failures; recipe.published now actually dispatches - New indexes/unique constraints on recipes, meal-planning, comments FK cascade - Recipe PUT/restore snapshot only inside the transaction, after validation - Recipe DELETE cleans up S3 objects (recipe + review photos) - Optimistic UI (favorite/star/follow/shopping-list) rolls back on failure - Upload presign enforces file size cap + per-tier storage quota - Route-level loading/error/not-found states across (app), admin, and root - middleware.ts guards (app)/admin; requireAdmin checks DB role, not cached session; rate limiting applied to both session and API-key branches, bucketed per key; Stripe webhook dedupes by event id - Pagination added to recipes, feed, profile, comments, pantry, admin tables - Nav shows real avatar + profile link + dark-mode toggle; destructive actions standardized on AlertDialog - Unsaved-changes guard + real ingredient/step validation on recipe form; canonical /recipes/[id] used in-app; next/image migration; aria-labels and alt text across icon buttons, avatars, recipe photos - packages/api-types removed (zero callers, too drifted to safely rewire); openapi.ts and ai-keys error shape drift fixed; BYOK decrypt failures now surface instead of silently falling back to the platform key Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,10 @@
|
||||
"apiKeys": "API keys",
|
||||
"webhooks": "Webhooks",
|
||||
"language": "Language",
|
||||
"notifications": "Notifications"
|
||||
"notifications": "Notifications",
|
||||
"viewProfile": "View profile",
|
||||
"lightMode": "Light mode",
|
||||
"darkMode": "Dark mode"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
@@ -151,7 +154,8 @@
|
||||
"bulkVisibility": "{count, plural, one {1 recipe set to {visibility}} other {{count} recipes set to {visibility}}}",
|
||||
"bulkDeleteFailed": "Delete failed",
|
||||
"bulkUpdateFailed": "Update failed",
|
||||
"bulkDeleteConfirm": "{count, plural, one {Delete 1 recipe?} other {Delete {count} recipes?}} This cannot be undone.",
|
||||
"bulkDeleteConfirmTitle": "{count, plural, one {Delete 1 recipe?} other {Delete {count} recipes?}}",
|
||||
"bulkDeleteConfirmDescription": "This action cannot be undone.",
|
||||
"deselectAll": "Deselect all",
|
||||
"selectAll": "Select all",
|
||||
"clickToSelect": "Click cards to select",
|
||||
@@ -323,7 +327,8 @@
|
||||
"ariaLabel": "Ask AI about this recipe",
|
||||
"title": "Ask about this recipe",
|
||||
"intro": "Ask anything about this recipe — ingredients, techniques, substitutions, timing…",
|
||||
"inputPlaceholder": "Ask a question…"
|
||||
"inputPlaceholder": "Ask a question…",
|
||||
"send": "Send message"
|
||||
},
|
||||
"servingScaler": {
|
||||
"servings": "Servings",
|
||||
@@ -351,7 +356,10 @@
|
||||
"selectConversation": "Select a conversation",
|
||||
"messageButton": "Message",
|
||||
"startConversationFailed": "Failed to start conversation",
|
||||
"title": "Messages"
|
||||
"title": "Messages",
|
||||
"loadOlder": "Load older messages",
|
||||
"loadingOlder": "Loading…",
|
||||
"send": "Send message"
|
||||
},
|
||||
"nutritionPanel": {
|
||||
"estimateButton": "Estimate nutrition",
|
||||
@@ -414,7 +422,9 @@
|
||||
"anyDifficulty": "Any",
|
||||
"editManually": "Edit manually",
|
||||
"surpriseMe": "Surprise me",
|
||||
"generatingContent": "Generating recipe content…"
|
||||
"generatingContent": "Generating recipe content…",
|
||||
"decrease": "Decrease",
|
||||
"increase": "Increase"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Sign in",
|
||||
@@ -532,7 +542,13 @@
|
||||
"createRecipe": "Create recipe",
|
||||
"updateSuccess": "Recipe updated",
|
||||
"createSuccess": "Recipe created",
|
||||
"saveError": "Failed to save recipe"
|
||||
"saveError": "Failed to save recipe",
|
||||
"ingredientsRequired": "Add at least one ingredient",
|
||||
"stepsRequired": "Add at least one step",
|
||||
"discardChangesTitle": "Discard changes?",
|
||||
"discardChangesDescription": "You have unsaved changes. If you leave now, they will be lost.",
|
||||
"keepEditing": "Keep editing",
|
||||
"discardChanges": "Discard changes"
|
||||
},
|
||||
"canCook": {
|
||||
"title": "What can I cook?",
|
||||
@@ -616,7 +632,8 @@
|
||||
"listCreating": "Creating…",
|
||||
"listCreate": "Create",
|
||||
"pickRecipe": "Pick recipe",
|
||||
"addEntry": "+ Add"
|
||||
"addEntry": "+ Add",
|
||||
"removeEntry": "Remove meal"
|
||||
},
|
||||
"pantry": {
|
||||
"title": "Pantry",
|
||||
@@ -630,6 +647,8 @@
|
||||
"add": "Add",
|
||||
"addFailed": "Failed to add",
|
||||
"removeFailed": "Failed to remove",
|
||||
"removeConfirmTitle": "Remove item?",
|
||||
"removeConfirmDescription": "Remove \"{name}\" from your pantry?",
|
||||
"empty": "No pantry items yet.",
|
||||
"expired": "Expired",
|
||||
"expiresInDays": "Expires in {days}d",
|
||||
@@ -651,7 +670,10 @@
|
||||
"trendingEmpty": "No trending recipes this week.",
|
||||
"forYou": "For You",
|
||||
"forYouEmpty": "Favorite or rate some recipes to get personalized picks here.",
|
||||
"loading": "Loading…"
|
||||
"loading": "Loading…",
|
||||
"loadMore": "Load more",
|
||||
"loadFailed": "Failed to load recipes. Check your connection and try again.",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"shoppingLists": {
|
||||
"title": "Shopping Lists",
|
||||
@@ -748,6 +770,13 @@
|
||||
"commentPlaceholder": "Share your thoughts…",
|
||||
"commentsTitle": "Comments",
|
||||
"noCommentsYet": "No comments yet. Be the first!",
|
||||
"deleteCommentTitle": "Delete comment?",
|
||||
"deleteCommentDescription": "This action cannot be undone.",
|
||||
"blockConfirmTitle": "Block @{username}?",
|
||||
"blockConfirmDescription": "They won't be able to follow you or comment on your recipes.",
|
||||
"blockConfirmAction": "Block",
|
||||
"loadMoreComments": "Load more comments",
|
||||
"loadingMoreComments": "Loading…",
|
||||
"postButton": "Post",
|
||||
"postingButton": "Posting…",
|
||||
"replyButton": "Reply",
|
||||
@@ -768,7 +797,9 @@
|
||||
"signInToReact": "Sign in to react to comments",
|
||||
"reactionFailed": "Failed to update reaction",
|
||||
"addReaction": "Add {type} reaction",
|
||||
"removeReaction": "Remove {type} reaction"
|
||||
"removeReaction": "Remove {type} reaction",
|
||||
"favoriteAdd": "Save recipe",
|
||||
"favoriteRemove": "Unsave recipe"
|
||||
},
|
||||
"cookingMode": {
|
||||
"cooking": "Cooking",
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
"apiKeys": "Clés API",
|
||||
"webhooks": "Webhooks",
|
||||
"language": "Langue",
|
||||
"notifications": "Notifications"
|
||||
"notifications": "Notifications",
|
||||
"viewProfile": "Voir le profil",
|
||||
"lightMode": "Mode clair",
|
||||
"darkMode": "Mode sombre"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
@@ -151,7 +154,8 @@
|
||||
"bulkVisibility": "{count, plural, one {1 recette définie sur {visibility}} other {{count} recettes définies sur {visibility}}}",
|
||||
"bulkDeleteFailed": "Échec de la suppression",
|
||||
"bulkUpdateFailed": "Échec de la mise à jour",
|
||||
"bulkDeleteConfirm": "{count, plural, one {Supprimer 1 recette ?} other {Supprimer {count} recettes ?}} Cette action est irréversible.",
|
||||
"bulkDeleteConfirmTitle": "{count, plural, one {Supprimer 1 recette ?} other {Supprimer {count} recettes ?}}",
|
||||
"bulkDeleteConfirmDescription": "Cette action est irréversible.",
|
||||
"deselectAll": "Tout désélectionner",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"clickToSelect": "Cliquez sur les cartes pour sélectionner",
|
||||
@@ -323,7 +327,8 @@
|
||||
"ariaLabel": "Demander à l'IA à propos de cette recette",
|
||||
"title": "Poser une question sur cette recette",
|
||||
"intro": "Demandez n'importe quoi sur cette recette — ingrédients, techniques, substitutions, minutage…",
|
||||
"inputPlaceholder": "Posez une question…"
|
||||
"inputPlaceholder": "Posez une question…",
|
||||
"send": "Envoyer le message"
|
||||
},
|
||||
"servingScaler": {
|
||||
"servings": "Portions",
|
||||
@@ -351,7 +356,10 @@
|
||||
"selectConversation": "Sélectionnez une conversation",
|
||||
"messageButton": "Message",
|
||||
"startConversationFailed": "Échec du démarrage de la conversation",
|
||||
"title": "Messages"
|
||||
"title": "Messages",
|
||||
"loadOlder": "Charger les messages précédents",
|
||||
"loadingOlder": "Chargement…",
|
||||
"send": "Envoyer le message"
|
||||
},
|
||||
"nutritionPanel": {
|
||||
"estimateButton": "Estimer les valeurs nutritionnelles",
|
||||
@@ -414,7 +422,9 @@
|
||||
"anyDifficulty": "Toutes",
|
||||
"editManually": "Modifier manuellement",
|
||||
"surpriseMe": "Surprends-moi",
|
||||
"generatingContent": "Génération du contenu de la recette…"
|
||||
"generatingContent": "Génération du contenu de la recette…",
|
||||
"decrease": "Diminuer",
|
||||
"increase": "Augmenter"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Se connecter",
|
||||
@@ -520,7 +530,13 @@
|
||||
"createRecipe": "Créer la recette",
|
||||
"updateSuccess": "Recette mise à jour",
|
||||
"createSuccess": "Recette créée",
|
||||
"saveError": "Échec de l'enregistrement de la recette"
|
||||
"saveError": "Échec de l'enregistrement de la recette",
|
||||
"ingredientsRequired": "Ajoutez au moins un ingrédient",
|
||||
"stepsRequired": "Ajoutez au moins une étape",
|
||||
"discardChangesTitle": "Abandonner les modifications ?",
|
||||
"discardChangesDescription": "Vous avez des modifications non enregistrées. Si vous quittez maintenant, elles seront perdues.",
|
||||
"keepEditing": "Continuer l'édition",
|
||||
"discardChanges": "Abandonner"
|
||||
},
|
||||
"canCook": {
|
||||
"title": "Que puis-je cuisiner ?",
|
||||
@@ -604,7 +620,8 @@
|
||||
"listCreating": "Création…",
|
||||
"listCreate": "Créer",
|
||||
"pickRecipe": "Choisir une recette",
|
||||
"addEntry": "+ Ajouter"
|
||||
"addEntry": "+ Ajouter",
|
||||
"removeEntry": "Retirer le repas"
|
||||
},
|
||||
"pantry": {
|
||||
"title": "Garde-manger",
|
||||
@@ -618,6 +635,8 @@
|
||||
"add": "Ajouter",
|
||||
"addFailed": "Échec de l'ajout",
|
||||
"removeFailed": "Échec de la suppression",
|
||||
"removeConfirmTitle": "Retirer cet article ?",
|
||||
"removeConfirmDescription": "Retirer « {name} » du garde-manger ?",
|
||||
"empty": "Aucun article dans le garde-manger.",
|
||||
"expired": "Expiré",
|
||||
"expiresInDays": "Expire dans {days}j",
|
||||
@@ -639,7 +658,10 @@
|
||||
"trendingEmpty": "Aucune recette tendance cette semaine.",
|
||||
"forYou": "Pour vous",
|
||||
"forYouEmpty": "Ajoutez des favoris ou notez des recettes pour des suggestions personnalisées.",
|
||||
"loading": "Chargement…"
|
||||
"loading": "Chargement…",
|
||||
"loadMore": "Charger plus",
|
||||
"loadFailed": "Échec du chargement des recettes. Vérifiez votre connexion et réessayez.",
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"shoppingLists": {
|
||||
"title": "Listes de courses",
|
||||
@@ -736,6 +758,13 @@
|
||||
"commentPlaceholder": "Partagez vos impressions…",
|
||||
"commentsTitle": "Commentaires",
|
||||
"noCommentsYet": "Aucun commentaire pour l'instant. Soyez le premier !",
|
||||
"deleteCommentTitle": "Supprimer le commentaire ?",
|
||||
"deleteCommentDescription": "Cette action est irréversible.",
|
||||
"blockConfirmTitle": "Bloquer @{username} ?",
|
||||
"blockConfirmDescription": "Cette personne ne pourra plus vous suivre ni commenter vos recettes.",
|
||||
"blockConfirmAction": "Bloquer",
|
||||
"loadMoreComments": "Charger plus de commentaires",
|
||||
"loadingMoreComments": "Chargement…",
|
||||
"postButton": "Publier",
|
||||
"postingButton": "Publication…",
|
||||
"replyButton": "Répondre",
|
||||
@@ -756,7 +785,9 @@
|
||||
"signInToReact": "Connectez-vous pour réagir aux commentaires",
|
||||
"reactionFailed": "Échec de la mise à jour de la réaction",
|
||||
"addReaction": "Ajouter la réaction {type}",
|
||||
"removeReaction": "Retirer la réaction {type}"
|
||||
"removeReaction": "Retirer la réaction {type}",
|
||||
"favoriteAdd": "Enregistrer la recette",
|
||||
"favoriteRemove": "Retirer la recette des enregistrements"
|
||||
},
|
||||
"cookingMode": {
|
||||
"cooking": "En cuisine",
|
||||
|
||||
Reference in New Issue
Block a user