diff --git a/.github/ISSUE_TEMPLATE/new-source.yml b/.github/ISSUE_TEMPLATE/new-source.yml index e89574b7e..173621ca0 100644 --- a/.github/ISSUE_TEMPLATE/new-source.yml +++ b/.github/ISSUE_TEMPLATE/new-source.yml @@ -27,15 +27,15 @@ body: - type: dropdown id: category attributes: - label: Categorie / Category + label: Catégorie / Category options: - AI / IA - - Cybersecurity / Cybersecurite + - Cybersecurity / Cybersécurité - IoT - Windows - Mac / Apple - Linux - - Tech Generale + - Tech Générale - Entrepreneuriat / Entrepreneurship - Bourse & Finance - Crypto & Blockchain diff --git a/.github/ISSUE_TEMPLATE/subscribe.yml b/.github/ISSUE_TEMPLATE/subscribe.yml index 2177d7cd6..01ddd1db5 100644 --- a/.github/ISSUE_TEMPLATE/subscribe.yml +++ b/.github/ISSUE_TEMPLATE/subscribe.yml @@ -13,7 +13,7 @@ body: - type: checkboxes id: categories attributes: - label: Categories souhaitees / Desired categories + label: Catégories souhaitées / Desired categories options: - label: AI / IA - label: Cybersecurity @@ -30,9 +30,9 @@ body: - type: dropdown id: language attributes: - label: Langue preferee / Preferred language + label: Langue préférée / Preferred language options: - - Francais + - Français - English - Les deux / Both validations: @@ -40,7 +40,7 @@ body: - type: dropdown id: frequency attributes: - label: Frequence / Frequency + label: Fréquence / Frequency options: - Chaque mise a jour (toutes les 3h) / Every update - Quotidien / Daily digest diff --git a/.github/workflows/add-source.yml b/.github/workflows/add-source.yml index 01d08173b..1f9061bd0 100644 --- a/.github/workflows/add-source.yml +++ b/.github/workflows/add-source.yml @@ -23,6 +23,10 @@ jobs: const issue = context.payload.issue; const body = issue.body; + // Constants + const MAX_SOURCE_NAME_LENGTH = 100; + const MAX_TAG_LENGTH = 50; + // Parse form fields from issue body function getField(label) { const regex = new RegExp(`### ${label}\\s*\\n\\s*(.+)`, 'i'); @@ -33,24 +37,61 @@ jobs: const sourceName = getField('Nom de la source / Source Name'); const sourceUrl = getField('URL du site / Website URL'); const rssUrl = getField('URL du flux RSS / RSS Feed URL'); - const categoryRaw = getField('Categorie / Category'); + const categoryRaw = getField('Catégorie / Category'); const languageRaw = getField('Langue / Language'); const tags = getField('Tags / Mots-cles'); + // Validate required fields if (!sourceName || !rssUrl) { console.log('Missing required fields'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: 'Missing required fields (source name or RSS URL). Please provide all required information.' + }); + return; + } + + // Sanitize and validate source name (prevent injection) + const sanitizedSourceName = sourceName.replace(/[<>'"]/g, '').slice(0, MAX_SOURCE_NAME_LENGTH); + if (sanitizedSourceName !== sourceName) { + console.log('Source name contains invalid characters'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: 'Source name contains invalid characters. Please use only alphanumeric characters, spaces, and basic punctuation.' + }); + return; + } + + // Validate RSS URL format + try { + const urlObj = new URL(rssUrl); + if (!['http:', 'https:'].includes(urlObj.protocol)) { + throw new Error('Invalid protocol'); + } + } catch (e) { + console.log('Invalid RSS URL:', rssUrl); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `Invalid RSS URL format: "${rssUrl}". Please provide a valid HTTP or HTTPS URL.` + }); return; } // Map category const catMap = { 'AI / IA': 'ai', - 'Cybersecurity / Cybersecurite': 'cybersecurity', + 'Cybersecurity / Cybersécurité': 'cybersecurity', 'IoT': 'iot', 'Windows': 'windows', 'Mac / Apple': 'mac', 'Linux': 'linux', - 'Tech Generale': 'tech', + 'Tech Générale': 'tech', 'Entrepreneuriat / Entrepreneurship': 'entrepreneurship', 'Bourse & Finance': 'finance', 'Crypto & Blockchain': 'crypto', @@ -60,10 +101,10 @@ jobs: const category = catMap[categoryRaw] || 'tech'; // Map language - const lang = languageRaw.includes('Francais') ? 'fr' : 'en'; + const lang = languageRaw.includes('Français') ? 'fr' : 'en'; - // Parse tags - const tagList = tags ? tags.split(',').map(t => t.trim()).filter(t => t) : [category]; + // Parse and sanitize tags + const tagList = tags ? tags.split(',').map(t => t.trim().replace(/[<>'"]/g, '').slice(0, MAX_TAG_LENGTH)).filter(t => t) : [category]; // Read and update config.json const config = JSON.parse(fs.readFileSync('config.json', 'utf-8')); @@ -85,9 +126,9 @@ jobs: return; } - // Add new feed + // Add new feed with sanitized data config.categories[category].feeds.push({ - name: sourceName, + name: sanitizedSourceName, url: rssUrl, tags: tagList, lang: lang diff --git a/.github/workflows/manage-subscriber.yml b/.github/workflows/manage-subscriber.yml index 34ada499b..ab6a66812 100644 --- a/.github/workflows/manage-subscriber.yml +++ b/.github/workflows/manage-subscriber.yml @@ -23,6 +23,9 @@ jobs: const issue = context.payload.issue; const body = issue.body; + // Constants + const MAX_AT_SYMBOLS_THRESHOLD = 5; // Maximum @ symbols allowed to prevent email harvesting + function getField(label) { const regex = new RegExp(`### ${label}\\s*\\n\\s*(.+)`, 'i'); const match = body.match(regex); @@ -36,15 +39,48 @@ jobs: return [...match[1].matchAll(/- \[X\] (.+)/gi)].map(m => m[1].trim()); } - const email = getField('Votre email / Your email'); - if (!email || !email.includes('@')) { - console.log('Invalid email'); + // Validate email address format + function isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return ( + typeof email === 'string' && + email.length > 0 && + email.length <= 254 && + emailRegex.test(email) + ); + } + + // Basic abuse heuristic: ignore issues that appear to contain many email-like strings + const atCount = (body.match(/@/g) || []).length; + if (atCount > MAX_AT_SYMBOLS_THRESHOLD) { + console.log('Suspicious issue body (too many @ symbols); skipping subscription.'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: 'This subscription request appears suspicious and has been rejected. If this is an error, please contact the maintainers.' + }); + return; + } + + const rawEmail = getField('Votre email / Your email'); + // In case multiple emails or extra text are provided, only keep the first token + const email = rawEmail.split(/[,\s]+/).filter(Boolean)[0] || ''; + + if (!isValidEmail(email)) { + console.log('Invalid email:', rawEmail); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `Invalid email address format: "${rawEmail}". Please provide a valid email address.` + }); return; } - const selectedCats = getCheckboxes('Categories souhaitees / Desired categories'); - const langRaw = getField('Langue preferee / Preferred language'); - const freqRaw = getField('Frequence / Frequency'); + const selectedCats = getCheckboxes('Catégories souhaitées / Desired categories'); + const langRaw = getField('Langue préférée / Preferred language'); + const freqRaw = getField('Fréquence / Frequency'); const catMap = { 'AI / IA': 'ai', @@ -53,7 +89,7 @@ jobs: 'Windows': 'windows', 'Mac / Apple': 'mac', 'Linux': 'linux', - 'Tech Generale': 'tech', + 'Tech Générale': 'tech', 'Entrepreneuriat': 'entrepreneurship', 'Bourse & Finance': 'finance', 'Crypto & Blockchain': 'crypto', @@ -62,7 +98,7 @@ jobs: }; const categories = selectedCats.map(c => catMap[c]).filter(Boolean); - const lang = langRaw.includes('Francais') ? 'fr' : langRaw.includes('English') ? 'en' : 'all'; + const lang = langRaw.includes('Français') ? 'fr' : langRaw.includes('English') ? 'en' : 'all'; const frequency = freqRaw.includes('Quotidien') ? 'daily' : 'each_update'; // Read subscribers diff --git a/config.json b/config.json index 7193b2b68..9a7205e66 100644 --- a/config.json +++ b/config.json @@ -31,7 +31,7 @@ ] }, "cybersecurity": { - "labels": { "en": "Cybersecurity", "fr": "Cybersecurite" }, + "labels": { "en": "Cybersecurity", "fr": "Cybersécurité" }, "icon": "shield", "priority": 2, "feeds": [ @@ -117,7 +117,7 @@ ] }, "tech": { - "labels": { "en": "General Tech", "fr": "Tech Generale" }, + "labels": { "en": "General Tech", "fr": "Tech Générale" }, "icon": "zap", "priority": 7, "feeds": [ @@ -230,33 +230,33 @@ "artificial_intelligence": { "en": "Artificial Intelligence", "fr": "Intelligence Artificielle" }, "machine_learning": { "en": "Machine Learning", "fr": "Apprentissage Automatique" }, "deep_learning": { "en": "Deep Learning", "fr": "Apprentissage Profond" }, - "cybersecurity": { "en": "Cybersecurity", "fr": "Cybersecurite" }, - "data_breach": { "en": "Data Breach", "fr": "Fuite de Donnees" }, - "privacy": { "en": "Privacy", "fr": "Vie Privee" }, + "cybersecurity": { "en": "Cybersecurity", "fr": "Cybersécurité" }, + "data_breach": { "en": "Data Breach", "fr": "Fuite de Données" }, + "privacy": { "en": "Privacy", "fr": "Vie Privée" }, "home_automation": { "en": "Home Automation", "fr": "Domotique" }, - "connected_objects": { "en": "Connected Objects", "fr": "Objets Connectes" }, + "connected_objects": { "en": "Connected Objects", "fr": "Objets Connectés" }, "open_source": { "en": "Open Source", "fr": "Logiciel Libre" }, - "update": { "en": "Update", "fr": "Mise a jour" }, - "vulnerability": { "en": "Vulnerability", "fr": "Vulnerabilite" }, - "neural_network": { "en": "Neural Network", "fr": "Reseau de Neurones" }, + "update": { "en": "Update", "fr": "Mise à jour" }, + "vulnerability": { "en": "Vulnerability", "fr": "Vulnérabilité" }, + "neural_network": { "en": "Neural Network", "fr": "Réseau de Neurones" }, "computer_vision": { "en": "Computer Vision", "fr": "Vision par Ordinateur" }, "robotics": { "en": "Robotics", "fr": "Robotique" }, "quantum_computing": { "en": "Quantum Computing", "fr": "Informatique Quantique" }, "stock_market": { "en": "Stock Market", "fr": "Bourse" }, "cryptocurrency": { "en": "Cryptocurrency", "fr": "Cryptomonnaie" }, - "fundraising": { "en": "Fundraising", "fr": "Levee de Fonds" }, + "fundraising": { "en": "Fundraising", "fr": "Levée de Fonds" }, "entrepreneurship": { "en": "Entrepreneurship", "fr": "Entrepreneuriat" }, "startup": { "en": "Startup", "fr": "Startup" }, "investment": { "en": "Investment", "fr": "Investissement" }, "bug_bounty": { "en": "Bug Bounty", "fr": "Bug Bounty" }, "exploit": { "en": "Exploit", "fr": "Exploit" }, - "regulation": { "en": "Regulation", "fr": "Regulation" }, + "regulation": { "en": "Regulation", "fr": "Réglementation" }, "new_product": { "en": "New Product", "fr": "Nouveau Produit" }, "launch": { "en": "Launch", "fr": "Lancement" }, "crowdfunding": { "en": "Crowdfunding", "fr": "Financement Participatif" }, - "repository": { "en": "Repository", "fr": "Depot" }, - "self_hosted": { "en": "Self-hosted", "fr": "Auto-heberge" }, - "library": { "en": "Library", "fr": "Bibliotheque" }, + "repository": { "en": "Repository", "fr": "Dépôt" }, + "self_hosted": { "en": "Self-hosted", "fr": "Auto-hébergé" }, + "library": { "en": "Library", "fr": "Bibliothèque" }, "tool": { "en": "Tool", "fr": "Outil" } } } diff --git a/js/tracker.js b/js/tracker.js index a731be7c7..5adbceac8 100644 --- a/js/tracker.js +++ b/js/tracker.js @@ -53,16 +53,23 @@ * Le visitorId est généré localement et ne permet pas d'identifier la personne. */ const Tracker = { + // Check if localStorage is available + isLocalStorageAvailable: function() { + try { + const test = '__localStorage_test__'; + localStorage.setItem(test, test); + localStorage.removeItem(test); + return true; + } catch (e) { + return false; + } + }, - /** - * ------------------------------------------------------------------------- - * MÉTHODE : init() - * ------------------------------------------------------------------------- - * Initialise le tracker au chargement de la page. - * Appelée automatiquement à la fin de ce fichier. - */ init: function () { - // Enregistrer cette visite + if (!this.isLocalStorageAvailable()) { + console.warn('localStorage is not available. Tracking features will be disabled.'); + return; + } this.trackVisit(); // Afficher des infos de débogage dans la console @@ -119,15 +126,17 @@ const Tracker = { * 5. Sauvegarde dans localStorage */ trackVisit: function () { - // Récupérer les stats existantes ou créer un nouvel objet - let stats = JSON.parse(localStorage.getItem('ai_pulse_stats')) || { - visitorId: this.generateUUID(), // ID unique pour ce navigateur - sessions: 0, // Nombre de sessions/visites - pageViews: 0, // Nombre de pages vues - lastVisit: 0, // Timestamp de la dernière visite - firstVisit: Date.now(), // Timestamp de la première visite - locations: [], // Liste des localisations - articleClicks: 0 // Nombre d'articles cliqués + if (!this.isLocalStorageAvailable()) return; + + try { + let stats = JSON.parse(localStorage.getItem('ai_pulse_stats')) || { + visitorId: this.generateUUID(), + sessions: 0, + pageViews: 0, + lastVisit: 0, + firstVisit: Date.now(), + locations: [], + articleClicks: 0 }; const now = Date.now(); @@ -161,6 +170,9 @@ const Tracker = { // Rendre les stats accessibles depuis d'autres scripts window.aiPulseStats = stats; window.aiPulseTracker = this; + } catch (e) { + console.error('Error tracking visit:', e); + } }, @@ -237,18 +249,26 @@ const Tracker = { * 2. Ajoute l'article à l'historique de lecture */ trackArticleClick: function (articleData) { - let stats = this.getStats(); + if (!this.isLocalStorageAvailable()) return; + + try { + let stats = this.getStats(); + stats.articleClicks = (stats.articleClicks || 0) + 1; + localStorage.setItem('ai_pulse_stats', JSON.stringify(stats)); - // Incrémenter le compteur de clics - stats.articleClicks = (stats.articleClicks || 0) + 1; - localStorage.setItem('ai_pulse_stats', JSON.stringify(stats)); + // Also add to read history + if ( + articleData.url && + typeof ReadHistory !== 'undefined' && + typeof ReadHistory.markRead === 'function' + ) { + ReadHistory.markRead(articleData.url, articleData.title || 'Unknown'); + } - // Ajouter à l'historique de lecture - if (articleData.url) { - ReadHistory.markRead(articleData.url, articleData.title || 'Unknown'); + console.log("Article tracked:", articleData.title); + } catch (e) { + console.error('Error tracking article click:', e); } - - console.log("Article tracked:", articleData.title); }, @@ -510,23 +530,29 @@ const ReadHistory = { * @param {string} title - Titre de l'article */ markRead: function (url, title) { - var articles = this.getAll(); - - // Vérifier si pas déjà lu - if (!this.isRead(url, title)) { - // Ajouter l'article - articles.push({ - url: url, - title: title, - readAt: Date.now() - }); - - // Garder maximum 500 entrées (supprimer les plus anciennes) - if (articles.length > 500) { - articles = articles.slice(-500); + try { + if (!Tracker.isLocalStorageAvailable()) return; + + var articles = this.getAll(); + if (!articles.some(function (a) { return a.url === url; })) { + articles.push({ url: url, title: title, readAt: Date.now() }); + // Keep max 500 entries to avoid localStorage bloat + // Average article entry ~200 bytes, 500 * 200 = 100KB (well within 5-10MB limit) + if (articles.length > 500) { + articles = articles.slice(-500); + } + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(articles)); + } + } catch (e) { + console.error('Error marking article as read:', e); + // If localStorage is full, remove oldest entries and try again + try { + var articles = this.getAll(); + articles = articles.slice(-250); // Keep only recent 250 + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(articles)); + } catch (e2) { + console.error('Failed to recover from localStorage error:', e2); } - - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(articles)); } }, diff --git a/readme-viewer.html b/readme-viewer.html index 531954c38..373a0166b 100644 --- a/readme-viewer.html +++ b/readme-viewer.html @@ -458,7 +458,7 @@
Votre digest personnalise - {{DATE}}
+Votre digest personnalisé - {{DATE}}
- Vous recevez ce mail car vous etes abonne a AI-Pulse ({{EMAIL}}). + Vous recevez ce mail car vous êtes abonné à AI-Pulse ({{EMAIL}}).