afkaar-gen 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ # Checklist sécurité pour sites générés
2
+
3
+ ## S-1 : Content-Security-Policy (CSP)
4
+
5
+ Dans `.htaccess` :
6
+ ```
7
+ Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data: https://maps.googleapis.com; connect-src 'self' https://maps.googleapis.com; frame-src 'self' https://www.googletagmanager.com"
8
+ ```
9
+
10
+ ## S-2 : Rate limiting formulaire contact
11
+
12
+ Dans `lead-handler.php`, avant traitement :
13
+ ```php
14
+ $ip = $_SERVER['REMOTE_ADDR'] ?? '0';
15
+ $limits = json_read('contact-limits');
16
+ $rec = $limits[$ip] ?? ['count' => 0, 'time' => time()];
17
+ if (time() - $rec['time'] > 900) $rec = ['count' => 0, 'time' => time()];
18
+ if ($rec['count'] >= 5) respond(false, 'Trop de tentatives, réessayez plus tard.');
19
+ $rec['count']++;
20
+ $limits[$ip] = $rec;
21
+ // Nettoyage IP expirées
22
+ foreach ($limits as $k => $v) if (time() - ($v['time'] ?? 0) > 900) unset($limits[$k]);
23
+ json_write('contact-limits', $limits);
24
+ ```
25
+
26
+ ## S-3 : Logging échecs login
27
+
28
+ Dans `auth.php`, après échec :
29
+ ```php
30
+ $log = date('Y-m-d H:i:s') . " | ÉCHEC | IP: $ip | User: $username\n";
31
+ file_put_contents(DATA_DIR . '/login-attempts.log', $log, FILE_APPEND | LOCK_EX);
32
+ ```
33
+
34
+ ## S-4 : Password policy
35
+
36
+ Dans `admin/settings.php`, changement mot de passe :
37
+ ```php
38
+ if (!preg_match('/^(?=.*[A-Z])(?=.*[0-9]).{10,}$/', $new)) {
39
+ flash('Minimum 10 caractères, 1 majuscule, 1 chiffre.', 'err');
40
+ }
41
+ ```
42
+
43
+ ## S-5 : Session timeout
44
+
45
+ Dans `auth.php`, au début de chaque page admin :
46
+ ```php
47
+ if (($admin_user) && (time() - ($_SESSION['last_activity'] ?? 0)) > 1800) {
48
+ admin_logout(); redirect('/admin/login.php');
49
+ }
50
+ $_SESSION['last_activity'] = time();
51
+ ```
52
+
53
+ ## S-6 : Upload validation renforcée
54
+
55
+ Dans `core/functions.php` :
56
+ ```php
57
+ function handle_image_upload(string $field, string $prefix = 'img'): ?string {
58
+ if (empty($_FILES[$field]['name']) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) return null;
59
+ // Sécurité : basename + regex pour éviter path traversal
60
+ $orig = basename((string)$_FILES[$field]['name']);
61
+ if (!preg_match('/^[a-zA-Z0-9._-]+$/', $orig)) return null;
62
+ $tmp = $_FILES[$field]['tmp_name'];
63
+ if (($_FILES[$field]['size'] ?? 0) > 4 * 1024 * 1024) return null;
64
+ $info = @getimagesize($tmp);
65
+ $allowed = [IMAGETYPE_JPEG => 'jpg', IMAGETYPE_PNG => 'png', IMAGETYPE_WEBP => 'webp', IMAGETYPE_GIF => 'gif'];
66
+ if (!$info || !isset($allowed[$info[2]])) return null;
67
+ $ext = $allowed[$info[2]];
68
+ $name = $prefix . '-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . $ext;
69
+ if (!is_dir(UPLOADS_DIR)) @mkdir(UPLOADS_DIR, 0755, true);
70
+ if (!move_uploaded_file($tmp, UPLOADS_DIR . '/' . $name)) return null;
71
+ return UPLOADS_URL . '/' . $name;
72
+ }
73
+ ```
74
+
75
+ ## S-7 : Anti path traversal (général)
76
+
77
+ Tous les paramètres de fichier :
78
+ ```php
79
+ basename($input); // + regex alphanum + tirets
80
+ ```
81
+
82
+ ## S-8 : Cache-Control admin pages
83
+
84
+ Dans `auth.php` ou `layout-top.php` :
85
+ ```php
86
+ header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
87
+ header('Pragma: no-cache');
88
+ ```
89
+
90
+ ## S-9 : Session cookie sécurisé
91
+
92
+ Dans `auth.php` (début) :
93
+ ```php
94
+ session_name(SESSION_NAME);
95
+ session_set_cookie_params([
96
+ 'lifetime' => 0,
97
+ 'path' => '/',
98
+ 'domain' => '',
99
+ 'secure' => !empty($_SERVER['HTTPS']),
100
+ 'httponly' => true,
101
+ 'samesite' => 'Strict',
102
+ ]);
103
+ session_start();
104
+ ```
105
+
106
+ ## S-10 : Protection brute-force login
107
+
108
+ Dans `auth.php` :
109
+ ```php
110
+ // Session : 5 tentatives / 10 min
111
+ $attempts = $_SESSION['login_attempts'] ?? ['count' => 0, 'time' => time()];
112
+ if (time() - $attempts['time'] > 600) $attempts = ['count' => 0, 'time' => time()];
113
+ if ($attempts['count'] >= 5) return false;
114
+
115
+ // IP : 12 tentatives / 15 min
116
+ $ip = $_SERVER['REMOTE_ADDR'] ?? '0';
117
+ $limits = json_read('login-limits');
118
+ $rec = $limits[$ip] ?? ['count' => 0, 'time' => time()];
119
+ if (time() - $rec['time'] > 900) $rec = ['count' => 0, 'time' => time()];
120
+ if ($rec['count'] >= 12) return false;
121
+ ```
122
+
123
+ ## S-11 : XSS — helper e()
124
+
125
+ Toujours utiliser, jamais de `echo $_POST[...]` ou `<?= $_GET[...]` :
126
+ ```php
127
+ function e(?string $s): string {
128
+ return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
129
+ }
130
+ ```
131
+
132
+ ## S-12 : CSRF sur tous les formulaires
133
+
134
+ ```php
135
+ // Génération
136
+ function csrf_token(): string {
137
+ if (session_status() !== PHP_SESSION_ACTIVE) session_start();
138
+ if (empty($_SESSION[CSRF_KEY]))
139
+ $_SESSION[CSRF_KEY] = bin2hex(random_bytes(32));
140
+ return $_SESSION[CSRF_KEY];
141
+ }
142
+ // Validation
143
+ function csrf_check(): bool {
144
+ if (session_status() !== PHP_SESSION_ACTIVE) session_start();
145
+ return isset($_POST['csrf'], $_SESSION[CSRF_KEY])
146
+ && hash_equals($_SESSION[CSRF_KEY], (string)$_POST['csrf']);
147
+ }
148
+ ```
149
+
150
+ ## S-13 : Honeypot anti-spam
151
+
152
+ Dans le formulaire contact :
153
+ ```html
154
+ <input type="text" name="website" tabindex="-1" autocomplete="off"
155
+ style="position:absolute;left:-9999px" aria-hidden="true">
156
+ ```
157
+ Dans `lead-handler.php` :
158
+ ```php
159
+ if (!empty($_POST['website'])) respond(true, 'Merci !');
160
+ ```
161
+
162
+ ## S-14 : Timer anti-spam (3s)
163
+
164
+ ```html
165
+ <input type="hidden" name="_ts" value="<?= time() ?>">
166
+ ```
167
+ ```php
168
+ $ts = (int)($_POST['_ts'] ?? 0);
169
+ if ($ts > 0 && (time() - $ts) < 3) respond(false, 'Envoi trop rapide.');
170
+ ```
171
+
172
+ ## S-15 : Validation email + téléphone
173
+
174
+ ```php
175
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) respond(false, 'Email invalide.');
176
+ if (!preg_match('/^[0-9 +().-]{6,20}$/', $phone)) respond(false, 'Téléphone invalide.');
177
+ ```
178
+
179
+ ## S-16 : Anti header injection email
180
+
181
+ ```php
182
+ $safeName = preg_replace('/[\r\n]+/', ' ', $name);
183
+ ```
184
+
185
+ ## S-17 : Audit trail admin
186
+
187
+ Logger chaque action CRUD :
188
+ ```php
189
+ $log = date('Y-m-d H:i:s') . " | ADMIN | {$_SESSION['admin_user']['username']} | Action: $action | Target: $id\n";
190
+ file_put_contents(DATA_DIR . '/audit.log', $log, FILE_APPEND | LOCK_EX);
191
+ ```
192
+
193
+ ## S-18 : Blocage exécution PHP dans uploads/
194
+
195
+ `assets/uploads/.htaccess` :
196
+ ```
197
+ <FilesMatch "\.(?i:php|phtml|phar)$">
198
+ Require all denied
199
+ </FilesMatch>
200
+ ```
201
+
202
+ ## S-19 : Session regenerée après login
203
+
204
+ ```php
205
+ session_regenerate_id(true);
206
+ ```
207
+
208
+ ## S-20 : Validation slug (anti injection JSON)
209
+
210
+ ```php
211
+ function slugify(string $text): string {
212
+ $text = mb_strtolower(trim($text), 'UTF-8');
213
+ $map = ['à'=>'a','â'=>'a','ä'=>'a','é'=>'e','è'=>'e','ê'=>'e','ë'=>'e','î'=>'i','ï'=>'i','ô'=>'o','ö'=>'o','ù'=>'u','û'=>'u','ü'=>'u','ç'=>'c','œ'=>'oe'];
214
+ $text = strtr($text, $map);
215
+ $text = preg_replace('/[^a-z0-9]+/', '-', $text);
216
+ return trim((string)$text, '-') ?: 'item';
217
+ }
218
+ ```
@@ -0,0 +1,183 @@
1
+ # SEO guide pour sites générés
2
+
3
+ ## Sitemap.xml dynamique
4
+
5
+ Route dans `index.php` :
6
+ ```php
7
+ if ($path === '/sitemap.xml') {
8
+ header('Content-Type: application/xml; charset=utf-8');
9
+ $urls = [APP_URL . '/', APP_URL . '/references', APP_URL . '/blog', APP_URL . '/contact'];
10
+ foreach (json_read('services') as $s) if (!empty($s['active'])) $urls[] = APP_URL . '/services/' . $s['slug'];
11
+ foreach (json_read('references') as $r) if (!empty($r['active'])) $urls[] = APP_URL . '/references/' . $r['slug'];
12
+ foreach (json_read('posts') as $p) if (!empty($p['active'])) $urls[] = APP_URL . '/blog/' . $p['slug'];
13
+ echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
14
+ echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
15
+ foreach ($urls as $u) echo " <url><loc>" . e($u) . "</loc></url>\n";
16
+ echo '</urlset>';
17
+ exit;
18
+ }
19
+ ```
20
+
21
+ ## robots.txt
22
+
23
+ ```txt
24
+ User-agent: *
25
+ Allow: /
26
+ Sitemap: https://monsite.tn/sitemap.xml
27
+ ```
28
+
29
+ ## JSON-LD Organization (header.php)
30
+
31
+ ```html
32
+ <script type="application/ld+json">
33
+ {
34
+ "@context": "https://schema.org",
35
+ "@type": "ProfessionalService",
36
+ "name": "Mon Site",
37
+ "url": "https://monsite.tn",
38
+ "logo": "https://monsite.tn/assets/img/logo.svg",
39
+ "email": "contact@monsite.tn",
40
+ "telephone": "+216 XX XXX XXX",
41
+ "address": {
42
+ "@type": "PostalAddress",
43
+ "streetAddress": "Adresse",
44
+ "addressLocality": "Ville",
45
+ "addressRegion": "Région",
46
+ "addressCountry": "TN"
47
+ },
48
+ "sameAs": ["https://facebook.com/...", "https://instagram.com/..."]
49
+ }
50
+ </script>
51
+ ```
52
+
53
+ ## JSON-LD Service (page service)
54
+
55
+ ```php
56
+ $pageSchemas[] = [
57
+ '@context' => 'https://schema.org', '@type' => 'Service',
58
+ 'name' => $found['title'],
59
+ 'description' => strip_tags($found['excerpt'] ?? ''),
60
+ 'url' => APP_URL . '/services/' . $found['slug'],
61
+ 'areaServed' => ['@type' => 'Country', 'name' => 'Tunisie'],
62
+ 'provider' => ['@type' => 'ProfessionalService', 'name' => APP_NAME, 'url' => APP_URL],
63
+ ];
64
+ ```
65
+
66
+ ## JSON-LD Article (page blog)
67
+
68
+ ```php
69
+ $pageSchemas[] = [
70
+ '@context' => 'https://schema.org', '@type' => 'Article',
71
+ 'headline' => $found['title'],
72
+ 'description' => strip_tags($found['excerpt'] ?? ''),
73
+ 'datePublished' => $found['date'] ?? '',
74
+ 'inLanguage' => 'fr',
75
+ 'image' => !empty($found['image']) ? APP_URL . $found['image'] : null,
76
+ 'author' => ['@type' => 'Organization', 'name' => APP_NAME, 'url' => APP_URL],
77
+ 'publisher' => ['@type' => 'Organization', 'name' => APP_NAME, 'url' => APP_URL],
78
+ 'mainEntityOfPage' => APP_URL . '/blog/' . $found['slug'],
79
+ ];
80
+ ```
81
+
82
+ ## JSON-LD BreadcrumbList
83
+
84
+ ```php
85
+ $pageSchemas[] = [
86
+ '@context' => 'https://schema.org', '@type' => 'BreadcrumbList',
87
+ 'itemListElement' => [
88
+ ['@type' => 'ListItem', 'position' => 1, 'name' => 'Accueil', 'item' => APP_URL . '/'],
89
+ ['@type' => 'ListItem', 'position' => 2, 'name' => 'Catégorie', 'item' => APP_URL . '/blog'],
90
+ ['@type' => 'ListItem', 'position' => 3, 'name' => $found['title']],
91
+ ],
92
+ ];
93
+ ```
94
+
95
+ ## JSON-LD FAQPage (page accueil)
96
+
97
+ ```php
98
+ $pageSchemas[] = [
99
+ '@context' => 'https://schema.org', '@type' => 'FAQPage',
100
+ 'mainEntity' => array_map(fn($q) => [
101
+ '@type' => 'Question', 'name' => $q[0],
102
+ 'acceptedAnswer' => ['@type' => 'Answer', 'text' => $q[1]],
103
+ ], $faq),
104
+ ];
105
+ ```
106
+
107
+ ## Open Graph (header.php)
108
+
109
+ ```html
110
+ <meta property="og:type" content="website">
111
+ <meta property="og:title" content="<?= e($seo['title']) ?>">
112
+ <meta property="og:description" content="<?= e($seo['description']) ?>">
113
+ <meta property="og:url" content="<?= e($seo['canonical']) ?>">
114
+ <meta property="og:image" content="https://monsite.tn/assets/img/og-cover.png">
115
+ <meta name="twitter:card" content="summary_large_image">
116
+ ```
117
+
118
+ ## SEO data management
119
+
120
+ Dans `data/seo.json` :
121
+ ```json
122
+ {
123
+ "global": {
124
+ "default_title": "Titre par défaut",
125
+ "default_description": "Description par défaut",
126
+ "default_keywords": "mots, clés, SEO",
127
+ "og_image": "/assets/img/og-cover.png",
128
+ "gtm_id": "GTM-XXXXXXX",
129
+ "google_verification": ""
130
+ },
131
+ "pages": {
132
+ "home": { "title": "...", "description": "...", "noindex": false },
133
+ "contact": { "title": "...", "description": "...", "noindex": false },
134
+ "references": { "title": "...", "description": "...", "noindex": false },
135
+ "service:slug": { "title": "...", "description": "..." },
136
+ "post:slug": { "title": "...", "description": "..." },
137
+ "reference:slug": { "title": "...", "description": "..." },
138
+ "404": { "title": "Page introuvable", "noindex": true }
139
+ }
140
+ }
141
+ ```
142
+
143
+ ## Fonction seo_for()
144
+
145
+ ```php
146
+ function seo_for(string $pageKey, array $fallback = []): array {
147
+ $seo = json_read('seo');
148
+ $page = $seo['pages'][$pageKey] ?? [];
149
+ $global = $seo['global'] ?? [];
150
+ return [
151
+ 'title' => ($page['title'] ?? '') ?: ($fallback['title'] ?? ($global['default_title'] ?? APP_NAME)),
152
+ 'description' => ($page['description'] ?? '') ?: ($fallback['description'] ?? ($global['default_description'] ?? '')),
153
+ 'keywords' => ($page['keywords'] ?? '') ?: ($global['default_keywords'] ?? ''),
154
+ 'og_image' => ($page['og_image'] ?? '') ?: ($global['og_image'] ?? ''),
155
+ 'canonical' => APP_URL . ($fallback['path'] ?? '/'),
156
+ 'noindex' => !empty($page['noindex']),
157
+ ];
158
+ }
159
+ ```
160
+
161
+ ## Google Analytics (GTM)
162
+
163
+ Dans `header.php`, si `gtm_id` est défini :
164
+ ```html
165
+ <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
166
+ new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
167
+ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
168
+ j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
169
+ f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','GTM-XXXX');</script>
170
+ ```
171
+ Google Search Console via balise meta :
172
+ ```html
173
+ <meta name="google-site-verification" content="...">
174
+ ```
175
+
176
+ ## Lazy loading images
177
+
178
+ Toute balise `<img>` générée DOIT avoir :
179
+ ```html
180
+ <img src="..." alt="..." loading="lazy" decoding="async">
181
+ ```
182
+
183
+ Exceptions : hero image principale (au-dessus-de-la-pli) peut avoir `loading="eager"`.