File: /homepages/35/d993672390/htdocs/dermiteseborrheique/api/scan-barcode.php
<?php
/**
* api/scan-barcode.php — Endpoint AJAX pour lookup produit
*
* Appelé par le scanner caméra et la recherche textuelle
*
* GET ?barcode=3600550361020 → lookup par code-barres
* GET ?search=ducray&limit=10 → recherche par nom
* POST {barcode, name, brand, ...} → ajout manuel d'un produit
*/
define('DS_APP', true);
define('DS_API', true); // Pas d'en-têtes HTML
require_once dirname(__DIR__) . '/includes/init.php';
require_once DS_INCLUDES . 'openbeautyfacts.php';
// ── Auth requise ──────────────────────────────────────────────────────────────
auth_require();
// ── CORS pour les requêtes AJAX internes ──────────────────────────────────────
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
// ── Rate limiting simple (anti-spam) ─────────────────────────────────────────
$ip = get_client_ip();
$rateKey = 'scan_' . md5($ip);
$cacheFile = sys_get_temp_dir() . '/' . $rateKey . '.tmp';
if (file_exists($cacheFile)) {
$data = json_decode(file_get_contents($cacheFile), true);
if ($data && $data['count'] > 60 && (time() - $data['ts']) < 60) {
json_response(['success' => false, 'error' => 'Trop de requêtes. Attendez 1 minute.'], 429);
}
if ((time() - $data['ts']) > 60) {
$data = ['count' => 1, 'ts' => time()];
} else {
$data['count']++;
}
} else {
$data = ['count' => 1, 'ts' => time()];
}
file_put_contents($cacheFile, json_encode($data));
// ── Routage selon méthode HTTP ────────────────────────────────────────────────
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
match($method) {
'GET' => handleGet(),
'POST' => handlePost(),
default => json_response(['success' => false, 'error' => 'Méthode non autorisée.'], 405),
};
// ── GET — lookup barcode ou recherche textuelle ───────────────────────────────
function handleGet(): never
{
// Lookup par code-barres
if (!empty($_GET['barcode'])) {
$barcode = trim($_GET['barcode']);
// 1. Chercher dans Open Beauty Facts (cosmétiques)
$product = OpenBeautyFacts::lookupBarcode($barcode);
$source = 'openbeautyfacts';
// 2. Si pas trouvé → chercher dans Open Food Facts (aliments/boissons)
if (!$product) {
$product = lookupOpenFoodFacts($barcode);
$source = 'openfoodfacts';
}
if (!$product) {
json_response([
'success' => false,
'error' => "Produit introuvable. Essayez de l'ajouter manuellement.",
'barcode' => $barcode,
], 404);
}
// Enregistrer dans user_items et incrémenter usage
if (!empty($product['id'])) {
db_execute("UPDATE items SET usage_count = usage_count + 1 WHERE id = ?", [$product['id']]);
$u = auth_user();
if ($u) {
db_execute(
"INSERT INTO user_items (user_id, item_id) VALUES (?, ?)
ON DUPLICATE KEY UPDATE usage_count = usage_count + 1, scanned_at = NOW()",
[$u['id'], $product['id']]
);
}
// Si produit sans ingrédients -> enrichir depuis OFF/OBF
$ingCount = db_count(
"SELECT COUNT(*) FROM item_ingredients WHERE item_id = ?",
[$product['id']]
);
if ($ingCount === 0 && !empty($product['barcode'])) {
$enriched = lookupOpenFoodFacts($product['barcode'])
?? lookupFromOBF($product['barcode']);
if ($enriched && !empty($enriched['ingredients'])) {
// Mettre à jour les ingrédients texte
db_execute(
"UPDATE items SET ingredients = ? WHERE id = ?",
[$enriched['ingredients'], $product['id']]
);
// Sauvegarder les ingrédients parsés si fournis via JS
}
}
}
json_response([
'success' => true,
'source' => $source,
'product' => formatProductResponse($product),
'needs_ingredients' => ($ingCount ?? 0) === 0,
]);
}
// Recherche textuelle (autocomplete)
if (!empty($_GET['search'])) {
$query = sanitize_string(trim($_GET['search']), 100);
$limit = min((int)($_GET['limit'] ?? 10), 20);
if (mb_strlen($query) < 2) {
json_response(['success' => false, 'error' => 'Requête trop courte (min. 2 caractères).'], 400);
}
$results = OpenBeautyFacts::searchByName($query, $limit);
json_response([
'success' => true,
'query' => $query,
'count' => count($results),
'results' => array_map('formatProductResponse', $results),
]);
}
// Récupérer un item existant par ID
if (!empty($_GET['item_id'])) {
$itemId = (int)$_GET['item_id'];
$item = db_fetch("SELECT * FROM items WHERE id = ?", [$itemId]);
if (!$item) {
json_response(['success' => false, 'error' => 'Item introuvable.'], 404);
}
json_response([
'success' => true,
'product' => formatProductResponse($item),
]);
}
json_response(['success' => false, 'error' => 'Paramètre manquant (barcode, search ou item_id).'], 400);
}
// ── POST — ajout manuel d'un produit ─────────────────────────────────────────
function handlePost(): never
{
csrf_verify();
$user = auth_user();
// Lire le body JSON ou les données POST classiques
$body = [];
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (str_contains($contentType, 'application/json')) {
$body = json_decode(file_get_contents('php://input'), true) ?? [];
} else {
$body = $_POST;
}
// Validation
$name = sanitize_string(trim($body['name'] ?? ''), 200);
if (!$name) {
json_response(['success' => false, 'error' => 'Le nom du produit est requis.'], 400);
}
$category = $body['category'] ?? 'cosmetic';
$allowed = ['food','drink','alcohol','tobacco','cosmetic','haircare','medication','supplement','other'];
if (!in_array($category, $allowed)) $category = 'other';
$barcode = OpenBeautyFacts::sanitizeBarcode($body['barcode'] ?? '');
$brand = sanitize_string($body['brand'] ?? '', 100);
$ingredients = sanitize_string($body['ingredients'] ?? '', 65535);
// Si ingrédients fournis → analyser les suspects
$warningIngredients = null;
if ($ingredients) {
$warnings = OpenBeautyFacts::detectSuspectIngredients($ingredients);
if (!empty($warnings)) {
$warningIngredients = json_encode($warnings, JSON_UNESCAPED_UNICODE);
}
}
// Vérifier doublons (même nom + marque pour cet utilisateur)
$existing = db_fetch(
"SELECT id FROM items
WHERE (user_id = ? OR is_global = 1)
AND name = ?
AND (brand = ? OR brand IS NULL)
LIMIT 1",
[$user['id'], $name, $brand]
);
if ($existing) {
$item = db_fetch("SELECT * FROM items WHERE id = ?", [$existing['id']]);
json_response([
'success' => true,
'duplicate' => true,
'message' => 'Ce produit existe déjà dans notre base.',
'product' => formatProductResponse($item),
]);
}
// Créer le nouvel item (privé à l'utilisateur)
$itemId = db_insert(
"INSERT INTO items
(user_id, category, name, brand, barcode, ingredients, warning_ingredients, is_global)
VALUES (?, ?, ?, ?, ?, ?, ?, 0)",
[
$user['id'],
$category,
$name,
$brand ?: null,
$barcode ?: null,
$ingredients ?: null,
$warningIngredients,
]
);
$item = db_fetch("SELECT * FROM items WHERE id = ?", [$itemId]);
// Lier à l'utilisateur via user_items
db_execute(
"INSERT INTO user_items (user_id, item_id) VALUES (?, ?)
ON DUPLICATE KEY UPDATE usage_count = usage_count + 1",
[$user['id'], $itemId]
);
// Sauvegarder les ingrédients individuels si fournis
if (!empty($body['parsed_ingredients']) && is_array($body['parsed_ingredients'])) {
db_execute("DELETE FROM item_ingredients WHERE item_id = ?", [$itemId]);
foreach (array_slice($body['parsed_ingredients'], 0, 200) as $ing) {
$inci = sanitize_string($ing['inci_name'] ?? '', 300);
if (!$inci) continue;
db_execute(
"INSERT IGNORE INTO item_ingredients (item_id, inci_name, common_name, position, is_suspect)
VALUES (?, ?, ?, ?, ?)",
[
$itemId,
$inci,
sanitize_string($ing['common_name'] ?? '', 300) ?: null,
(int)($ing['position'] ?? 0),
!empty($ing['is_suspect']) ? 1 : 0,
]
);
}
}
json_response([
'success' => true,
'created' => true,
'message' => 'Produit ajouté avec succès.',
'product' => formatProductResponse($item),
], 201);
}
// ── Lookup Open Beauty Facts ──────────────────────────────────────────────────
function lookupFromOBF(string $barcode): ?array
{
$barcode = preg_replace('/[^0-9]/', '', $barcode);
if (!$barcode) return null;
if (!function_exists('curl_init')) return null;
$url = 'https://world.openbeautyfacts.org/api/v2/product/' . $barcode . '.json?fields=code,product_name,brands,ingredients_text,image_front_url';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_USERAGENT => 'DermiteSeborrheique/1.0',
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) return null;
$data = json_decode($response, true);
if (empty($data['product']) || ($data['status'] ?? 0) !== 1) return null;
$p = $data['product'];
return [
'ingredients' => $p['ingredients_text'] ?? '',
'image_url' => $p['image_front_url'] ?? null,
];
}
// ── Lookup Open Food Facts ────────────────────────────────────────────────────
function lookupOpenFoodFacts(string $barcode): ?array
{
$barcode = preg_replace('/[^0-9]/', '', $barcode);
if (!$barcode) return null;
// Vérifier cache local d'abord
$cached = db_fetch(
"SELECT * FROM items WHERE barcode = ? AND is_global = 1 LIMIT 1",
[$barcode]
);
if ($cached) return $cached;
// Appel API Open Food Facts
if (!function_exists('curl_init')) return null;
$url = 'https://world.openfoodfacts.org/api/v2/product/' . $barcode . '.json?fields=code,product_name,brands,ingredients_text,image_front_url,categories_tags,nutriments,nova_group';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_USERAGENT => 'DermiteSeborrheique/1.0 (contact@dermiteseborrheique.fr)',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) return null;
$data = json_decode($response, true);
if (empty($data['product']) || ($data['status'] ?? 0) !== 1) return null;
$p = $data['product'];
$name = $p['product_name'] ?? '';
if (empty($name)) return null;
// Déterminer la catégorie
$categories = $p['categories_tags'] ?? [];
$category = 'food';
foreach ($categories as $cat) {
if (str_contains($cat, 'beverages') || str_contains($cat, 'drinks')) {
$category = 'drink'; break;
}
if (str_contains($cat, 'alcoholic') || str_contains($cat, 'wine') || str_contains($cat, 'beer')) {
$category = 'alcohol'; break;
}
}
$ingredients = $p['ingredients_text'] ?? '';
// Détecter ingrédients suspects (réutilise la logique OBF)
$warnings = null;
if ($ingredients) {
$w = OpenBeautyFacts::detectSuspectIngredients($ingredients);
if (!empty($w)) $warnings = json_encode($w, JSON_UNESCAPED_UNICODE);
}
// Sauvegarder comme produit global
$itemId = db_insert(
"INSERT IGNORE INTO items
(user_id, category, name, brand, barcode, ingredients, warning_ingredients, image_url, is_global, source)
VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, 1, 'off')",
[
$category,
$name,
$p['brands'] ?? null,
$barcode,
$ingredients ?: null,
$warnings,
$p['image_front_url'] ?? null,
]
);
return db_fetch("SELECT * FROM items WHERE id = ?", [$itemId]) ?: [
'id' => 0,
'category' => $category,
'name' => $name,
'brand' => $p['brands'] ?? '',
'barcode' => $barcode,
'ingredients' => $ingredients,
'warning_ingredients' => $warnings,
'image_url' => $p['image_front_url'] ?? null,
];
}
// ── Formatage de la réponse produit ──────────────────────────────────────────
function formatProductResponse(array $item): array
{
$warnings = [];
if (!empty($item['warning_ingredients'])) {
$raw = is_string($item['warning_ingredients'])
? json_decode($item['warning_ingredients'], true)
: $item['warning_ingredients'];
$warnings = $raw ?? [];
}
return [
'id' => (int)($item['id'] ?? 0),
'barcode' => $item['barcode'] ?? null,
'name' => $item['name'] ?? '',
'brand' => $item['brand'] ?? '',
'category' => $item['category'] ?? 'cosmetic',
'ingredients' => $item['ingredients'] ?? '',
'image_url' => $item['image_url'] ?? null,
'has_warnings' => !empty($warnings),
'warning_count' => count($warnings),
'warnings' => $warnings,
// Résumé rapide pour l'UI
'warning_summary' => !empty($warnings)
? implode(', ', array_column(array_slice($warnings, 0, 3), 'label'))
: null,
];
}