Changes In Branch dev Excluding Merge-Ins
This is equivalent to a diff from 69b90fa62a to 66b22c9173
2023-03-19
| ||
00:04 | Create new branch named "blocks-editor" check-in: e6b4bfd0b6 user: bohwaz tags: blocks-editor | |
2023-03-15
| ||
11:24 | Implement validate_only for {{:save}} Leaf check-in: 66b22c9173 user: bohwaz tags: dev | |
11:24 | Update doc on priority of skeletons check-in: 1fa2fe5faf user: bohwaz tags: dev | |
2023-03-12
| ||
00:32 | Don't use JournalLib as type in FEC import check-in: b2219bae19 user: bohwaz tags: trunk, stable | |
2023-03-11
| ||
19:24 | Merge trunk into dev check-in: 5134a286ff user: bohwaz tags: dev | |
19:18 | Fix title in internal MD doc check-in: 69b90fa62a user: bohwaz tags: trunk | |
19:14 | Add title to Markdown doc pages check-in: a70b4e7738 user: bohwaz tags: trunk | |
Modified doc/admin/brindille.md from [04a28217a2] to [326665aff2].
1 2 3 4 5 6 7 8 9 10 11 | Title: Documentation du langage Brindille dans Paheko {{{.nav * **[Documentation Brindille](brindille.html)** * [Fonctions](brindille_functions.html) * [Sections](brindille_sections.html) * [Filtres](brindille_modifiers.html) }}} <<toc aside>> | > > > > | > > > > > > > > > | > | | < | > > > > > > > > > | | | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | > > > > > > > > > > > > > > > > | > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | Title: Documentation du langage Brindille dans Paheko {{{.nav * **[Documentation Brindille](brindille.html)** * [Fonctions](brindille_functions.html) * [Sections](brindille_sections.html) * [Filtres](brindille_modifiers.html) }}} <<toc aside>> # Introduction La syntaxe utilisée dans les squelettes du site web et des modules s'appelle **Brindille**. Si vous avez déjà fait de la programmation, elle ressemble à un mélange de Mustache, Smarty, Twig et PHP. Son but est de permettre une grande flexibilité, sans avoir à utiliser un "vrai" langage de programmation, mais en s'en rapprochant suffisamment quand même. ## Fichiers Un fichier texte contenant du code Brindille est appelé un **squelette**. Seuls les fichiers ayant une des extensions `.tpl`, `.html`, `.htm`, `.skel` ou `.xml` seront traités par Brindille. De même, les fichiers qui n'ont pas d'extension seront également traités par Brindille. Les autres types de fichiers seront renvoyés sans traitement, comme des fichiers "bruts". En d'autres termes, il n'est pas possible de mettre du code *Brindille* dans des fichiers qui ne sont pas des fichiers textes. # Syntaxe de base ## Affichage de variable Une variable est affichée à l'aide de la syntaxe : `{{$date}}` affichera la valeur brute de la date par exemple : `2020-01-31 16:32:00`. La variable peut être modifiée à l'aide de filtres de modification, qui sont ajoutés avec le symbole de la barre verticale (pipe `|`) : `{{$date|date_long}}` affichera une date au format long : `jeudi 7 mars 2021`. Ces filtres peuvent accepter des paramètres, séparés par deux points `:`. Exemple : `{{$date|date:"%d/%m/%Y"}}` affichera `31/01/2020`. Par défaut la variable sera recherchée dans le contexte actuel de la section, si elle n'est pas trouvée elle sera recherchée dans le contexte parent (section parente), etc. jusqu'à trouver la variable. Il est possible de faire référence à une variable d'un contexte particulier avec la notation à points : `{{$article.date}}`. La même syntaxe est utilisée pour accéder aux membres d'un tableau : `{{$labels.new_page}}`. Il existe deux variables de contexte spécifiques : `$_POST` et `$_GET` qui permettent d'accéder aux données envoyées dans un formulaire et dans les paramètres de la page. Par défaut le filtre `escape` est appliqué à toutes les variables pour protéger les variables contre les injections de code HTML. Ce filtre est appliqué en dernier, après les autres filtres. Il est possible de contourner cet automatisme en rajoutant le filtre `escape` ou `raw` explicitement. `raw` désactive tout échappement, alors que `escape` est utilisé pour changer l'ordre d'échappement. Exemple : ``` {{:assign text = "Coucou\nça va ?" }} {{$text|escape|nl2br}} ``` Donnera bien `Coucou<br />ça va ?`. Si on n'avait pas indiqué le filtre `escape` le résultat serait `Coucou<br />ça va ?`. ### Échappement des caractères spéciaux dans les chaînes de caractère Pour inclure un caractère spécial (retour de ligne, guillemets ou apostrophe) dans une chaîne de caractère il suffit d'utiliser un antislash : ``` {{:assign text="Retour \n à la ligne"}} {{:assign text="Utiliser des \"apostrophes\"}} ``` ## Ordre de recherche des variables Par défaut les variables sont recherchées dans l'ordre inverse, c'est à dire que sont d'abord recherchées les variables avec le nom demandé dans la section courante. Si la variable n'existe pas dans la section courante, alors elle est recherchée dans la section parente, et ainsi de suite jusqu'à ce que la variable soit trouvée, où qu'il n'y ait plus de section parente. Prenons cet exemple : ``` {{#articles uri="Actualite"}} <h1>{{$title}}</h1> {{#images parent=$path limit=1}} <img src="{{$thumb_url}}" alt="{{$title}}" /> {{/images}} {{/articles}} ``` Dans la section `articles`, `$title` est une variable de l'article, donc la variable est celle de l'article. Dans la section `images`, les images n'ayant pas de titre, la variable sera celle de l'article de la section parente, alors que `$thumb_url` sera lié à l'image. ## Conflit de noms de variables Imaginons que nous voulions mettre un lien vers l'article sur l'image de l'exemple précédent : ``` {{#articles uri="Actualite"}} <h1>{{$title}}</h1> {{#images parent=$path limit=1}} {{/images}} {{/articles}} ``` Problème, ici `$url` fera référence à l'URL de l'image elle-même, et non pas l'URL de l'article. La solution est d'ajouter un point au début du nom de variable : `{{$.url}}`. Un point au début d'un nom de variable signifie que la variable est recherchée à partir de la section précédente. Il est possible d'utiliser plusieurs points, chaque point correspond à un niveau à remonter. Ainsi `$.url` cherchera la variable dans la section parente (et ses sections parentes si elle n'existe pas, etc.). De même, `$..url` cherchera dans la section parente de la section parente. ## Création manuelle de variable ### Variable simple La création d'une variable se fait via l'appel de la fonction `{{:assign}}`. Exemple : ``` {{:assign source='wiki'}} {{* est identique à : *}} {{:assign var='source' value='wiki'}} ``` Un deuxième appel à `{{:assign}}` avec le même nom de variable écrase la valeur précédente ``` {{:assign var='source' value='wiki'}} {{:assign var='source' value='documentation'}} {{$source}} {{* => Affiche documentation *}} ``` ### Nom de variable dynamique Il est possible de créer une variable dont une partie du nom est dynamique. ``` {{:assign type='user'}} {{:assign var='allowed_%s'|args:$type value='jeanne'}} {{:assign type='side'}} {{:assign var='allowed_%s'|args:$type value='admin'}} {{$allowed_user}} => jeanne {{$allowed_side}} => admin ``` [Documentation complète de la fonction {{:assign}}](brindille_functions.html#assign). ### Tableaux *(array)* Pour créer des tableaux, il suffit d'utiliser des points `.` dans le nom de la variable (ex : `colors.yellow`). Il n'y a pas besoin d'initialiser le tableau avant de le remplir. ``` {{:assign var='colors.admin' value='blue'}} {{:assign var='colors.website' value='grey'}} {{:assign var='colors.debug' value='yellow'}} ``` On accède ensuite à la valeur d'un élément du tableau avec la même syntaxe : `{{$colors.website}}` Méthode rapide de création du même tableau : ``` {{:assign var='colors' admin='blue' website='grey' debug='yellow'}} ``` Pour ajouter un élément à la suite du tableau sans spécifier de clef *(push)*, il suffit de terminer le nom de la variable par un point `.` sans suffixe. Exemple : ``` {{* Ajouter les valeurs 17, 43 et 214 dans $processed_ids *}} {{:assign var='processed_ids.' value=17}} {{:assign var='processed_ids.' value=43}} {{:assign var='processed_ids.' value=214}} ``` #### Clef dynamique de tableau Il est possible d'accéder dynamiquement à un des éléments d'un tableau de la manière suivante : ``` {{:assign location='admin'}} {{:assign var='location_color' from='colors.%s'|args:$location}} {{$location_color}} => blue ``` Exemple plus complexe : ``` {{:assign var='type_whitelist.text' value=1}} {{:assign var='type_whitelist.html' value=1}} {{#foreach from=$documents item='document'}} {{:assign var='allowed' value='type_whitelist.%s'|args:$document->type}} {{if $allowed !== null}} {{:include file='document/'|cat:$type:'.tpl' keep='document'}} {{/if}} {{/foreach}} ``` Il est également possible de créer un membre dynamique d'un tableau en conjuguant les syntaxes précédentes. Exemple : ``` {{:assign var='type_whitelist.%s'|args:$type value=1}} ``` ## Conditions Il est possible d'utiliser des conditions de type **"si"** (`if`), **"sinon si"** (`elseif`) et **"sinon"** (`else`). Celles-ci sont terminées par un block **"fin si"** (`/if`). ``` {{if $date|date:"%Y" > 2020}} La date est en 2020 {{elseif $article.status == 'draft'}} La page est un brouillon {{else}} Autre chose. {{/if}} ``` ### Test d'existence Brindille ne fait pas de différences entre une variable qui n'existe pas, et une variable définie à `null`. On peut donc tester l'existence d'une variable en la comparant à `null` comme ceci : ``` {{if $session !== null}} Session en cours pour l'utilisateur/trice {{$session->user->name}}. {{else}} Session inexistante. {{/if}} ``` ## Fonctions ### Fonctions natives Une fonction va répondre à certains paramètres et renvoyer un résultat ou réaliser une action. **Un bloc de fonction commence par le signe deux points `:`.** ``` {{:http code=404}} ``` Contrairement aux autres types de blocs, et comme pour les variables, il n'y a pas de bloc fermant (avec un slash `/`). |
︙ | ︙ | |||
118 119 120 121 122 123 124 | Un exemple de sous-section ``` {{#categories uri=$_GET.uri}} <h1>{{$title}}</h1> {{#articles parent=$path order="published DESC" limit="10"}} | | | > | > | | | > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 | Un exemple de sous-section ``` {{#categories uri=$_GET.uri}} <h1>{{$title}}</h1> {{#articles parent=$path order="published DESC" limit="10"}} <h2></h2> <p>{{$content|truncate:600:"..."}}</p> {{else}} <p>Aucun article trouvé.</p> {{/articles}} {{/categories}} ``` Voir la référence des sections pour voir quelles sont les sections possibles et quel est leur comportement. ## Bloc litéral Pour qu'une partie du code ne soit pas interprété, pour éviter les conflits avec certaines syntaxes, il est possible d'utiliser un bloc `literal` : ``` {{literal}} <script> // Ceci ne sera pas interprété function test (a) {{ }} </script> {{/literal}} ``` ## Commentaires Les commentaires sont figurés dans des blocs qui commencent et se terminent par une étoile (`*`) : ``` {{* Ceci est un commentaire Il sera supprimé du résultat final Il peut contenir du code qui ne sera pas interprété : {{if $test}} OK {{/if}} *}} ``` # Liste des variables définies par défaut Ces variables sont définies tout le temps : | Nom de la variable | Valeur | | :- | :- | | `$_GET` | Alias de la super-globale _GET de PHP. | | `$_POST` | Alias de la super-globale _POST de PHP. | | `$root_url` | Adresse racine du site web Paheko. | | `$request_url` | Adresse de la page courante. | | `$admin_url` | Adresse de la racine de l'administration Paheko. | | `$visitor_lang` | Langue préférée du visiteur, sur 2 lettres (exemple : `fr`, `en`, etc.). | | `$logged_user` | Informations sur le membre actuellement connecté dans l'administration (vide si non connecté). | | `$dialog` | Vaut `TRUE` si la page est dans un dialogue (iframe sous forme de pop-in dans l'administration). | | `$now` | Contient la date et heure courante. | | `$legal_line` | Contient la ligne de bas de page des mentions légales (sous forme de code HTML) qui doit être présente en bas des pages publiques. | | `$config.org_name` | Nom de l'association | | `$config.org_email` | Adresse e-mail de l'association | | `$config.org_phone` | Numéro de téléphone de l'association | | `$config.org_address` | Adresse postale de l'association | | `$config.org_web` | Adresse du site web de l'association | | `$config.files.logo` | Adresse du logo de l'association, si définit dans la personnalisation | | `$config.files.favicon` | Adresse de l'icône de favoris de l'association, si défini dans la personnalisation | | `$config.files.signature` | Adresse de l'image de signature, si défini dans la personnalisation | À celles-ci s'ajoutent [les variables spéciales des modules](modules.html#variables_speciales) lorsque le script est chargé dans un module. # Erreurs Si une erreur survient dans un squelette, que ça soit au niveau d'une erreur de syntaxe, ou une erreur dans une fonction, filtre ou section, alors elle sera affichée selon les règles suivantes : * si le membre connecté est administrateur, une erreur est affichée avec le code du squelette ; * sinon l'erreur est affichée sans le code. # Avertissement sur la sécurité des requêtes SQL Attention, en utilisant la section `{{#select ...}}`, ou une des sections SQL (voir plus bas), avec des paramètres qui ne seraient pas protégés, il est possible qu'une personne mal intentionnée ait accès à des parties de la base de données à laquelle vous ne désirez pas donner accès. Pour protéger contre cela il est essentiel d'utiliser les paramètres nommés. Exemple de requête dangereuse : ``` {{#sql select="*" tables="users" where="id = %s"|args:$_GET.id}} ... {{/sql}} ``` On se dit que la requête finale sera donc : `SELECT * FROM users WHERE id = 42;` si le numéro 42 est passé dans le paramètre `id` de la page. Imaginons qu'une personne mal-intentionnée indique dans le paramètre `id` de la page la chaîne de caractère suivante : `0 OR 1`. Dans ce cas la requête exécutée sera `SELECT * FROM users WHERE id = 0 OR 1;`. Cela aura pour effet de lister tous les membres, au lieu d'un seul. Pour protéger contre cela il convient d'utiliser un paramètre nommé : ``` {{#sql select="*" tables="users" where="id = :id" :id=$_GET.id}} ``` Dans ce cas la requête malveillante générée sera `SELECT * FROM users WHERE id = '0 OR 1';`. Ce qui aura pour effet de ne lister aucun membre. ## Mesures prises pour la sécurité des données Dans Brindille, il n'est pas possible de modifier ou supprimer des éléments dans la base de données avec les requêtes SQL directement. Seules les requêtes SQL en lecture (`SELECT`) sont permises. Cependant certaines fonctions permettent de modifier ou créer des éléments précis (écritures par exemple), ce qui peut avoir un effet de remplir ou modifier des données par une personne mal-intentionnée, donc attention à leur utilisation. Les autres mesures prises sont : * impossibilité d'accéder à certaines données sensibles (mot de passe, logs de connexion, etc.) * incitation forte à utiliser les paramètres nommés dans la documentation * protection automatique des variables dans la section `{{#select}}` * fourniture de fonctions pour protéger les chaînes de caractères contre l'injection SQL |
Modified doc/admin/brindille_functions.md from [5a9c2c0ee5] to [4cfdb06ea7].
1 2 3 4 5 6 7 8 9 10 11 | Title: Référence des fonctions Brindille {{{.nav * [Documentation Brindille](brindille.html) * **[Fonctions](brindille_functions.html)** * [Sections](brindille_sections.html) * [Filtres](brindille_modifiers.html) }}} <<toc aside>> | > > | | > > > > > > > > > > > < < | > > > > > > > > > > > > > > | | | > > > > > > > > > > > > > > > | > > > > > > > > > > > > > > > > > | | | | | | | | > > | | | | > > > < | > | > > > > > > | > > > > | > > > | > > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | | | > > > > > > > > > | < < | > | | | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 | Title: Référence des fonctions Brindille {{{.nav * [Documentation Brindille](brindille.html) * **[Fonctions](brindille_functions.html)** * [Sections](brindille_sections.html) * [Filtres](brindille_modifiers.html) }}} <<toc aside>> # Fonctions généralistes ## assign Permet d'assigner une valeur dans une variable. | Paramètre | Optionnel / obligatoire ? | Fonction | | :- | :- | :- | | `.` | optionnel | Assigner toutes les variables du contexte (section) actuel | | `var` | optionnel | Nom de la variable à créer ou modifier | | `value` | optionnel | Valeur de la variable | | `from` | optionnel | Recopier la valeur depuis la variable ayant le nom fourni dans ce paramètre. | Tous les autres paramètres sont considérés comme des variables à assigner. Exemple : ``` {{:assign blabla="Coucou"}} {{$blabla}} ``` Il est possible d'assigner toutes les variables d'une section dans une variable en utilisant le paramètre point `.` (`.="nom_de_variable"`). Cela permet de capturer le contenu d'une section pour le réutiliser à un autre endroit. ``` {{#pages uri="Informations" limit=1}} {{:assign .="infos"}} {{/pages}} {{$infos.title}} ``` Il est aussi possible de remonter dans les sections parentes en utilisant plusieurs points. Ainsi deux points remonteront à la section parente, trois points à la section parente de la section parente, etc. ``` {{#foreach from=$infos item="info"}} {{#foreach from=$info item="sous_info"}} {{if $sous_info.titre == 'Coucou'}} {{:assign ..="info_importante"}} {{/if}} {{/foreach}} {{/foreach}} {{$info_importante.titre}} ``` En utilisant le paramètre spécial `var`, tous les autres paramètres passés sont ajoutés à la variable donnée en valeur : ``` {{:assign var="tableau" label="Coucou" name="Pif le chien"}} {{$tableau.label}} {{$tableau.name}} ``` De la même manière on peut écraser une variable avec le paramètre spécial `value`: ``` {{:assign var="tableau" value=$infos}} ``` Il est également possible de créer des tableaux avec la syntaxe `.` dans le nom de la variable : ``` {{:assign var="liste.comptes.530" label="Caisse"}} {{:assign var="liste.comptes.512" label="Banque"}} {{#foreach from=$liste.comptes}} {{$key}} = {{$value.label}} {{/foreach}} ``` Il est possible de rajouter des éléments à un tableau simplement en utilisant un point seul : ``` {{:assign var="liste.comptes." label="530 - Caisse"}} {{:assign var="liste.comptes." label="512 - Banque"}} ``` Enfin, il est possible de faire référence à une variable de manière dynamique en utilisant le paramètre spécial `from` : ``` {{:assign var="tableau" a="Coucou" b="Test !"}} {{:assign var="titre" from="tableau.%s"|args:"b"}} {{$titre}} -> Affichera "Test !", soit la valeur de {{$tableau.b}} ``` ## debug Cette fonction permet d'afficher le contenu d'une ou plusieurs variables : ``` {{:debug test=$title}} ``` Affichera : ``` array(1) { ["test"] => string(6) "coucou" } ``` Si aucun paramètre n'est spécifié, alors toutes les variables définies sont renvoyées. Utile pour découvrir quelles sont les variables accessibles dans une section par exemple. ## error Affiche un message d'erreur et arrête le traitement à cet endroit. | Paramètre | Optionnel / obligatoire ? | Fonction | | :- | :- | :- | | `message` | **obligatoire** | Message d'erreur à afficher | Exemple : ``` {{if $_POST.nombre != 42}} {{:error message="Le nombre indiqué n'est pas 42"}} {{/if}} ``` ## http Permet de modifier les entêtes HTTP renvoyés par la page. Cette fonction doit être appelée au tout début du squelette, avant tout autre code ou ligne vide. | Paramètre | Optionnel / obligatoire ? | Fonction | | :- | :- | :- | | `code` | *optionnel* | Modifie le code HTTP renvoyé. [Liste des codes HTTP](https://fr.wikipedia.org/wiki/Liste_des_codes_HTTP) | | `redirect` | *optionnel* | Rediriger vers l'adresse URI indiquée en valeur. Seules les adresses internes sont acceptées, il n'est pas possible de rediriger vers une adresse extérieure. | | `type` | *optionnel* | Modifie le type MIME renvoyé | | `download` | *optionnel* | Force la page à être téléchargée sous le nom indiqué. | Note : si le type `application/pdf` est indiqué, la page sera convertie en PDF à la volée. Il est possible de forcer le téléchargement du fichier en utilisant le paramètre `download`. Exemples : ``` {{:http code=404}} {{:http redirect="/Nos-Activites/"}} {{:http type="application/svg+xml"}} {{:http type="application/pdf" download="liste_membres_ca.pdf"}} ``` ## include Permet d'inclure un autre squelette. Paramètres : | Paramètre | Optionnel / obligatoire ? | Fonction | | :- | :- | :- | | `file` | **obligatoire** | Nom du squelette à inclure | | `keep` | *optionnel* | Liste de noms de variables à conserver | | `capture` | *optionnel* | Si renseigné, au lieu d'afficher le squelette, son contenu sera enregistré dans la variable de ce nom. | | … | *optionnel* | Tout autre paramètre sera utilisé comme variable qui n'existea qu'à l'intérieur du squelette inclus. | ``` {{* Affiche le contenu du squelette "navigation.html" dans le même répertoire que le squelette d'origine *}} {{:include file="./navigation.html"}} ``` Par défaut, les variables du squelette parent sont transmis au squelette inclus, mais les variables définies dans le squelette inclus ne sont pas transmises au squelette parent. Exemple : ``` {{* Squelette page.html *}} {{:assign title="Super titre !"}} {{:include file="./_head.html"}} {{$nav}} ``` ``` {{* Squelette _head.html *}} <h1>{{$title}}</h1> {{:assign nav="Accueil > %s"|args:$title}} ``` Dans ce cas, la dernière ligne du premier squelette (`{{$nav}}`) n'affichera rien, car la variable définie dans le second squelette n'en sortira pas. Pour indiquer qu'une variable doit être transmise au squelette parent, il faut utiliser le paramètre `keep`: ``` {{:include file="./_head.html" keep="nav"}} ``` On peut spécifier plusieurs noms de variables, séparés par des virgules, et utiliser la notation à points : ``` {{:include file="./_head.html" keep="nav,article.title,name"}} {{$nav}} {{$article.title}} {{$name}} ``` On peut aussi capturer le résultat d'un squelette dans une variable : ``` {{:include file="./_test.html" capture="test"}} {{:assign var="test" value=$test|replace:'TITRE':'Ceci est un titre'}} {{$test}} ``` Il est possible d'assigner de nouvelles variables au contexte du include en les déclarant comme paramètres tout comme on le ferait avec `{{:assign}}` : ``` {{:include file="./_head.html" title='%s documentation'|args:$doc.label visitor=$user}} ``` ## captcha Permet de générer une question qui doit être répondue correctement par l'utilisateur pour valider une action. Utile pour empêcher les robots spammeurs d'effectuer une action. L'utilisation simplifiée utilise un de ces deux paramètres : | Paramètre | Fonction | | :- | :- | | `html` | Si `true`, crée un élément de formulaire HTML et le texte demandant à l'utilisateur de répondre à la question | | `verify` | Si `true`, vérifie que l'utilisateur a correctement répondu à la question | L'utilisation avancée utilise d'abord ces deux paramètres : | Paramètre | Fonction | | :- | :- | | `assign_hash` | Nom de la variable où assigner le hash (à mettre dans un `<input type="hidden" />`) | | `assign_number` | Nom de la variable où assigner le nombre de la question (à afficher à l'utilisateur) | Puis on vérifie : | Paramètre | Fonction | | :- | :- | | `verify_hash` | Valeur qui servira comme hash de vérification (valeur du `<input type="hidden" />`) | | `verify_number` | Valeur qui représente la réponse de l'utilisateur | | `assign_error` | Si spécifié, le message d'erreur sera placé dans cette variable, sinon il sera affiché directement. | Exemple : ``` {{if $_POST.send}} {{:captcha verify_hash=$_POST.h verify_number=$_POST.n assign_error="error"}} {{if $error}} <p class="alert">Mauvaise réponse</p> {{else}} ... {{/if}} {{/if}} <form method="post" action=""> {{:captcha assign_hash="hash" assign_number="number"}} <p>Merci de recopier le nombre suivant en chiffres : <tt>{{$number}}</tt></p> <p> <input type="text" name="n" placeholder="1234" /> <input type="hidden" name="h" value="{{$hash}}" /> <input type="submit" name="send" /> </p> </form> ``` ## mail Permet d'envoyer un e-mail à une ou des adresses indiquées (sous forme de tableau). Restrictions : * le message est toujours envoyé en format texte ; * l'expéditeur est toujours l'adresse de l'association ; * l'envoi est limité à une seule adresse e-mail externe (adresse qui n'est pas celle d'un membre) dans une page ; * l'envoi est limité à maximum 10 adresses e-mails internes (adresses de membres) dans une page ; * un message envoyé à une adresse e-mail externe ne peut pas contenir une adresse web (`https://...`) autre que celle de l'association. Note : il est également conseillé d'utiliser la fonction `captcha` pour empêcher l'envoi de spam. | Paramètre | Obligatoire ou optionnel ? | Fonction | | :- | :- | :- | | `to` | **obligatoire** | Adresse email destinataire (seule l'adresse e-mail elle-même est acceptée, pas de nom) | | `subject` | **obligatoire** | Sujet du message | | `body` | **obligatoire** | Corps du message | | `block_urls` | *optionnel* | (`true` ou `false`) Permet de bloquer l'envoi si le message contient une adresse `https://…` | Pour le destinataire, il est possible de spécifier un tableau : ``` {{:assign var="recipients[]" value="membre1@framasoft.net"}} {{:assign var="recipients[]" value="membre2@chatons.org"}} {{:mail to=$recipients subject="Coucou" body="Contenu du message\nNouvelle ligne"}} ``` Exemple de formulaire de contact : ``` {{if !$_POST.email|check_email}} <p class="alert">L'adresse e-mail indiquée est invalide.</p> {{elseif $_POST.message|trim == ''}} <p class="alert">Le message est vide</p> {{elseif $_POST.send}} {{:captcha verify=true}} {{:mail to=$config.org_email subject="Formulaire de contact" body="%s a écrit :\n\n%s"|args:$_POST.email:$_POST.message block_urls=true}} <p class="ok">Votre message nous a bien été transmis !</p> {{/if}} <form method="post" action=""> <dl> <dt><label>Votre e-mail : <input type="email" required name="email" /></label></dt> <dt><label>Votre message : <textarea required name="message" cols="50" rows="5"></textarea></label></dt> <dt>{{:captcha html=true}}</dt> </dl> <p><input type="submit" name="send" value="Envoyer !" /></p> </form> ``` # Fonctions relatives aux Modules ## save Enregistre des données, sous la forme d'un document, dans la base de données, pour le module courant. Note : un appel à cette fonction depuis le code du site web provoquera une erreur, elle ne peut être appelée que depuis un module. | Paramètre | Obligatoire ou optionnel ? | Fonction | | :- | :- | :- | | `key` | optionnel | Clé unique du document | | `id` | optionnel | Numéro unique du document | | `validate_schema` | optionnel | Fichier de schéma JSON à utiliser pour valider les données avant enregistrement | | `validate_only` | optionnel | Liste des paramètres à valider (par exemple pour ne faire qu'une mise à jour partielle), séparés par des virgules. | | `assign_new_id` | optionnel | Si renseigné, le nouveau numéro unique du document sera indiqué dans cette variable. | | … | optionnel | Autres paramètres : traités comme des valeurs à enregistrer dans le document | Si ni `key` ni `id` ne sont indiqués, un nouveau document sera créé avec un nouveau numéro unique. Si le document indiqué existe déjà, il sera mis à jour. Les valeurs nulles (`NULL`) seront effacées. ``` {{:save key="facture_43" nom="Atelier mobile" montant=250}} ``` Enregistrera dans la base de données le document suivant sous la clé `facture_43` : ``` {"nom": "Atelier mobile", "montant": 250} ``` Exemple de mise à jour : ``` {{:save key="facture_43" montant=300}} ``` Exemple de récupération du nouvel ID : ``` {{:save titre="Coucou !" assign_new_id="id"}} Le document n°{{$id}} a bien été enregistré. ``` ### Validation avec un schéma JSON ``` {{:save titre="Coucou" texte="Très long" validate_schema="./document.schema.json"}} ``` Pour ne valider qu'une partie du schéma, par exemple si on veut faire une mise à jour du document : ``` {{:save key="test" titre="Coucou" validate_schema="./document.schema.json" validate_only="titre"}} ``` ## delete Supprime un document lié au module courant. Note : un appel à cette fonction depuis le code du site web provoquera une erreur, elle ne peut être appelée que depuis un module. | Paramètre | Obligatoire ou optionnel ? | Fonction | | :- | :- | :- | | `key` | optionnel | Clé unique du document | | `id` | optionnel | Numéro unique du document | Si ni `key` ni `id` ne sont indiqués, une erreur sera affichée. Exemple : ``` {{:delete key="facture_43"}} ``` ## admin_header Affiche l'entête de l'administration de l'association. | Paramètre | Obligatoire ou optionnel ? | Fonction | | :- | :- | :- | | `title` | *optionnel* | Titre de la page | | `layout` | *optionnel* | Aspect de la page. Peut être `public` pour une page publique simple (sans le menu), ou `raw` pour une page vierge (sans aucun menu ni autre élément). Défaut : vide (affichage du menu) | | `current` | *optionnel* | Indique quel élément dans le menu de gauche doit être marqué comme sélectionné | | `custom_css` | *optionnel* | Fichier CSS supplémentaire à appeler dans le `<head>` | ``` {{:admin_header title="Gestion des dons" current="acc"}} ``` Liste des choix possibles pour `current` : * `home` : menu Accueil * `users` : menu Membres * `users/new` : sous-menu "Ajouter" de Membres * `users/services` : sous-menu "Activités et cotisations" de Membres * `users/mailing` : sous-menu "Message collectif" de Membres * `acc` : menu Comptabilité * `acc/new` : sous-menu "Saisie" de Comptabilité * `acc/accounts` : sous-menu "Comptes" * `acc/simple` : sous-menu "Suivi des écritures" * `acc/years` : sous-menu "Exercices et rapports" * `docs` : menu Documents * `web` : menu Site web * `config` : menu Configuration * `me` : menu "Mes infos personnelles" * `me/services` : sous-menu "Mes activités et cotisations" Exemple d'utilisation de `custom_css` depuis un module : ``` {{:admin_header title="Mon module" custom_css="./style.css"}} ``` ## admin_footer Affiche le pied de page de l'administration de l'association. ``` {{:admin_footer}} ``` ## input Crée un champ de formulaire HTML. Cette fonction est une extension à la balise `<input>` en HTML, mais permet plus de choses. | Paramètre | Obligatoire ou optionnel ? | Fonction | | :- | :- | :- | | `name` | **obligatoire** | Nom du champ | | `type` | **obligatoire** | Type de champ | | `required` | *optionnel* | Mettre à `true` si le champ est obligatoire | | `label` | *optionnel* | Libellé du champ | | `help` | *optionnel* | Texte d'aide, affiché sous le champ | | `default` | *optionnel* | Valeur du champ par défaut, si le formulaire n'a pas été envoyé, et que la valeur dans `source` est vide | | `source` | *optionnel* | Source de pré-remplissage du champ. Si le nom du champ est `montant`, alors la valeur de `[source].montant` sera affichée si présente. | Si `label` ou `help` sont spécifiés, le champ sera intégré à une balise HTML `<dd>`, et le libellé sera intégré à une balise `<dt>`. Dans ce cas il faut donc que le champ soit dans une liste `<dl>`. Si ces deux paramètres ne sont pas spécifiés, le champ sera le seul tag HTML. ``` <dl> {{:input name="amount" type="money" label="Montant" required=true}} </dl> ``` Note : le champ aura comme `id` la valeur `f_[name]`. Ainsi un champ avec `amount` comme `name` aura `id="f_amount"`. ### Valeur du champ La valeur du champ est remplie avec : * la valeur dans `$_POST` qui correspond au `name` ; * sinon la valeur dans `source` (tableau) avec le même nom (exemple : `$source[name]`) ; * sinon la valeur de `default` est utilisée. Note : le paramètre `value` n'est pas supporté sauf pour checkbox et radio. ### Types de champs supportés * les types classiques de `input` en HTML : text, search, email, url, file, date, checkbox, radio, password, etc. * Note : pour checkbox et radio, il faut utiliser le paramètre `value` en plus pour spécifier la valeur. * `textarea` * `money` créera un champ qui attend une valeur de monnaie au format décimal * `datetime` créera un champ date et un champ texte pour entrer l'heure au format `HH:MM` * `radio-btn` créera un champ de type radio mais sous la forme d'un gros bouton * `select` crée un sélecteur de type `<select>`. Dans ce cas il convient d'indiquer un tableau associatif dans le paramètre `options`. * `select_groups` crée un sélecteur de type `<select>`, mais avec des `<optgroup>`. Dans ce cas il convient d'indiquer un tableau associatif à deux niveaux dans le paramètre `options`. * `list` crée un champ permettant de sélectionner un ou des éléments (selon si le paramètre `multiple` est `true` ou `false`) dans un formulaire externe. Le paramètre `can_delete` indique si l'utilisateur peut supprimer l'élément déjà sélectionné (si `multiple=false`). La sélection se fait à partir d'un formulaire dont l'URL doit être spécifiée dans le paramètre `target`. Les formulaires actuellement supportés sont : * `!acc/charts/accounts/selector.php?targets=X` pour sélectionner un compte du plan comptable, où X est une liste de types de comptes qu'il faut permettre de choisir (séparés par des `:`) * `!users/selector.php` pour sélectionner un membre ## button Affiche un bouton, similaire à `<button>` en HTML, mais permet d'ajouter une icône par exemple. ``` {{:button type="submit" name="save" label="Créer ce membre" shape="plus" class="main"}} ``` | Paramètre | Obligatoire ou optionnel ? | Fonction | | :- | :- | :- | | `type` | optionnel | Type du bouton | | `name` | optionnel | Nom du bouton | | `label` | optionnel | Label du bouton | | `shape` | optionnel | Affiche une icône en préfixe du label | | `class` | optionnel | Classe CSS | | `title` | optionnel | Attribut HTML `title` | | `disabled` | optionnel | Désactive le bouton si `true` | ## link Affiche un lien. ``` {{:link href="!users/new.php" label="Créer un nouveau membre"}} ``` | Paramètre | Obligatoire ou optionnel ? | Fonction | | :- | :- | :- | | `href` | **obligatoire** | Adresse du lien | | `label` | **obligatoire** | Libellé du lien | | `target` | *optionnel* | Cible du lien, utiliser `_dialog` pour que le lien s'ouvre dans une fenêtre modale. | ## linkbutton Affiche un lien sous forme de faux bouton, avec une icône si le paramètre `shape` est spécifié. ``` {{:linkbutton href="!users/new.php" label="Créer un nouveau membre" shape="plus"}} ``` | Paramètre | Obligatoire ou optionnel ? | Fonction | | :- | :- | :- | | `href` | **obligatoire* | Adresse du lien | | `label` | **obligatoire** | Libellé du bouton | | `target` | *optionnel* | Cible de l'ouverture du lien | | `shape` | *optionnel* | Affiche une icône en préfixe du label | Si on utilise `target="_dialog"` alors le lien s'ouvrira dans une fenêtre modale (iframe) par dessus la page actuelle. Si on utilise `target="_blank"` alors le lien s'ouvrira dans un nouvel onglet. ## icon Affiche une icône. ``` {{:icon shape="print"}} ``` | Paramètre | Obligatoire ou optionnel ? | Fonction | | :- | :- | :- | | `shape` | **obligatoire** | Forme de l'icône. | # Formes d'icônes disponibles  |
Modified doc/admin/brindille_modifiers.md from [dc87155bc4] to [f777fa9608].
1 2 3 4 5 6 7 8 9 10 11 12 13 | Title: Référence des filtres Brindille {{{.nav * [Documentation Brindille](brindille.html) * [Fonctions](brindille_functions.html) * [Sections](brindille_sections.html) * **[Filtres](brindille_modifiers.html)** }}} <<toc aside>> # Filtres PHP | | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > < < < | | > | | | | | > | > | | > | > | > | > > | > > | | | > | > > | > | > > > > > | > | > > > > | > > | > > > | | | | > > > < > < > > > > | > | > > > > > > > > > > > | | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | > > > > > > > > > > > > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | > | > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 | Title: Référence des filtres Brindille {{{.nav * [Documentation Brindille](brindille.html) * [Fonctions](brindille_functions.html) * [Sections](brindille_sections.html) * **[Filtres](brindille_modifiers.html)** }}} <<toc aside>> # Filtres PHP Ces filtres viennent directement de PHP et utilisent donc les mêmes paramètres. Voir la [documentation PHP](https://www.php.net/manual/fr/function.htmlspecialchars.php) pour plus de détails. | Nom | Description | | :- | :- | | `htmlentities` | Convertit tous les caractères éligibles en entités HTML | | `htmlspecialchars` | Convertit les caractères spéciaux en entités HTML | | `trim` | Supprime les espaces et lignes vides au début et à la fin d'un texte | | `ltrim` | Supprime les espaces et lignes vides au début d'un texte | [Documentation](https://www.php.net/ltrim) | | `rtrim` | Supprime les espaces et lignes vides à la fin d'un texte | [Documentation](https://www.php.net/rtrim) | | `md5` | Génère un hash MD5 d'un texte | | `sha1` | Génère un hash SHA1 d'un texte | | `strlen` | Nombre de caractères dans une chaîne de texte | | `strpos` | Position d'un élément dans une chaîne de texte | | `strrpos` | Position d'un dernier élément dans une chaîne de texte | | `strip_tags` | Supprime les tags HTML | | `nl2br` | Remplace les retours à la ligne par des tags HTML `<br/>` | | `wordwrap` | Ajoute des retours à la ligne tous les 75 caractères | | `substr` | Découpe une chaîne de caractère | | `abs` | Renvoie la valeur absolue d'un nombre (exemple : -42 sera transformé en 42) | | `intval` | Transforme une valeur en entier (integer) | | `boolval` | Transforme une valeur en booléen (true ou false) | | `floatval` | Transforme une valeur en nombre flottant (à virgule) | | `strval` | Transforme une valeur en chaîne de texte | | `json_encode` | Transforme une valeur en chaîne JSON | # Filtres utiles pour les e-mails ## check_email Permet de vérifier la validité d'une adresse email. Cette fonction vérifie la syntaxe de l'adresse mais aussi que le nom de domaine indiqué possède bien un enregistrement de type MX. Renvoie `true` si l'adresse est valide. ``` {{if !$_POST.email|check_email}} <p class="alert">L'adresse e-mail indiquée est invalide.</p> {{/if}} ``` ## protect_contact Crée un lien protégé pour une adresse email, pour éviter que l'adresse ne soit recueillie par les robots spammeurs (empêche également le copier-coller et le lien ne fonctionnera pas avec javascript désactivé). # Filtres de tableaux ## count Compte le nombre d'entrées dans un tableau. ``` {{$products|count}} = 5 ``` ## implode Réunit un tableau sous forme de chaîne de texte en utilisant éventuellement une chaîne de liaison entre chaque élément du tableau. ``` {{:assign var="table" a="bleu" b="orange"}} {{$table|implode}} {{$table|implode:" - "}} ``` Affichera : ``` bleuorange bleu - orange ``` # Filtres de texte ## args Remplace des arguments dans le texte selon le schéma utilisé par [sprintf](https://www.php.net/sprintf). ``` {{"Il y a %d résultats dans la recherche sur le terme '%s'."|args:$results_count:$query}} = Il y a 5 résultat dans la recherche sur le terme 'test'. ``` ## cat Concaténer un texte avec un autre. ``` {{"Tangerine"|cat:" Dream"}} = Tangerine Dream ``` ## count_words Compte le nombre de mots dans un texte. ## escape Échappe le contenu pour un usage dans un document HTML. Ce filtre est appliqué par défaut à tout ce qui est affiché (variables, etc.) sauf à utiliser le filtre `raw` (voir plus bas). ## excerpt Produit un extrait d'un texte. Supprime les tags HTML, tronque au nombre de caractères indiqué en second argument (si rien n'est indiqué, alors 600 est utilisé), et englobe dans un paragraphe `<p>...</p>`. Équivalent de : ``` <p>{{$html|strip_tags|truncate:600|nl2br}}</p> ``` ## extract_leading_number Extrait le numéro au début d'une chaîne de texte. Exemple : ``` {{:assign title="02. Cours sur la physique nucléaire"}} {{$title|extract_leading_number}} ``` Affichera : ``` 02 ``` ## raw Passer ce filtre désactive la protection automatique contre le HTML (échappement) dans le texte. À utiliser en connaissance de cause avec les contenus qui contiennent du HTML et sont déjà filtrés ! ``` {{"<b>Test"}} = <b>Test {{"<b>Test"|raw}} = <b>Test ``` ## replace Remplace des parties du texte par une autre partie. ``` {{"Tata yoyo"|replace:"yoyo":"yaya"}} = Tata yaya ``` ## regexp_replace Remplace des valeurs en utilisant une expression rationnelles (regexp) ([documentation PHP](https://www.php.net/manual/fr/regexp.introduction.php)). ``` {{"Tartagueule"|regexp_replace:"/ta/i":"tou"}} = tourtougueule ``` ## remove_leading_number Supprime le numéro au début d'un titre. Cela permet de définir un ordre spécifique aux pages et catégories dans les listes. ``` {{"03. Beau titre"|remove_leading_number}} Beau titre ``` ## truncate Tronque un texte à une longueur définie. | Argument | Fonction | Valeur par défaut (si omis) | | :- | :- | :- | | 1 | longueur en nombre de caractères | 80 | | 2 | texte à placer à la fin (si tronqué) | … | | 3 | coupure stricte, si `true` alors un mot pourra être coupé en deux, si `false` le texte sera coupé au dernier mot complet | `false` | ``` {{:assign texte="Ceci n'est pas un texte."}} {{$texte|truncate:19:"(...)":true}} {{$texte|truncate:19:"":false}} ``` Affichera : ``` Ceci n'est pas un (...) Ceci n'est pas un t ``` ## typo Formatte un texte selon les règles typographiques françaises : ajoute des espaces insécables devant ou derrière les ponctuations françaises (`« » ? ! :`). ## urlencode Encode une chaîne de texte pour utilisation dans une adresse URL (alias de `rawurlencode` en PHP). ## xml_escape Échappe le contenu pour un usage dans un document XML. ## Autres filtres de texte Les filtres suivants modifient la casse (majuscule/minuscules) d'un texte et ne fonctionneront correctement que si l'extension `mbstring` est installée sur le serveur. Sinon les lettres accentuées ne seront pas modifiées. Note : il est donc préférable d'utiliser la propriété CSS [`text-transform`](https://developer.mozilla.org/en-US/docs/Web/CSS/text-transform) pour modifier la casse si l'usage n'est que pour l'affichage, et non pas pour enregistrer les données. * `tolower` : transforme un texte en minuscules * `toupper` : transforme un texte en majuscules * `ucfirst` : met la première lettre du texte en majuscule * `ucwords` : met la première lettre de chaque mot en majuscule * `lcfirst` : met la première lettre du texte en minuscule # Filtres sur les sommes en devises ## money Formatte une valeur de monnaie pour l'affichage. Une valeur de monnaie doit **toujours** inclure les cents (exprimée sous forme d'entier). Ainsi `15,02` doit être exprimée sous la forme `1502`. Paramètres optionnels : 1. `true` (défaut) pour ne rien afficher si la valeur est zéro, ou `false` pour afficher `0,00` 2. `true` pour afficher le signe `+` si le nombre est positif (`-` est toujours affiché si le nombre est négatif) ``` {{* 12 345,67 = 1234567 *}} {{:assign amount=1234567}} {{$amount|money}} 12 345,67 ``` ## money_currency Comme `money` (même paramètres), formatte une valeur de monnaie (entier) pour affichage, mais en ajoutant la devise. ``` {{:assign amount=1502}} {{$amount|money_currency}} 15,02 € ``` ## money_html Idem que `money`, mais pour l'affichage en HTML : ``` {{* 12 345,67 = 1234567 *}} {{:assign amount=1234567}} {{$amount|money_html}} <span class="money">12 345,67</span> ``` ## money_currency_html Idem que `money_currency`, mais pour l'affichage en HTML : ``` {{:assign amount=1502}} {{$amount|money_currency_html}} <span class="money">15,02 €</span> ``` ## money_raw Formatte une valeur de monnaie (entier) de manière brute : les milliers n'auront pas de séparateur. ``` {{:assign amount=1234567}} {{$amount|money_raw}} 12345,67 ``` ## money_int Transforme un nombre à partir d'une chaîne de caractère (par exemple `12345,67`) en entier (`1234567`) pour stocker une valeur de monnaie. ``` {{:assign montant=$_POST.montant|trim|money_int}} ``` # Filtres SQL ## quote_sql Protège une chaîne contre les attaques SQL, pour l'utilisation dans une condition. **Note : il est FORTEMENT déconseillé d'intégrer directement des sources extérieures dans les requêtes SQL, il est préférable d'utiliser les paramètres dans la boucle `sql` et ses dérivées, comme ceci : `{{#sql select="id, nom" tables="users" where="lettre_infos = :lettre" :lettre=$_GET.lettre}}`.** Exemple : ``` {{:assign nom=$_GET.nom|quote_sql}} {{#sql select="id, nom" tables="users" where="nom = %s"|args:$nom}} ``` ## quote_sql_identifier La même chose que `quote_sql`, mais pour les identifiants (par exemple nom de table ou de colonne). Exemple : ``` {{:assign colonne=$_GET.colonne|quote_sql_identifier}} {{#sql select="id, %s"|args:$colonne tables="users"}} ``` ## sql_where Permet de créer une partie d'une clause SQL `WHERE` complexe. Le premier paramètre est le nom de la colonne (sans préfixe). Paramètres : 1. Comparateur : `=, !=, IN, NOT IN, >, >=, <, <=` 2. Valeur à comparer (peut être un tableau) Exemple pour afficher la liste des membres des catégories n°1 et n°2: ``` {{:assign var="list." value=1}} {{:assign var="list." value=2}} {{#sql select="nom" tables="users" where="id_category"|sql_where:'IN':$id_list}} {{$nom}} {{/sql}} ``` Le requête SQL générée sera alors `SELECT nom FROM users WHERE id_category IN (1, 2)`. # Filtres de date ## date Formatte une date selon le format spécifié en premier paramètre. Le format est identique au [format utilisé par PHP](https://www.php.net/manual/fr/datetime.format.php). Si aucun format n'est indiqué, le défaut sera `d/m/Y à H:i`. (en français) ## strftime Formatte une date selon un format spécifié en premier paramètre. Le format à utiliser est identique [au format utilisé par la fonction strftime de PHP](https://www.php.net/strftime). Un format doit obligatoirement être spécifié. En passant un code de langue en second paramètre, cette langue sera utilisée. Sont supportés le français (`fr`) et l'anglais (`en`). Le défaut est le français si aucune valeur n'est passée en second paramètre . ## relative_date Renvoie une date relative à la date du jour : `aujourd'hui`, `hier`, `demain`, ou sinon `mardi 2 janvier` (si la date est de l'année en cours) ou `2 janvier 2021` (si la date est d'une autre année). En spécifiant `true` en premier paramètre, l'heure sera ajoutée au format `14h34`. ## date_short Formatte une date au format court : `d/m/Y`. En spécifiant `true` en premier paramètre l'heure sera ajoutée : `à H\hi`. ## date_long Formatte une date au format long : `lundi 2 janvier 2021`. En spécifiant `true` en premier paramètre l'heure sera ajoutée : `à 20h42`. ## date_hour Formatte une date en renvoyant l'heure uniquement : `20h00`. En passant `true` en premier paramètre, les minutes seront omises si elles sont égales à zéro : `20h`. ## atom_date Formatte une date au format ATOM : `Y-m-d\TH:i:sP` ## parse_date Vérifie le format d'une chaîne de texte et la transforme en chaîne de date standardisée au format `AAAA-MM-JJ HH:MM` (ou `AAAA-MM-JJ` si l'heure n'a pas été précisée). Les formats acceptés sont : * `AAAA-MM-JJ` * `JJ/MM/AAAA` * `JJ/MM/AA` # Filtres de condition Ces filtres sont à utiliser dans les conditions ## match Renvoie `true` si le texte indiqué en premier paramètre est trouvé dans la variable. Ce filtre est insensible à la casse. ``` {{if $page.path|match:"/aide"}}Bienvenue dans l'aide !{{/if}} ``` ## regexp_match Renvoie `true` si l'expression régulière indiquée en premier paramètre est trouvée dans la variable. Exemple pour voir si le texte contient les mots "Bonjour" ou "Au revoir" (insensible à la casse) : ``` {{if $texte|regexp_match:"/Bonjour|Au revoir/i"}} Trouvé ! {{else}} Rien trouvé :-( {{/if}} ``` # Autres filtres ## math Réalise un calcul mathématique. Cette fonction accepte : * les nombres: `42`, `13,37`, `14.05` * les signes : `+ - / *` pour additionner, diminuer, diviser ou multiplier |
︙ | ︙ | |||
130 131 132 133 134 135 136 | {{"1+%d"|math:$age}} = 43 {{:assign prix=39.99 tva=19.1}} {{"round(%f*%f, 2)"|math:$prix:$tva}} = 47.63 ``` | < < | < > < < > > < | < < < > > < | < < | < < | < < < < < < < < < < < | < < < < < < < < < < | < < < < < < < < < < < | < < < < < < < < < < < < < < < < < < < | 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 | {{"1+%d"|math:$age}} = 43 {{:assign prix=39.99 tva=19.1}} {{"round(%f*%f, 2)"|math:$prix:$tva}} = 47.63 ``` ## or Si la variable passée est évalue comme `false` (c'est à dire que sa valeur est un texte vide, ou un nombre qui vaut zéro, ou la valeur `false`), alors le premier paramètre sera utilisé. ``` {{:assign texte=""}} {{$texte|or:"Le texte est vide"}} ``` Il est possible de chaîner les appels à `or` : ``` {{:assign texte1="" texte2="0"}} {{$texte1|or:$texte2|or:"Aucun texte"}} ``` ## size_in_bytes Renvoie une taille en octets, Ko, Mo, ou Go à partir d'une taille en octets. ``` {{100|size_in_bytes}} = 100 o {{1500|size_in_bytes}} = 1,50 Ko {{1048576|size_in_bytes}} = 1 Mo ``` ## spell_out_number Épelle un nombre en toutes lettres. Le premier paramètre peut être utilisé pour spécifier le code de la langue à utiliser (par défaut c'est le français, donc le code `fr`). ``` {{42|spell_out_number}} ``` Donnera : ``` quarante deux ``` |
Modified doc/admin/brindille_sections.md from [3025661395] to [82a154d838].
︙ | ︙ | |||
9 10 11 12 13 14 15 | <<toc aside level=2>> # Sections généralistes ## foreach | | > > > > > > > > > > > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > | | | | > > > > > > > > | > > > > > > > > > > > > > > > > > > > > | > > > > > > > > | > > > > > > > | > | > | > > > > | > | > | > > > > > > > > > > > > > > > | > > > > > > > > > > > > > > > > | < < | | | | < < | < < < < | < | > > > > | < < | < | < < < | < < > < > > > | < < < | | > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 | <<toc aside level=2>> # Sections généralistes ## foreach Permet d'itérer sur un tableau par exemple. Ainsi chaque élément du tableau exécutera une fois le contenu de la section. | Paramètre | Optionnel / obligatoire ? | Fonction | | :- | :- | :- | | `from` | **obligatoire** | Variable sur laquelle effectuer l'itération | | `key` | **optionnel** | Nom de la variable à utiliser pour la clé de l'élément | | `value` | **optionnel** | Nom de la variable à utiliser pour la valeur de l'élément | Considérons ce tableau : ``` {{:assign var="tableau" a="bleu" b="orange"}} ``` On peut alors itérer pour récupérer les clés (`a` et `b` ainsi que les valeurs `bleu` et `orange`) : ``` {{#foreach from=$tableau key="key" item="value"}} {{$key}} = {{$value}} {{/foreach}} ``` Cela affichera : ``` a = bleu b = orange ``` Si on a un tableau à plusieurs niveaux, les éléments du tableau sont automatiquement transformés en variable : ``` {{:assign var="tableau.a" couleur="bleu"}} {{:assign var="tableau.b" couleur="orange"}} ``` ``` {{#foreach from=$variable}} {{$couleur}} {{/foreach}} ``` Affichera : ``` bleu orange ``` ## restrict Permet de limiter (restreindre) une partie de la page aux membres qui sont connectés et/ou qui ont certains droits. Deux paramètres optionnels peuvent être utilisés ensemble (il n'est pas possible d'utiliser seulement un des deux) : | Paramètre | Optionnel / obligatoire ? | Fonction | | :- | :- | :- | | `level` | *optionnel* | Niveau d'accès : `read`, `write`, `admin` | | `section` | *optionnel* | Section où le niveau d'accès doit s'appliquer : `users`, `accounting`, `web`, `documents`, `config` | | `block` | *optionnel* | Si ce paramètre est présent et vaut `true`, alors l'accès sera interdit si les conditions d'accès demandées ne sont pas remplies : une page d'erreur sera renvoyée. | Exemple pour voir si un membre est connecté : ``` {{#restrict}} Un membre est connecté, mais on ne sait pas avec quels droits. {{else}} Aucun membre n'est connecté. {{/restrict}} ``` Exemple pour voir si un membre qui peut administrer les membres est connecté : ``` {{#restrict section="users" level="admin"}} Un membre est connecté, et il a le droit d'administrer les membres. {{else}} Aucun membre n'est connecté, ou un membre est connecté mais n'est pas administrateur des membres. {{/if}} ``` Pour bloquer l'accès aux membres non connectés, ou qui n'ont pas accès en écriture à la comptabilité. ``` {{#restrict block=true section="accounting" level="write"}} {{/restrict}} ``` Le mieux est de mettre ce code au début d'un squelette. # Requêtes SQL ## select Exécute une requête SQL `SELECT` et effectue une itération pour chaque résultat de la requête. Pour une utilisation plus simplifiée des requêtes, voir aussi la section [sql](#sql). Attention : la syntaxe de cette section est différente des autres sections Brindille. En effet après le début (`{{#select`) doit suivre la suite de la requête, et non pas les paramètres : ``` Liste des membres inscrits à la lettre d'informations : {{#select nom, prenom FROM users WHERE lettre_infos = 1;}} - {{prenom}} {{$nom}}<br /> {{else}} Aucun membre n'est inscrit à la lettre d'information. {{/select}} ``` Des paramètres nommés de SQL peuvent être présentés après le point-virgule marquant la fin de la requête SQL : ``` {{:assign prenom="Karim"}} {{#select * FROM users WHERE prenom = :prenom; :prenom=$prenom}} ... {{/select}} ``` Notez les deux points avant le nom du paramètre. Ces paramètres sont protégés contre les injections SQL (généralement appelés paramètres nommés). Pour intégrer des paramètres qui ne sont pas protégés (**attention !**), il faut utiliser le point d'exclamation : ``` {{:assign var="categories." value=1}} {{:assign var="categories." value=2}} {{#select * FROM users WHERE !categories; !categories='id_category'|sql_where:'IN':$categories}} ``` Cela créera la requête suivante : `SELECT * FROM users WHERE id_category IN (1, 2);` Il est aussi possible d'intégrer directement des variables dans la requête, en utilisant la syntaxe `{$variable|filtre:argument1:argument2}`, comme une variable classique donc, mais au lieu d'utiliser des doubles accolades, on utilise ici des accolades simples. Ces variables seront automatiquement protégées contre les injections SQL. ``` {{:assign prenom="Camille"}} {{#select * FROM users WHERE initiale_prenom = {$prenom|substr:0:1};}} ``` Cependant, pour plus de lisibilité il est conseillé d'utiliser la syntaxe des paramètres nommés SQL (voir ci-dessus). Il est aussi possible d'insérer directement du code SQL (attention aux problèmes de sécurité dans ce cas !), pour cela il faut rajouter un point d'exclamation après l'accolade ouvrante : ``` {{:assign var="prenoms." value="Karim"}} {{:assign var="prenoms." value="Camille"}} {{#select * FROM users WHERE {!"prenom"|sql_where:"IN":$prenoms};}} ... {{/select}} ``` Il est aussi possible d'utiliser les paramètres suivants : | Paramètre | Fonction | | :- | :- | | `debug` | Si ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle. | | `explain` | Si ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle. | | `assign` | Si renseigné, une variable de ce nom sera créée, et le contenu de la dernière ligne du résultat y sera assigné. | Exemple avec `debug` : ``` {{:assign prenom="Karim"}} {{#select * FROM users WHERE prenom = :prenom; :prenom=$prenom debug=true}} ... {{/select}} ``` Affichera : `SELECT * FROM users WHERE nom = 'Karim'`. Exemple avec `assign` : ``` {{#select * FROM users WHERE prenom = 'Camille' LIMIT 1; assign="membre"}}{{/select}} {{$membre.nom}} ``` ## sql Effectue une requête SQL de type `SELECT` dans la base de données, mais de manière simplifiée par rapport à `select`. ``` {{#sql select="*, julianday(date) AS day" tables="membres" where="id_categorie = :id_categorie" :id_categorie=$_GET.id_categorie order="numero DESC" begin=":page*100" limit=100 :page=$_GET.page}} … {{/sql}} ``` | Paramètre | Optionnel / obligatoire ? | Fonction | | :- | :- | :- | | `tables` | **obligatoire** | Liste des tables à utiliser dans la requête (séparées par des virgules). | | `select` | *optionnel* | Liste des colonnes à sélectionner, si non spécifié, toutes les colonnes (`*`) seront sélectionnées | ### Sections qui héritent de `sql` Certaines sections (voir plus bas) héritent de `sql` et rajoutent des fonctionnalités. Dans toutes ces sections, il est possible d'utiliser les paramètres suivants : | Paramètre | Fonction | | :- | :- | | `where` | Condition de sélection des résultats | | `begin` | Début des résultats, si vide une valeur de `0` sera utilisée. | | `limit` | Limitation des résultats. Si vide, une valeur de `1000` sera utilisée. | | `group` | Contenu de la clause `GROUP BY` | | `order` | Ordre de tri des résultats. Si vide le tri sera fait par ordre d'ajout dans la base de données. | | `assign` | Si renseigné, une variable de ce nom sera créée, et le contenu de la dernière ligne du résultat y sera assigné. | | `debug` | Si ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle. | | `explain` | Si ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle. | Il est également possible de passer des arguments dans les paramètres à l'aides des arguments nommés qui commencent par deux points `:` : ``` {{#articles where="title = :montitre" :montitre="Actualité"}} ``` # Pour le site web ## breadcrumbs Permet de récupérer la liste des pages parentes d'une page afin de constituer un [fil d'ariane](https://fr.wikipedia.org/wiki/Fil_d'Ariane_(ergonomie)) permettant de remonter dans l'arborescence du site Un seul paramètre est possible : | Paramètre | Fonction | | :- | :- | | `path` (obligatoire) | Chemin (adresse unique) de la page parente | Chaque itération renverra trois variables : | Variable | Contenu | | :- | :- | | `$title` | Titre de la page ou catégorie | | `$url` | Adresse HTTP de la page ou catégorie | | `$path` | Chemin (adresse unique) de la page ou catégorie | ### Exemple ``` <ul> {{#breadcrumbs path=$page.path}} <li>{{$title}}</li> {{/breadcrumbs}} </ul> ``` ## pages, articles, categories <sup>(sql)</sup> Note : ces sections héritent de `sql` (voir plus haut). * `pages` renvoie une liste de pages, qu'elles soient des articles ou des catégories * `categories` ne renvoie que des catégories * `articles` ne renvoie que des articles À part cela ces trois types de section se comportent de manière identique. | Paramètre | Fonction | | :- | :- | | `search` | Renseigner ce paramètre avec un terme à rechercher dans le texte ou le titre. Dans ce cas par défaut le tri des résultats se fait sur la pertinence, sauf si le paramètre `order` est spécifié. Dans ce cas une variable `$snippet` sera disponible à l'intérieur de la boucle, contenant les termes trouvés. | | `future` | Renseigner ce paramètre à `false` pour que les articles dont la date est dans le futur n'apparaissent pas, `true` pour ne renvoyer QUE les articles dans le futur, et `null` (ou ne pas utiliser ce paramètre) pour que tous les articles, passés et futur, apparaissent. | | `parent` | Chemin (path) de la catégorie parente. Exemple pour renvoyer la liste des articles de la sous-catégorie "Événements" de la catégorie "Notre atelier" : `atelier-velo/evenements`. Utiliser `null` pour n'afficher que les articles ou catégories de la racine du site. | ## files, documents, images <sup>(sql)</sup> Note : ces sections héritent de `sql` (voir plus haut). * `files` renvoie une liste de fichiers * `documents` renvoie une liste de fichiers qui ne sont pas des images * `images` renvoie une liste de fichiers qui sont des images À part cela ces trois types de section se comportent de manière identique. Note : seul les fichiers de la section site web sont accessibles, les fichiers de membres, de comptabilité, etc. ne sont pas disponibles. | Paramètre | Optionnel / obligatoire ? | Fonction | | :- | :- | :- | | `parent` | **obligatoire** | Chemin (adresse unique) de l'article ou catégorie parente dont ont veut lister les fichiers | | `except_in_text` | *optionnel* | passer `true` à ce paramètre , et seuls les fichiers qui ne sont pas liés dans le texte de la page seront renvoyés | # Sections relatives aux modules ## load <sup>(sql)</sup> Note : cette section hérite de `sql` (voir plus haut). Charge un ou des documents pour le module courant. | Paramètre | Optionnel / obligatoire ? | Fonction | | :- | :- | :- | | `module` | optionnel | Nom unique du module lié (par exemple : `recu_don`). Si non spécifié, alors le nom du module courant sera utilisé. | | `key` | optionnel | Clé unique du document | | `id` | optionnel | Numéro unique du document | Il est possible d'utiliser un paramètre commençant par un dollar : `$.cle="valeur"`. Cela va comparer `"valeur"` avec la valeur de la clé `cle` dans le document JSON. C'est l'équivalent d'écrire `where="json_extract(document, '$.cle') = 'valeur'"`. Pour des conditions plus complexes qu'une simple égalité, il est possible d'utiliser la syntaxe courte `$$…` dans le paramètre `where`. Ainsi `where="$$.nom LIKE 'Bourse%'` est l'équivalent de `where="json_extract(document, '$.nom') LIKE 'Bourse%'"`. Voir [la documentation de SQLite pour plus de détails sur la syntaxe de json_extract](https://www.sqlite.org/json1.html#jex). Note : un index SQL dynamique est créé pour chaque requête utilisant une clause `json_extract`. Chaque itération renverra ces deux variables : | Variable | Valeur | | :- | :- | | `$key` | Clé unique du document | | `$id` | Numéro unique du document | Ainsi que chaque élément du document JSON lui-même. ### Exemples Afficher le nom du document dont la clé est `facture_43` : ``` {{#load key="facture_43"}} {{$nom}} {{/load}} ``` Afficher la liste des devis du module `invoice` depuis un autre module par exemple : ``` {{#load module="invoice" $.type="quote"}} <h1>Titre du devis : {{$subject}}</h1> <h2>Montant : {{$total}}</h2> {{/load}} ``` ## list Attention : cette section n'hérite **PAS de `sql`**. Un peu comme `{{#load}}` cette section charge les documents d'un module, mais au sein d'une liste (tableau HTML). Cette liste gère automatiquement l'ordre selon les préférences des utilisateurs, ainsi que la pagination. Cette section est très puissante et permet de générer des listes simplement, une fois qu'on a saisi la logique de son fonctionnement. | Paramètre | Optionnel / obligatoire ? | Fonction | | :- | :- | :- | | `schema` | **requis** si `select` n'est pas fourni | Chemin vers un fichier de schéma JSON qui représenterait le document | | `select` | **requis** si `schema` n'est pas fourni | Liste des colonnes à sélectionner, sous la forme `$$.colonne AS "Colonne"`, chaque colonne étant séparée par un point-virgule. | | `module` | *optionnel* | Nom unique du module lié (par exemple : `recu_don`). Si non spécifié, alors le nom du module courant sera utilisé. | | `columns` | *optionnel* | Permet de n'afficher que certaines colonnes du schéma. Indiquer ici le nom des colonnes, séparées par des virgules. | | `order` | *optionnel* | Colonne utilisée par défaut pour le tri (si l'utilisateur n'a pas choisi le tri sur une autre colonne). Si `select` est utilisé, il faut alors indiquer ici le numéro de la colonne, et non pas son nom. | | `desc` | *optionnel* | Si ce paramètre est à `true`, l'ordre de tri sera inversé. | | `max` | *optionnel* | Nombre d'éléments à afficher dans la liste, sur chaque page. | | `where` | *optionnel* | Condition `WHERE` de la requête SQL. | | `debug` | *optionnel* | Si ce paramètre existe, la requête SQL exécutée sera affichée avant le début de la boucle. | | `explain` | *optionnel* | Si ce paramètre existe, l'explication de la requête SQL exécutée sera affichée avant le début de la boucle. | Pour déterminer quelles colonnes afficher dans le tableau, il faut utiliser soit le paramètre `schema` pour indiquer un fichier de schéma JSON qui sera utilisé pour donner le libellé des colonnes (via la `description` indiquée dans le schéma), soit le paramètre `select`, où il faut alors indiquer le nom et le libellé des colonnes sous la forme `$$.colonne1 AS "Libellé"; $$.colonne2 AS "Libellé 2"`. Comme pour `load`, il est possible d'utiliser un paramètre commençant par un dollar : `$.cle="valeur"`. Cela va comparer `"valeur"` avec la valeur de la clé `cle` dans le document JSON. C'est l'équivalent d'écrire `where="json_extract(document, '$.cle') = 'valeur'"`. Pour des conditions plus complexes qu'une simple égalité, il est possible d'utiliser la syntaxe courte `$$…` dans le paramètre `where`. Ainsi `where="$$.nom LIKE 'Bourse%'` est l'équivalent de `where="json_extract(document, '$.nom') LIKE 'Bourse%'"`. Voir [la documentation de SQLite pour plus de détails sur la syntaxe de json_extract](https://www.sqlite.org/json1.html#jex). Note : un index SQL dynamique est créé pour chaque requête utilisant une clause `json_extract`. Chaque itération renverra toujours ces deux variables : | Variable | Valeur | | :- | :- | | `$key` | Clé unique du document | | `$id` | Numéro unique du document | Ainsi que chaque élément du document JSON lui-même. La section ouvre un tableau HTML et le ferme automatiquement, donc le contenu de la section **doit** être une ligne de tableau HTML (`<tr>`). Dans chaque ligne du tableau il faut respecter l'ordre des colonnes indiqué dans `columns` ou `select`. Une dernière colonne est réservée aux boutons d'action : `<td class="actions">...</td>`. ### Exemples Lister le nom, la date et le montant des reçus fiscaux, à partir du schéma JSON suivant : ``` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "date": { "description": "Date d'émission", "type": "string", "format": "date" }, "adresse": { "description": "Adresse du bénéficiaire", "type": "string" }, "nom": { "description": "Nom du bénéficiaire", "type": "string" }, "montant": { "description": "Montant", "type": "integer", "minimum": 0 } } } ``` Le code de la section sera alors comme suivant : ``` {{#list schema="./recu.schema.json" columns="nom, date, montant"}} <tr> <th>{{$nom}}</th> <td>{{$date|date_short}}</td> <td>{{$montant|raw|money_currency}}</td> <td class="actions"> {{:linkbutton shape="eye" label="Ouvrir" href="./voir.html?id=%d"|args:$id target="_dialog"}} </td> </tr> {{else}} <p class="alert block">Aucun reçu n'a été trouvé.</p> {{/list}} ``` Si le paramètre `columns` avait été omis, la colonne `adresse` aurait également été incluse. Il est à noter que si l'utilisation directe du schéma est bien pratique, cela ne permet pas de récupérer des informations plus complexes dans la structure JSON, par exemple une sous-clé ou l'application d'une fonction SQL. Dans ce cas il faut obligatoirement utiliser `select`. Par exemple ici on veut pouvoir afficher l'année, et trier sur l'année par défaut : ``` {{#list select="$$.nom AS 'Nom du donateur' ; strftime('%Y', $$.date) AS 'Année'" order=2}} <tr> <th>{{$nom}}</th> <td>{{$col2}}</td> <td class="actions"> {{:linkbutton shape="eye" label="Ouvrir" href="./voir.html?id=%d"|args:$id target="_dialog"}} </td> </tr> {{else}} <p class="alert block">Aucun reçu n'a été trouvé.</p> {{/list}} ``` On peut utiliser le nom des clés du document JSON, mais sinon pour faire référence à la valeur d'une colonne spécifique dans la boucle, il faut utiliser son numéro d'ordre (qui commence à `1`, pas zéro). Ici on veut afficher l'année, donc la seconde colonne, donc `$col1`. Noter aussi l'utilisation du numéro de la colonne de l'année (`2`) pour le paramètre `order`, qui avec `select` doit indiquer le numéro de la colonne à utiliser pour l'ordre. ## users Liste les membres. Paramètres possibles : | `id` | optionnel | Identifiant unique du membre | Chaque itération renverra la fiche du membre, ainsi que ces variables : | `$id` | Identifiant unique du membre | | `$user_name` | Nom du membre, tel que défini dans la configuration | | `$user_login` | Identifiant de connexion du membre, tel que défini dans la configuration | ## subscriptions Liste les inscriptions à une ou des activités. Paramètres possibles : | `user` | optionnel | Identifiant unique du membre | | `active` | optionnel | Si `TRUE`, seules les inscriptions à jour sont listées | |
Modified doc/admin/web.md from [91062da00f] to [d5a305d427].
1 2 3 4 5 6 7 8 9 10 11 | Title: Squelettes du site web dans Paheko {{{.nav * [Documentation Brindille](brindille.html) * [Fonctions](brindille_functions.html) * [Sections](brindille_sections.html) * [Filtres](brindille_modifiers.html) }}} # Les squelettes du site web | | | | < | < < < < < < | | | > | | | < > | | | | > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | Title: Squelettes du site web dans Paheko {{{.nav * [Documentation Brindille](brindille.html) * [Fonctions](brindille_functions.html) * [Sections](brindille_sections.html) * [Filtres](brindille_modifiers.html) }}} # Les squelettes du site web Les squelettes sont un ensemble de fichiers qui permettent de modéliser l'apparence du site web selon ses préférences et besoins. La syntaxe utilisée dans les squelettes s'appelle **[Brindille](brindille.html)**. Voir la [documentation de Brindille](brindille.html) pour son fonctionnement. # Exemples de sites réalisés avec Paheko * [ASBM Mortagne](https://asbm-mortagne.fr/) * [Vélocité 63](https://www.velocite63.fr/) * [La rustine, Dijon](https://larustine.org/) * [Tauto école](https://tauto-ecole.net/) [(les squelettes sont disponibles ici)](https://gitlab.com/noizette/squelettes-garradin-tauto-ecole/) * [La boîte à vélos](https://boiteavelos.chenove.net/) # Fonctionnement des squelettes Par défaut sont fournis plusieurs squelettes qui permettent d'avoir un site web basique mais fonctionnel : page d'accueil, menu avec les catégories de premier niveau, et pour afficher les pages, les catégories, les fichiers joints et images. Il y a également un squelette `atom.xml` permettant aux visiteurs d'accéder aux dernières pages publiées. Les squelettes peuvent être modifiés via l'onglet **Configuration** de la section **Site web** du menu principal. Une fois un squelette modifié, il apparaît dans la liste comme étant modifié, sinon il apparaît comme *défaut*. Si vous avez commis une erreur, il est possible de restaurer le squelette d'origine. ## Adresses des pages du site Les squelettes sont appelés en fonction des règles suivantes (dans l'ordre) : | Squelette appelé | Cas où le squelette est appelé | | :---- | :---- | | `adresse` | Si l'adresse `adresse` est appelée, et qu'un squelette du même nom existe | | `adresse/index.html` | Si l'adresse `adresse/` est appelée, et qu'un squelette `index.html` dans le répertoire du même nom existe | | `category.html` | Toute autre adresse se terminant par un slash `/`, si une catégorie du même nom existe | | `article.html` | Toute autre adresse, si une page du même nom existe | | `404.html` | Si aucune règle précédente n'a fonctionné | Ainsi l'adresse `https://monsite.paheko.cloud/Actualite/` appellera le squelette `category.html`, mais l'adresse `https://monsite.paheko.cloud/Actualite` (sans slash à la fin) appellera le squelette `article.html` si un article avec l'URI `Actualite` existe. Si un squelette `Actualite` (sans extension) existe, c'est lui qui sera appelé en priorité et ni `category.html` ni `article.html` ne seront appelés. Autre exemple : `https://monsite.paheko.cloud/atom.xml` appellera le squelette `atom.xml` s'il existe. Ceci vous permet de créer de nouvelles pages dynamiques sur le site, par exemple pour notre atelier vélo nous avons une page `https://larustine.org/velos` qui appelle le squelette `velos` (sans extension), qui va afficher la liste des vélos actuellement en stock dans notre hangar. Le type de fichier étant déterminé selon l'extension (`.html, .css, etc.`) pour les fichiers traités par Brindille, un fichier sans extension sera considéré comme un fichier texte par le navigateur. Si on veut que le squelette `velos` (sans extension) s'affiche comme du HTML il faut forcer le type en mettant le code `{{:http type="text/html"}}` au début du squelette (première ligne). ## Fichier content.css Ce fichier est particulier, car il définit le style du contenu des pages et des catégories. Ainsi il est également utilisé quand vous éditez un contenu dans l'administration. Donc si vous souhaitez modifier le style d'un élément du texte, il vaux mieux modifier ce fichier, sinon le rendu sera différent entre l'administration et le site public. |
Modified src/.htaccess.www from [3ae6d113cf] to [1fa848cf6a].
︙ | ︙ | |||
20 21 22 23 24 25 26 | # Objectif: supprimer le /www/ de l'URL # Note: il est probable qu'il soit nécessaire d'adapter la configuration # à votre hébergeur ! <IfModule mod_rewrite.c> RewriteEngine on ## Remplacer dans les lignes suivantes | | | | | 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | # Objectif: supprimer le /www/ de l'URL # Note: il est probable qu'il soit nécessaire d'adapter la configuration # à votre hébergeur ! <IfModule mod_rewrite.c> RewriteEngine on ## Remplacer dans les lignes suivantes ## /paheko/ par le nom du sous-répertoire où est installé Paheko RewriteBase /paheko/ FallbackResource /paheko/www/_route.php ## Ne pas modifier les lignes suivantes, les décommenter simplement ! RewriteCond %{REQUEST_URI} !www/ RewriteRule ^(.*)$ www/$1 [QSA,L] </IfModule> |
Modified src/Makefile from [8af5eeaacf] to [fc09ed5571].
︙ | ︙ | |||
41 42 43 44 45 46 47 | mv www/admin/static/mini.css /tmp/paheko-build/paheko/src/www/admin/static/admin.css cd /tmp/paheko-build/paheko/src/www/admin/static; \ rm -f styles/[0-9]*.css; \ rm -f font/*.css font/*.json cd /tmp/paheko-build/paheko/src; \ rm -f Makefile include/lib/KD2/data/countries.en.json cd /tmp/paheko-build/paheko/src/data; mkdir plugins && cd plugins; \ | | > > > > > > | 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | mv www/admin/static/mini.css /tmp/paheko-build/paheko/src/www/admin/static/admin.css cd /tmp/paheko-build/paheko/src/www/admin/static; \ rm -f styles/[0-9]*.css; \ rm -f font/*.css font/*.json cd /tmp/paheko-build/paheko/src; \ rm -f Makefile include/lib/KD2/data/countries.en.json cd /tmp/paheko-build/paheko/src/data; mkdir plugins && cd plugins; \ wget https://fossil.kd2.org/paheko-plugins/uv/welcome.tar.gz \ && wget https://fossil.kd2.org/paheko-plugins/uv/caisse.tar.gz \ && wget https://fossil.kd2.org/paheko-plugins/uv/taima.tar.gz \ && wget https://fossil.kd2.org/paheko-plugins/uv/dompdf.tar.gz \ && wget https://fossil.kd2.org/paheko-plugins/uv/reservations.tar.gz \ && wget https://fossil.kd2.org/paheko-plugins/uv/webstats.tar.gz \ && wget https://fossil.kd2.org/paheko-plugins/uv/stock_velos.tar.gz mv /tmp/paheko-build/paheko/src /tmp/paheko-build/paheko-${VERSION} @#cd /tmp/paheko-build/; zip -r -9 paheko-${VERSION}.zip paheko-${VERSION}; @#mv -f /tmp/paheko-build/paheko-${VERSION}.zip ./ tar czvfh paheko-${VERSION}.tar.gz --hard-dereference -C /tmp/paheko-build paheko-${VERSION} deb: cd ../build/debian; ./makedeb.sh |
︙ | ︙ |
Modified src/VERSION from [6caf539ada] to [98db9e6a5b].
|
| | | 1 | 1.3.0-alpha1 |
Modified src/config.dist.php from [fee27d0d97] to [65125f1971].
1 2 3 4 | <?php /** * Ce fichier représente un exemple des constantes de configuration | | | | | > > > > | > > | < | | > > > > > > > > > > > | | < < < < < < < < < < < < < < | | | | > > > > > > > > > > > > > > > > > > > > > | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | <?php /** * Ce fichier représente un exemple des constantes de configuration * disponibles pour Paheko. * * NE PAS MODIFIER CE FICHIER! * * Pour configurer Paheko, copiez ce fichier en 'config.local.php' * puis décommentez et modifiez ce dont vous avez besoin. */ // Nécessaire pour situer les constantes dans le bon namespace namespace Garradin; /** * Clé secrète, doit être unique à chaque instance de Paheko * * Ceci est utilisé afin de sécuriser l'envoi de formulaires * (protection anti-CSRF). * * Cette valeur peut être modifiée sans autre impact que la déconnexion des utilisateurs * actuellement connectés. * * Si cette constante n'est définie, Paheko ajoutera automatiquement * une valeur aléatoire dans le fichier config.local.php. */ //const SECRET_KEY = '3xUhIgGwuovRKOjVsVPQ5yUMfXUSIOX2GKzcebsz5OINrYC50r'; /** * @var null|int|array * * Forcer la connexion locale * * Si un numéro est spécifié, alors le membre avec l'ID correspondant à ce * numéro sera connecté (sans besoin de mot de passe). * * Exemple: LOCAL_LOGIN = 42 connectera automatiquement le membre avec id = 42 * Attention à ne pas utiliser en production ! * * Si le nombre spécifié est -1, alors c'est le premier membre trouvé qui * peut gérer la configuration (et donc modifier les droits des membres) * qui sera connecté. * * Si un tableau est spécifié, alors Paheko considérera que l'utilisateur * connecté fourni dans le tableau n'est pas un membre. * Voir la documentation sur l'utilisation avec SSO et LDAP pour plus de détails. * * Exemple : * const LOCAL_LOGIN = [ * 'user' => ['_name' => 'bohwaz'], * 'permissions' => ['users' => 9, 'config' => 9] * ]; * * Défault : null (connexion automatique désactivée) */ //const LOCAL_LOGIN = null; /** * Autoriser (ou non) l'import de sauvegarde qui a été modifiée ? * * Si mis à true, un avertissement et une confirmation seront demandés * Si mis à false, tout fichier SQLite importé qui ne comporte pas une signature * valide (hash SHA1) sera refusé. * * Ceci ne s'applique qu'à la page "Sauvegarde et restauration" de l'admin, * il est toujours possible de restaurer une base de données non signée en * la recopiant à la place du fichier association.sqlite * * Défaut : true */ //const ALLOW_MODIFIED_IMPORT = true; /** * Répertoire où se situe le code source de Paheko * * Défaut : répertoire racine de Paheko (__DIR__) */ //const ROOT = __DIR__; /** * Répertoire où sont situées les données de Paheko * (incluant la base de données SQLite, les sauvegardes, le cache, les fichiers locaux et les plugins) * * Défaut : sous-répertoire "data" de la racine */ //const DATA_ROOT = ROOT . '/data'; /** * Répertoire où est situé le cache, * exemples : graphiques de statistiques, templates Brindille, etc. * * Défaut : sous-répertoire 'cache' de DATA_ROOT */ //const CACHE_ROOT = DATA_ROOT . '/cache'; /** * Répertoire où est situé le cache partagé entre instances * Paheko utilisera ce répertoire pour stocker le cache susceptible d'être partagé entre instances, comme * le code PHP généré à partir des templates Smartyer. * * Défaut : sous-répertoire 'shared' de CACHE_ROOT */ //const SHARED_CACHE_ROOT = CACHE_ROOT . '/shared'; /** * Motif qui détermine l'emplacement des fichiers de cache du site web. * * Le site web peut créer des fichiers de cache pour les pages et catégories. * Ensuite le serveur web (Apache) servira ces fichiers directement, sans faire * appel au PHP, permettant de supporter beaucoup de trafic si le site web * a une vague de popularité. * * Certaines valeurs sont remplacées : * %host% = hash MD5 du hostname (utile en cas d'hébergement de plusieurs instances) * %host.2% = 2 premiers caractères du hash MD5 du hostname * * Utiliser NULL pour désactiver le cache. * * Défault : CACHE_ROOT . '/web/%host%' * * @var null|string */ //const WEB_CACHE_ROOT = CACHE_ROOT . '/web/%host%'; /** * Emplacement du fichier de base de données de Paheko * * Défaut : DATA_ROOT . '/association.sqlite' */ //const DB_FILE = DATA_ROOT . '/association.sqlite'; /** |
︙ | ︙ | |||
135 136 137 138 139 140 141 | * La clé est le nom du signal, et la valeur est la fonction. * * Défaut: [] (tableau vide) */ //const SYSTEM_SIGNALS = [['files.delete' => 'MyNamespace\Signals::deleteFile'], ['entity.Accounting\Transaction.save.before' => 'MyNamespace\Signals::saveTransaction']]; /** | | | | | | | 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | * La clé est le nom du signal, et la valeur est la fonction. * * Défaut: [] (tableau vide) */ //const SYSTEM_SIGNALS = [['files.delete' => 'MyNamespace\Signals::deleteFile'], ['entity.Accounting\Transaction.save.before' => 'MyNamespace\Signals::saveTransaction']]; /** * Adresse URI de la racine du site Paheko * (doit se terminer par un slash) * * Défaut : découverte automatique à partir de SCRIPT_NAME */ //const WWW_URI = '/asso/'; /** * Adresse URL HTTP(S) de Paheko * * Défaut : découverte à partir de HTTP_HOST ou SERVER_NAME + WWW_URI */ //const WWW_URL = 'http://paheko.chezmoi.tld' . WWW_URI; /** * Adresse URL HTTP(S) de l'admin Paheko * * Défaut : WWW_URL + 'admin/' */ //const ADMIN_URL = 'https://admin.paheko.chezmoi.tld/'; /** * Affichage des erreurs * Si "true" alors un message expliquant l'erreur et comment rapporter le bug s'affiche * en cas d'erreur. Sinon rien ne sera affiché. * * Défaut : false |
︙ | ︙ | |||
185 186 187 188 189 190 191 | * * Défaut : false */ //const MAIL_ERRORS = false; /** | | | | | 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | * * Défaut : false */ //const MAIL_ERRORS = false; /** * Envoi des erreurs à une API compatible AirBrake/Errbit/Paheko * * Si renseigné avec une URL HTTP(S) valide, chaque erreur système sera envoyée * automatiquement à cette URL. * * Si laissé à null, aucun rapport ne sera envoyé. * * Paheko accepte aussi les rapports d'erreur venant d'autres instances. * * Pour cela utiliser l'URL https://login:password@paheko.site.tld/api/errors/report * (voir aussi API_USER et API_PASSWORD) * * Les erreurs seront ensuite visibles dans * Configuration -> Fonctions avancées -> Journal d'erreurs * * Défaut : null */ |
︙ | ︙ | |||
269 270 271 272 273 274 275 276 277 278 279 280 281 282 | * Cette option peut significativement ralentir le chargement des pages. * * Défaut : null (= désactivé) * @var string|null */ // const SQL_DEBUG = __DIR__ . '/debug_sql.sqlite'; /** * Mode de journalisation de SQLite * * Paheko recommande le mode 'WAL' de SQLite, qui permet à SQLite * d'être extrêmement rapide. * * Cependant, sur certains hébergeurs utilisant NFS, ce mode peut | > | 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 | * Cette option peut significativement ralentir le chargement des pages. * * Défaut : null (= désactivé) * @var string|null */ // const SQL_DEBUG = __DIR__ . '/debug_sql.sqlite'; /** /** * Mode de journalisation de SQLite * * Paheko recommande le mode 'WAL' de SQLite, qui permet à SQLite * d'être extrêmement rapide. * * Cependant, sur certains hébergeurs utilisant NFS, ce mode peut |
︙ | ︙ | |||
296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | * @see https://stackoverflow.com/questions/52378361/which-nfs-implementation-is-safe-for-sqlite-database-accessed-by-multiple-proces * * Défaut : 'TRUNCATE' * @var string */ //const SQLITE_JOURNAL_MODE = 'TRUNCATE'; /** * Activer la possibilité de faire une mise à jour semi-automatisée * depuis fossil.kd2.org. * * Si mis à TRUE, alors un bouton sera accessible depuis le menu "Configuration" * pour faire une mise à jour en deux clics. * * Il est conseillé de désactiver cette fonctionnalité si vous ne voulez pas * permettre à un utilisateur de casser l'installation ! * * Si cette constante est désactivée, mais que ENABLE_TECH_DETAILS est activé, * la vérification de nouvelle version se fera quand même, mais plutôt que de proposer | > > > > > > > > > > > > > > > > | | 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 | * @see https://stackoverflow.com/questions/52378361/which-nfs-implementation-is-safe-for-sqlite-database-accessed-by-multiple-proces * * Défaut : 'TRUNCATE' * @var string */ //const SQLITE_JOURNAL_MODE = 'TRUNCATE'; /** * Activation du log HTTP (option de développement) * * Si cette constante est renseignée par un fichier texte, *TOUTES* les requêtes HTTP * ainsi que leur contenu y sera enregistré. * * C'est surtout utile pour débuguer les problèmes de WebDAV par exemple. * * ATTENTION : cela signifie que des informations personnelles (mot de passe etc.) * peuvent se retrouver dans le log. Ne pas utiliser à moins de tester en développement. * * Default : null (= désactivé) * @var string|null */ // const HTTP_LOG_FILE = __DIR__ . '/http.log'; /** * Activer la possibilité de faire une mise à jour semi-automatisée * depuis fossil.kd2.org. * * Si mis à TRUE, alors un bouton sera accessible depuis le menu "Configuration" * pour faire une mise à jour en deux clics. * * Il est conseillé de désactiver cette fonctionnalité si vous ne voulez pas * permettre à un utilisateur de casser l'installation ! * * Si cette constante est désactivée, mais que ENABLE_TECH_DETAILS est activé, * la vérification de nouvelle version se fera quand même, mais plutôt que de proposer * la mise à jour, Paheko proposera de se rendre sur le site officiel pour * télécharger la mise à jour. * * Défaut : true * * @var bool */ |
︙ | ︙ | |||
350 351 352 353 354 355 356 | * - Lighttpd * * N'activer que si vous êtes sûr que le module est installé et activé (sinon * les fichiers ne pourront être vus ou téléchargés). * Nginx n'est PAS supporté, car X-Accel-Redirect ne peut gérer que des fichiers * qui sont *dans* le document root du vhost, ce qui n'est pas le cas ici. * | | | | > > > > > > > > > > > > > > > > > | 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 | * - Lighttpd * * N'activer que si vous êtes sûr que le module est installé et activé (sinon * les fichiers ne pourront être vus ou téléchargés). * Nginx n'est PAS supporté, car X-Accel-Redirect ne peut gérer que des fichiers * qui sont *dans* le document root du vhost, ce qui n'est pas le cas ici. * * Pour activer X-SendFile mettre dans la config du virtualhost de Paheko: * XSendFile On * XSendFilePath /var/www/paheko * * (remplacer le chemin par le répertoire racine de Paheko) * * Détails : https://tn123.org/mod_xsendfile/ * * Défaut : false */ //const ENABLE_XSENDFILE = false; /** * Serveur NTP utilisé pour les connexions avec TOTP * (utilisé seulement si le code OTP fourni est faux) * * Désactiver (false) si vous êtes sûr que votre serveur est toujours à l'heure. * * Défaut : fr.pool.ntp.org */ //const NTP_SERVER = 'fr.pool.ntp.org'; /** * Désactiver l'envoi d'e-mails * * Si positionné à TRUE, l'envoi d'e-mail ne sera pas proposé, et il ne sera * pas non plus possible de récupérer un mot de passe perdu. * Les parties de l'interface relatives à l'envoi d'e-mail seront cachées. * * Ce réglage est utilisé pour la version autonome sous Windows, car Windows * ne permet pas l'envoi d'e-mails. * * Défaut : false * @var bool */ //const DISABLE_EMAIL = false; /** * Hôte du serveur SMTP, mettre à false (défaut) pour utiliser la fonction * mail() de PHP * * Défaut : false */ |
︙ | ︙ | |||
402 403 404 405 406 407 408 | * Login utilisateur pour le server SMTP * * mettre à null pour utiliser un serveur local ou anonyme * * Défaut : null */ | | | 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 | * Login utilisateur pour le server SMTP * * mettre à null pour utiliser un serveur local ou anonyme * * Défaut : null */ //const SMTP_USER = 'paheko@monserveur.com'; /** * Mot de passe pour le serveur SMTP * * mettre à null pour utiliser un serveur local ou anonyme * * Défaut : null |
︙ | ︙ | |||
527 528 529 530 531 532 533 | */ //const DISABLE_INSTALL_FORM = false; /** * Stockage des fichiers * * Indiquer ici le nom d'une classe de stockage de fichiers | | | 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 | */ //const DISABLE_INSTALL_FORM = false; /** * Stockage des fichiers * * Indiquer ici le nom d'une classe de stockage de fichiers * (parmis celles disponibles dans lib/Paheko/Files/Backend) * * Indiquer NULL si vous souhaitez stocker les fichier dans la base * de données SQLite (valeur par défaut). * * Classes de stockage possibles : * - SQLite : enregistre dans la base de données (défaut) * - FileSystem : enregistrement des fichiers dans le système de fichier |
︙ | ︙ | |||
576 577 578 579 580 581 582 | * * Défaut : null (dans ce cas c'est le stockage qui détermine la taille disponible, donc généralement l'espace dispo sur le disque dur !) */ //const FILE_STORAGE_QUOTA = 10*1024*1024; // Forcer le quota alloué à 10 Mo, quel que soit le backend de stockage /** | > > > > > > > > | > | > | > > | | 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 | * * Défaut : null (dans ce cas c'est le stockage qui détermine la taille disponible, donc généralement l'espace dispo sur le disque dur !) */ //const FILE_STORAGE_QUOTA = 10*1024*1024; // Forcer le quota alloué à 10 Mo, quel que soit le backend de stockage /** * Adresse de découverte d'un client d'édition de documents (WOPI) * (type OnlyOffice, Collabora, MS Office) * * Cela permet de savoir quels types de fichiers sont éditables * avec l'éditeur web. * * Si NULL, alors l'édition de documents est désactivée. * * Défaut : null */ //const WOPI_DISCOVERY_URL = 'http://localhost:9980/hosting/discovery'; /** * PDF_COMMAND * Commande qui sera exécutée pour créer un fichier PDF à partir d'un HTML. * * Si laissé sur 'auto', Paheko essaiera de détecter une solution entre * PrinceXML, Chromium, wkhtmltopdf ou weasyprint (dans cet ordre). * Si aucune solution n'est disponible, une erreur sera affichée. * * Il est possible d'indiquer NULL pour désactiver l'export en PDF. * * Il est possible d'indiquer uniquement le nom du programme : * 'chromium', 'prince', 'weasyprint', ou 'wkhtmltopdf'. |
︙ | ︙ | |||
604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 | * Si vous utilisez une extension pour générer les PDF (comme DomPDF), alors * laisser cette constante sur 'auto'. * * Exemples : * 'weasyprint' * 'wkhtmltopdf -q --print-media-type --enable-local-file-access %s %s' * * Défaut : 'auto' * @var null|string */ //const PDF_COMMAND = 'auto'; /** * PDF_USAGE_LOG * Chemin vers le fichier où enregistrer la date de chaque export en PDF * * Ceci est utilisé notamment pour estimer le prix de la licence PrinceXML. * * Défaut : NULL * @var null|string */ //const PDF_USAGE_LOG = null; /** * CALC_CONVERT_COMMAND * Outil de conversion de formats de tableur vers un format propriétaire * | > > > > | | | 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 | * Si vous utilisez une extension pour générer les PDF (comme DomPDF), alors * laisser cette constante sur 'auto'. * * Exemples : * 'weasyprint' * 'wkhtmltopdf -q --print-media-type --enable-local-file-access %s %s' * * Si vous utilisez Prince, un message mentionnant l'utilisation de Prince * sera joint aux e-mails utilisant des fichiers PDF, conformément à la licence : * https://www.princexml.com/purchase/license_faq/#non-commercial * * Défaut : 'auto' * @var null|string */ //const PDF_COMMAND = 'auto'; /** * PDF_USAGE_LOG * Chemin vers le fichier où enregistrer la date de chaque export en PDF * * Ceci est utilisé notamment pour estimer le prix de la licence PrinceXML. * * Défaut : NULL * @var null|string */ //const PDF_USAGE_LOG = null; /** * CALC_CONVERT_COMMAND * Outil de conversion de formats de tableur vers un format propriétaire * * Paheko gère nativement les exports en ODS (OpenDocument : LibreOffice) * et CSV, et imports en CSV. * * En indiquant ici le nom d'un outil, Paheko autorisera aussi * l'import en XLSX, XLS et ODS, et l'export en XLSX. * * Pour cela il procédera simplement à une conversion entre les formats natifs * ODS/CSV et XLSX ou XLS. * * Noter qu'installer ces commandes peut introduire des risques de sécurité sur le serveur. * |
︙ | ︙ | |||
650 651 652 653 654 655 656 | //const CALC_CONVERT_COMMAND = 'ssconvert'; //const CALC_CONVERT_COMMAND = 'unoconvert --interface localhost --port 2022'; /** * API_USER et API_PASSWORD * Login et mot de passe système de l'API * | | | | | 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 | //const CALC_CONVERT_COMMAND = 'ssconvert'; //const CALC_CONVERT_COMMAND = 'unoconvert --interface localhost --port 2022'; /** * API_USER et API_PASSWORD * Login et mot de passe système de l'API * * Une API est disponible via l'URL https://login:password@paheko.association.tld/api/... * Voir https://fossil.kd2.org/paheko/wiki?name=API pour la documentation * * Ces deux constantes permettent d'indiquer un nom d'utilisateur * et un mot de passe pour accès à l'API. * * Cet utilisateur est distinct de ceux définis dans la page de gestion des * identifiants d'accès à l'API, et aura accès à TOUT en écriture/administration. * * Défaut: null */ //const API_USER = 'coraline'; //const API_PASSWORD = 'thisIsASecretPassword42'; /** * DISABLE_INSTALL_PING * * Lors de l'installation, ou d'une mise à jour, la version installée de Paheko, * ainsi que celle de PHP et de SQLite, sont envoyées à Paheko.cloud. * * Cela permet de savoir quelles sont les versions utilisées, et également de compter * le nombre d'installations effectuées. * * Aucune donnée personnelle n'est envoyée. Un identifiant anonyme est envoyé, * permettant d'identifier l'installation et éviter les doublons. |
︙ | ︙ | |||
703 704 705 706 707 708 709 | * Il faut recopier cette clé dans le fichier config.local.php * dans la constante CONTRIBUTOR_LICENSE. * * Merci de ne pas essayer de contourner cette licence et de contribuer au * financement de notre travail :-) */ //const CONTRIBUTOR_LICENSE = 'XXXXX'; | > > > > > > > > > > > | 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 | * Il faut recopier cette clé dans le fichier config.local.php * dans la constante CONTRIBUTOR_LICENSE. * * Merci de ne pas essayer de contourner cette licence et de contribuer au * financement de notre travail :-) */ //const CONTRIBUTOR_LICENSE = 'XXXXX'; /** * Ligne légale sur le pied de page du site public * * Ce texte (HTML) est affiché en bas des pages du site public. * Utile pour indiquer les mentions légales obligatoires * Le %1$s est remplacé par le nom de l'association, %2$s par son adresse. * * Défaut : "Hébergé par nom_association, adresse_association" */ //const LEGAL_LINE = 'Hébergé par <strong>%1$s</strong>, %2$s'; |
Deleted src/include/data/1.0.0-beta6_migration.sql version [13c95a32fe].
|
| < < < < < < < < < < < < < |
Deleted src/include/data/1.0.0-beta8_migration.sql version [ff1b70a076].
|
| < < |
Deleted src/include/data/1.0.0-rc10_migration.sql version [ab6262425c].
|
| < < < < |
Deleted src/include/data/1.0.0-rc14_migration.sql version [2be7aec0e4].
|
| < < < < |
Deleted src/include/data/1.0.0-rc16_migration.sql version [2408d33901].
|
| < < |
Deleted src/include/data/1.0.0-rc3_migration.sql version [46b4d521e5].
|
| < |
Deleted src/include/data/1.0.0_migration.sql version [5b1e5033eb].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/include/data/1.0.0_schema.sql version [292ae06778].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/include/data/1.0.1_migration.sql version [d72d553917].
|
| < < < < < < < < < < < < < < < |
Deleted src/include/data/1.0.3_migration.sql version [9ded2b030d].
|
| < < |
Deleted src/include/data/1.0.6_migration.sql version [6e98c5399a].
|
| < < |
Deleted src/include/data/1.0.7_migration.sql version [f53124e2db].
|
| < < < < < |
Deleted src/include/data/1.1.0_migration.sql version [9f5de55bec].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/include/data/1.1.15_migration.sql version [f806b6152c].
|
| < < < < < |
Deleted src/include/data/1.1.19_migration.sql version [a29354b289].
|
| < < < |
Deleted src/include/data/1.1.3_migration.sql version [18eac8e4eb].
|
| < < < < |
Deleted src/include/data/1.1.7_migration.sql version [1dab4145d3].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted src/include/data/1.1.8_migration.sql version [cc434f178d].
|
| < < < < < |
Deleted src/include/data/champs_membres.ini version [1af4967b02].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Modified src/include/data/schema.sql from [bb4040c594] to [57116110a2].
|
| | | 1 | ../migrations/1.3/schema.sql |
Added src/include/data/users_fields_presets.ini version [082f99cf40].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | ; Ce fichier contient la configuration par défaut des champs des fiches membres. ; La configuration est ensuite enregistrée au format INI dans la table ; config de la base de données. ; ; Syntaxe : ; ; [nom_du_champ] ; Nom unique du champ, ne peut contenir que des lettres et des tirets bas ; type = text ; label = "Super champ trop cool" ; required = true ; write_access = 0 ; ; Description des options possibles pour chaque champ : ; ; type: (défaut: text) OBLIGATOIRE ; certains types gérés par <input type> de HTML5 : ; text, number, date, datetime, url, email, checkbox, file, password, tel ; champs spécifiques : ; - country = sélecteur de pays ; - textarea = texte multi lignes ; - multiple = multiples cases à cocher (jusqu'à 32, binaire) ; - select = un choix parmis plusieurs ; label: OBLIGATOIRE ; Titre du champ ; help: ; Texte d'aide sur les fiches membres ; options[]: ; pour définir les options d'un champ de type select ou multiple ; write_access: ; 1 = modifiable par le membre ; 0 = modifiable uniquement par un admin (défaut) ; required: ; true = obligatoire, la fiche membre ne pourra être enregistrée si ce champ est vide ; false = facultatif (défaut) ; read_access: ; 1 = visible par le membre (défaut) ; 0 = visible uniquement par un admin ; list_table: 'true' si doit être listé par défaut dans la liste des membres ; sql: SQL code for GENERATED columns ; depends[]: list of fields that need to be existing in order to install this field [numero] type = number label = "Numéro de membre" required = true write_access = 0 list_table = true default = true [nom] type = text label = "Nom & prénom" required = true write_access = 1 list_table = true default = true [email] ; ce champ est facultatif et de type 'email' type = email label = "Adresse E-Mail" required = false write_access = 1 default = true [password] ; ce champ est obligatoirement présent et de type 'password' ; le titre ne peut être modifié label = "Mot de passe" type = password required = false write_access = 1 default = true [adresse] type = textarea label = "Adresse postale" help = "Indiquer ici le numéro, le type de voie, etc." write_access = 1 default = true [code_postal] type = text label = "Code postal" write_access = 1 default = true [ville] type = text label = "Ville" write_access = 1 list_table = true default = true [pays] type = country label = "Pays" write_access = 1 default = false [telephone] type = tel label = "Numéro de téléphone" write_access = 1 default = true [lettre_infos] type = checkbox label = "Inscription à la lettre d'information" write_access = 1 default = true [annee_naissance] type = year label = "Année de naissance" default = false [age_annee] type = generated label = "Âge" install_help = "Déterminé en utilisant l'année de naissance" depends[] = annee_naissance default = false sql = "strftime('%Y', date) - annee_naissance" [date_naissance] type = date label = "Date de naissance complète" default = false install_help = "Attention, cette information est très sensible, il est déconseillé par le RGPD de la demander aux membres. Il est préférable de demander seulement l'année de naissance." [age_date] type = generated label = "Âge" install_help = "Déterminé en utilisant la date de naissance" depends[] = date_naissance default = false sql = "CAST(strftime('%Y.%m%d', date()) - strftime('%Y.%m%d', date_naissance) as int)" [photo] type = file label = "Photo" default = false [date_inscription] type = date label = "Date d'inscription" help = "Date à laquelle le membre a été inscrit à l'association pour la première fois" default = true default_value = '=NOW' [anciennete] type = generated label = "Ancienneté" help = "Nombre d'années depuis l'inscription" depends[] = date_inscription default = false sql = "CAST(strftime('%Y.%m%d', date()) - strftime('%Y.%m%d', date_inscription) as int)" |
Modified src/include/init.php from [df194f0caa] to [73265b0a9f].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | <?php namespace Garradin; use KD2\ErrorManager; use KD2\Security; use KD2\Form; use KD2\Translate; use KD2\DB\EntityManager; error_reporting(-1); /* * Version de Garradin */ function garradin_version() { if (defined('Garradin\VERSION')) | > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | <?php namespace Garradin; use KD2\ErrorManager; use KD2\Security; use KD2\Form; use KD2\Translate; use KD2\DB\EntityManager; error_reporting(-1); const CONFIG_FILE = 'config.local.php'; /* * Version de Garradin */ function garradin_version() { if (defined('Garradin\VERSION')) |
︙ | ︙ | |||
86 87 88 89 90 91 92 | } /* * Configuration globale */ // Configuration externalisée | | | | | | > > > > > > > | 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | } /* * Configuration globale */ // Configuration externalisée if (file_exists(__DIR__ . '/../' . CONFIG_FILE)) { require __DIR__ . '/../' . CONFIG_FILE; } // Configuration par défaut, si les constantes ne sont pas définies dans CONFIG_FILE // (fallback) if (!defined('Garradin\ROOT')) { define('Garradin\ROOT', dirname(__DIR__)); } \spl_autoload_register(function (string $classname): void { $classname = ltrim($classname, '\\'); // Plugins if (substr($classname, 0, 16) == 'Garradin\\Plugin\\') { $classname = substr($classname, 16); $plugin_name = substr($classname, 0, strpos($classname, '\\')); $filename = str_replace('\\', '/', substr($classname, strpos($classname, '\\')+1)); $path = Plugins::getPath(strtolower($plugin_name)); // Plugin does not exist, just abort if (!$path) { return; } $path = $path . '/lib/' . $filename . '.php'; } else { // PSR-0 autoload $filename = str_replace('\\', '/', $classname); $path = ROOT . '/include/lib/' . $filename . '.php'; } |
︙ | ︙ | |||
180 181 182 183 184 185 186 187 188 189 | if (!defined('Garradin\WWW_URL') && $host !== null) { define('Garradin\WWW_URL', \KD2\HTTP::getScheme() . '://' . $host . WWW_URI); } static $default_config = [ 'CACHE_ROOT' => DATA_ROOT . '/cache', 'SHARED_CACHE_ROOT' => DATA_ROOT . '/cache/shared', 'DB_FILE' => DATA_ROOT . '/association.sqlite', 'DB_SCHEMA' => ROOT . '/include/data/schema.sql', 'PLUGINS_ROOT' => DATA_ROOT . '/plugins', | > < > > | 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | if (!defined('Garradin\WWW_URL') && $host !== null) { define('Garradin\WWW_URL', \KD2\HTTP::getScheme() . '://' . $host . WWW_URI); } static $default_config = [ 'CACHE_ROOT' => DATA_ROOT . '/cache', 'SHARED_CACHE_ROOT' => DATA_ROOT . '/cache/shared', 'WEB_CACHE_ROOT' => DATA_ROOT . '/cache/web/%host%', 'DB_FILE' => DATA_ROOT . '/association.sqlite', 'DB_SCHEMA' => ROOT . '/include/data/schema.sql', 'PLUGINS_ROOT' => DATA_ROOT . '/plugins', 'ALLOW_MODIFIED_IMPORT' => true, 'SHOW_ERRORS' => true, 'MAIL_ERRORS' => false, 'ERRORS_REPORT_URL' => null, 'REPORT_USER_EXCEPTIONS' => 0, 'ENABLE_TECH_DETAILS' => true, 'HTTP_LOG_FILE' => null, 'ENABLE_UPGRADES' => true, 'USE_CRON' => false, 'ENABLE_XSENDFILE' => false, 'DISABLE_EMAIL' => false, 'SMTP_HOST' => false, 'SMTP_USER' => null, 'SMTP_PASSWORD' => null, 'SMTP_PORT' => 587, 'SMTP_SECURITY' => 'STARTTLS', 'MAIL_RETURN_PATH' => null, 'MAIL_BOUNCE_PASSWORD' => null, |
︙ | ︙ | |||
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | 'API_PASSWORD' => null, 'PDF_COMMAND' => 'auto', 'PDF_USAGE_LOG' => null, 'CALC_CONVERT_COMMAND' => null, 'CONTRIBUTOR_LICENSE' => null, 'SQL_DEBUG' => null, 'SYSTEM_SIGNALS' => [], 'DISABLE_INSTALL_PING' => false, 'SQLITE_JOURNAL_MODE' => 'TRUNCATE', ]; foreach ($default_config as $const => $value) { $const = sprintf('Garradin\\%s', $const); | > > > | 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | 'API_PASSWORD' => null, 'PDF_COMMAND' => 'auto', 'PDF_USAGE_LOG' => null, 'CALC_CONVERT_COMMAND' => null, 'CONTRIBUTOR_LICENSE' => null, 'SQL_DEBUG' => null, 'SYSTEM_SIGNALS' => [], 'LOCAL_LOGIN' => null, 'LEGAL_LINE' => 'Hébergé par <strong>%1$s</strong>, %2$s', 'DISABLE_INSTALL_PING' => false, 'WOPI_DISCOVERY_URL' => null, 'SQLITE_JOURNAL_MODE' => 'TRUNCATE', ]; foreach ($default_config as $const => $value) { $const = sprintf('Garradin\\%s', $const); |
︙ | ︙ | |||
259 260 261 262 263 264 265 | const SHARED_USER_TEMPLATES_CACHE_ROOT = SHARED_CACHE_ROOT . '/utemplates'; const SMARTYER_CACHE_ROOT = SHARED_CACHE_ROOT . '/compiled'; // PHP devrait être assez intelligent pour chopper la TZ système mais nan // il sait pas faire (sauf sur Debian qui a le bon patch pour ça), donc pour // éviter le message d'erreur à la con on définit une timezone par défaut // Pour utiliser une autre timezone, il suffit de définir date.timezone dans | | | 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 | const SHARED_USER_TEMPLATES_CACHE_ROOT = SHARED_CACHE_ROOT . '/utemplates'; const SMARTYER_CACHE_ROOT = SHARED_CACHE_ROOT . '/compiled'; // PHP devrait être assez intelligent pour chopper la TZ système mais nan // il sait pas faire (sauf sur Debian qui a le bon patch pour ça), donc pour // éviter le message d'erreur à la con on définit une timezone par défaut // Pour utiliser une autre timezone, il suffit de définir date.timezone dans // un .htaccess ou dans CONFIG_FILE if (!ini_get('date.timezone')) { if (($tz = @date_default_timezone_get()) && $tz != 'UTC') { ini_set('date.timezone', $tz); } else |
︙ | ︙ | |||
303 304 305 306 307 308 309 | if (ERRORS_REPORT_URL) { ErrorManager::setRemoteReporting(ERRORS_REPORT_URL, true); } ErrorManager::setProductionErrorTemplate(defined('Garradin\ERRORS_TEMPLATE') && ERRORS_TEMPLATE ? ERRORS_TEMPLATE : '<!DOCTYPE html><html><head><title>Erreur interne</title> <style type="text/css"> | | | | | > | > | > > > | | > > | 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 | if (ERRORS_REPORT_URL) { ErrorManager::setRemoteReporting(ERRORS_REPORT_URL, true); } ErrorManager::setProductionErrorTemplate(defined('Garradin\ERRORS_TEMPLATE') && ERRORS_TEMPLATE ? ERRORS_TEMPLATE : '<!DOCTYPE html><html><head><title>Erreur interne</title> <style type="text/css"> body {font-family: sans-serif; background: #fff; } code, p, h1 { max-width: 400px; margin: 1em auto; display: block; } code { text-align: right; color: #666; } a { color: blue; } form { text-align: center; } </style></head><body><h1>Erreur interne</h1><p>Désolé mais le serveur a rencontré une erreur interne et ne peut répondre à votre requête. Merci de ré-essayer plus tard.</p> <p>Si vous suspectez un bug dans Paheko, vous pouvez suivre <a href="https://fossil.kd2.org/paheko/wiki?name=Rapporter+un+bug&p">ces instructions</a> pour le rapporter.</p> <if(sent)><p>Un-e responsable a été notifié-e et cette erreur sera corrigée dès que possible.</p></if> <if(logged)><code>L\'erreur a été enregistrée dans les journaux système (error.log) sous la référence : <b>{$ref}</b></code></if> <p><a href="' . WWW_URL . '">← Retour à la page d\'accueil</a></p> </body></html>'); ErrorManager::setHtmlHeader('<!DOCTYPE html><html><head><meta charset="utf-8" /><style type="text/css"> body { font-family: sans-serif; background: #fff; } * { margin: 0; padding: 0; } u, code b, i, h3 { font-style: normal; font-weight: normal; text-decoration: none; } #icn { color: #fff; font-size: 2em; float: right; margin: 1em; padding: 1em; background: #900; border-radius: 50%; } section header { background: #fdd; padding: 1em; } section article { margin: 1em; } section article h3, section article h4 { font-size: 1em; font-family: mono; } code { border: 1px dotted #ccc; display: block; } code b { margin-right: 1em; color: #999; } code u { background: #fcc; display: inline-block; width: 100%; } table { border-collapse: collapse; margin: 1em; } td, th { border: 1px solid #ccc; padding: .2em .5em; text-align: left; vertical-align: top; } input { padding: .3em; margin: .5em; font-size: 1.2em; cursor: pointer; } </style></head><body> <pre id="icn"> \__/<br /> (xx)<br />//||\\\\</pre> <section> <article> <h1>Une erreur s\'est produite</h1> <if(report)><form method="post" action="{$report_url}"><p><input type="hidden" name="report" value="{$report_json}" /><input type="submit" value="Rapporter l\'erreur aux développeur⋅euses de Paheko →" /></p></form></if> </article> </section> '); function user_error(UserException $e) { if (PHP_SAPI == 'cli') { echo $e->getMessage(); } else { // Don't use Template class as there might be an error there due do the context (eg. install/upgrade) $tpl = new \KD2\Smartyer(ROOT . '/templates/error.tpl'); $tpl->setCompiledDir(SMARTYER_CACHE_ROOT); $tpl->assign('error', $e->getMessage()); $tpl->assign('html_error', $e->getHTMLMessage()); $tpl->assign('admin_url', ADMIN_URL); $tpl->display(); } exit; } if (REPORT_USER_EXCEPTIONS < 2) { // Message d'erreur simple pour les erreurs de l'utilisateur ErrorManager::setCustomExceptionHandler('\Garradin\UserException', '\Garradin\user_error'); } // Clé secrète utilisée pour chiffrer les tokens CSRF etc. if (!defined('Garradin\SECRET_KEY')) { if (!is_writable(ROOT)) { throw new \RuntimeException('Impossible de créer le fichier de configuration "'. CONFIG_FILE .'". Le répertoire "'. ROOT . '" n\'est pas accessible en écriture.'); } $key = base64_encode(random_bytes(64)); Install::setLocalConfig('SECRET_KEY', $key); define('Garradin\SECRET_KEY', $key); } // Intégration du secret pour les tokens CSRF Form::tokenSetSecret(SECRET_KEY); EntityManager::setGlobalDB(DB::getInstance()); Translate::setLocale('fr_FR'); /* * Vérifications pour enclencher le processus d'installation ou de mise à jour */ if (!defined('Garradin\INSTALL_PROCESS')) { $exists = file_exists(DB_FILE); if (!$exists) { if (in_array('install.php', get_included_files())) { die('Erreur de redirection en boucle : problème de configuration ?'); } Utils::redirect(ADMIN_URL . 'install.php'); } |
︙ | ︙ |
Modified src/include/lib/Garradin/API.php from [2b3d70413d] to [1c14e4473d].
1 2 3 4 | <?php namespace Garradin; | | | 1 2 3 4 5 6 7 8 9 10 11 12 | <?php namespace Garradin; use Garradin\Users\Session; use Garradin\Web\Web; use Garradin\Accounting\Accounts; use Garradin\Accounting\Charts; use Garradin\Accounting\Reports; use Garradin\Accounting\Transactions; use Garradin\Accounting\Years; use Garradin\Entities\Accounting\Transaction; |
︙ | ︙ |
Modified src/include/lib/Garradin/Accounting/Accounts.php from [d233ba835a] to [171b8add61].
1 2 3 4 5 6 7 8 | <?php namespace Garradin\Accounting; use Garradin\Entities\Accounting\Account; use Garradin\Entities\Accounting\Line; use Garradin\Entities\Accounting\Transaction; use Garradin\Entities\Accounting\Year; | | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <?php namespace Garradin\Accounting; use Garradin\Entities\Accounting\Account; use Garradin\Entities\Accounting\Line; use Garradin\Entities\Accounting\Transaction; use Garradin\Entities\Accounting\Year; use Garradin\Users\DynamicFields; use Garradin\DB; use Garradin\DynamicList; use Garradin\Utils; use Garradin\UserException; use Garradin\ValidationException; use KD2\DB\EntityManager; |
︙ | ︙ | |||
247 248 249 250 251 252 253 | public function getClosingAccountId(): ?int { return $this->getIdForType(Account::TYPE_CLOSING); } public function listUserAccounts(int $year_id): DynamicList { | < < | | | > > > > > > > > > > > > > | 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 | public function getClosingAccountId(): ?int { return $this->getIdForType(Account::TYPE_CLOSING); } public function listUserAccounts(int $year_id): DynamicList { $columns = [ 'id' => [ 'select' => 'u.id', ], 'user_number' => [ 'select' => 'u.' . DynamicFields::getNumberField(), 'label' => 'N° membre', ], 'user_identity' => [ 'select' => DynamicFields::getNameFieldsSQL('u'), 'label' => 'Membre', ], 'balance' => [ 'select' => 'SUM(l.debit - l.credit)', 'label' => 'Solde', //'order' => 'balance != 0 %s, balance < 0 %1$s', ], 'status' => [ 'select' => null, 'label' => 'Statut', ], ]; $tables = 'acc_transactions_users tu INNER JOIN users u ON u.id = tu.id_user INNER JOIN acc_transactions t ON tu.id_transaction = t.id INNER JOIN acc_transactions_lines l ON t.id = l.id_transaction INNER JOIN acc_accounts a ON a.id = l.id_account'; $conditions = 'a.type = ' . Account::TYPE_THIRD_PARTY . ' AND t.id_year = ' . $year_id; $list = new DynamicList($columns, $tables, $conditions); $list->orderBy('balance', false); $list->groupBy('u.id'); $list->setCount('COUNT(*)'); $list->setPageSize(null); $list->setExportCallback(function (&$row) { $row->balance = Utils::money_format($row->balance, '.', '', false); }); return $list; } /** * Renvoie TRUE si le solde du compte est inversé (= crédit - débit, au lieu de débit - crédit) * @return boolean */ static public function isReversed(bool $simple, int $type): bool { if ($simple && in_array($type, [Account::TYPE_BANK, Account::TYPE_CASH, Account::TYPE_OUTSTANDING, Account::TYPE_EXPENSE, Account::TYPE_THIRD_PARTY])) { return false; } return true; } /* FIXME: implement closing of accounts public function closeRevenueExpenseAccounts(Year $year, int $user_id) { $closing_id = $this->getClosingAccountId(); |
︙ | ︙ |
Added src/include/lib/Garradin/Accounting/AdvancedSearch.php version [208743c768].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 | <?php namespace Garradin\Accounting; use Garradin\DynamicList; use Garradin\Users\DynamicFields; use Garradin\AdvancedSearch as A_S; use Garradin\DB; use Garradin\Accounting\Years; use Garradin\Entities\Accounting\Transaction; use function Garradin\qg; class AdvancedSearch extends A_S { /** * Returns list of columns for search * @return array */ public function columns(): array { $db = DB::getInstance(); $types = 'CASE t.type '; foreach (Transaction::TYPES_NAMES as $num => $name) { $types .= sprintf('WHEN %d THEN %s ', $num, $db->quote($name)); } $types .= 'END'; return [ 'id' => [ 'label' => 'Numéro écriture', 'type' => 'integer', 'null' => false, 'select' => 't.id', ], 'date' => [ 'label' => 'Date', 'type' => 'date', 'null' => false, 'select' => 't.date', ], 'label' => [ 'label' => 'Libellé écriture', 'type' => 'text', 'null' => false, 'select' => 't.label', 'order' => 't.label COLLATE U_NOCASE %s', ], 'reference' => [ 'label' => 'Numéro pièce comptable', 'type' => 'text', 'null' => true, 'select' => 't.reference', 'order' => 't.reference COLLATE U_NOCASE %s', ], 'notes' => [ 'label' => 'Remarques', 'type' => 'text', 'null' => true, 'select' => 't.notes', 'order' => 't.notes COLLATE U_NOCASE %s', ], 'account_code' => [ 'textMatch'=> true, 'label' => 'Numéro de compte', 'type' => 'text', 'null' => false, 'select' => 'a.code', ], 'debit' => [ 'label' => 'Débit', 'type' => 'text', 'null' => false, 'select' => 'l.debit', 'normalize' => 'money', ], 'credit' => [ 'label' => 'Crédit', 'type' => 'text', 'null' => false, 'select' => 'l.credit', 'normalize' => 'money', ], 'line_label' => [ 'label' => 'Libellé ligne', 'type' => 'text', 'null' => true, 'select' => 'l.label', 'order' => 'l.label COLLATE U_NOCASE %s', ], 'line_reference' => [ 'textMatch'=> true, 'label' => 'Référence ligne écriture', 'type' => 'text', 'null' => true, 'select' => 'l.reference', ], 'type' => [ 'textMatch'=> false, 'label' => 'Type d\'écriture', 'type' => 'enum', 'null' => false, 'values' => Transaction::TYPES_NAMES, 'select' => $types, 'where' => 't.type', ], 'id_year' => [ 'textMatch'=> false, 'label' => 'Exercice', 'type' => 'enum', 'null' => false, 'values' => $db->getAssoc('SELECT id, label FROM acc_years ORDER BY end_date;'), 'select' => 'y.label', 'where' => 't.id_year', ], 'project_code' => [ 'textMatch'=> true, 'label' => 'Code projet', 'type' => 'text', 'null' => true, 'select' => 'p.code', ], ]; } public function simple(string $text, bool $allow_redirect = false, ?int $id_year = null): \stdClass { $query = []; $text = trim($text); if ($id_year) { $query[] = [ 'operator' => 'AND', 'conditions' => [ [ 'column' => 'id_year', 'operator' => '= ?', 'values' => [$id_year], ], ], ]; } // Match number: find transactions per credit or debit if (preg_match('/^=\s*\d+([.,]\d+)?$/', $text)) { $text = ltrim($text, "\n\t ="); $query[] = [ 'operator' => 'OR', 'conditions' => [ [ 'column' => 'debit', 'operator' => '= ?', 'values' => [$text], ], [ 'column' => 'credit', 'operator' => '= ?', 'values' => [$text], ], ], ]; } // Match account number elseif ($allow_redirect && $id_year && preg_match('/^[0-9]+[A-Z]*$/', $text) && ($year = Years::get($id_year)) && ($id = (new Accounts($year->id_chart))->getIdFromCode($text))) { Utils::redirect(sprintf('!acc/accounts/journal.php?id=%d&year=%d', $id, $id_year)); } // Match date elseif (preg_match('!^\d{2}/\d{2}/\d{4}$!', $text) && ($d = Utils::get_datetime($text))) { $query[] = [ 'operator' => 'OR', 'conditions' => [ [ 'column' => 'date', 'operator' => '= ?', 'values' => [$d->format('Y-m-d')], ], ], ]; } // Match transaction ID elseif ($allow_redirect && preg_match('/^#[0-9]+$/', $text)) { Utils::redirect(sprintf('!acc/transactions/details.php?id=%d', (int)substr($text, 1))); } // Or search in label or reference else { $operator = 'LIKE %?%'; $query[] = [ 'operator' => 'OR', 'conditions' => [ [ 'column' => 'label', 'operator' => $operator, 'values' => [$text], ], [ 'column' => 'reference', 'operator' => $operator, 'values' => [$text], ], [ 'column' => 'reference', 'operator' => $operator, 'values' => [$text], ], ], ]; } return (object) [ 'groups' => $query, 'order' => 'id', 'desc' => true, ]; } public function schemaTables(): array { return [ 'acc_transactions' => 'Écritures', 'acc_transactions_lines' => 'Lignes des écritures', 'acc_accounts' => 'Comptes des plans comptables', 'acc_years' => 'Exercices', 'acc_projects' => 'Projets', ]; } public function tables(): array { return array_merge(array_keys($this->schemaTables()), [ 'acc_charts', 'acc_transactions_users', ]); } public function make(string $query): DynamicList { $tables = 'acc_transactions AS t INNER JOIN acc_transactions_lines AS l ON l.id_transaction = t.id INNER JOIN acc_accounts AS a ON l.id_account = a.id INNER JOIN acc_years AS y ON t.id_year = y.id LEFT JOIN acc_projects AS p ON l.id_project = p.id'; return $this->makeList($query, $tables, 'id', true, ['id', 'account_code', 'debit', 'credit']); } public function defaults(): \stdClass { $group = [ 'operator' => 'AND', 'conditions' => [ [ 'column' => 'id_year', 'operator' => '= ?', 'values' => [(int)qg('year') ?: Years::getCurrentOpenYearId()], ], [ 'column' => 'label', 'operator' => 'LIKE %?%', 'values' => [''], ], ], ]; if (null !== qg('type')) { $group['conditions'][] = [ 'column' => 'type', 'operator' => '= ?', 'values' => [(int)qg('type')], ]; } if (null !== qg('account')) { $group['conditions'][] = [ 'column' => 'account_code', 'operator' => '= ?', 'values' => [qg('account')], ]; } return (object) ['groups' => [$group]]; } } |
Modified src/include/lib/Garradin/Accounting/AssistedReconciliation.php from [7eaa670dc7] to [313f79c9f4].
1 2 3 4 5 6 7 | <?php namespace Garradin\Accounting; use Garradin\CSV_Custom; use Garradin\UserException; use Garradin\Utils; | | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <?php namespace Garradin\Accounting; use Garradin\CSV_Custom; use Garradin\UserException; use Garradin\Utils; use Garradin\Users\Session; use Garradin\Entities\Accounting\Account; use Garradin\Entities\Accounting\Transaction; use Garradin\Entity; /** * Provides assisted reconciliation */ |
︙ | ︙ |
Modified src/include/lib/Garradin/Accounting/Charts.php from [87fab5ba75] to [26876f4cc0].
︙ | ︙ | |||
96 97 98 99 100 101 102 103 104 105 106 107 108 109 | throw new \RuntimeException('Ce plan comptable est déjà installé'); } $db = DB::getInstance(); $db->begin(); $chart = new Chart; $chart->label = self::BUNDLED_CHARTS[$chart_code]; $chart->country = $country; $chart->code = $code; $chart->save(); $chart->importCSV($file); $db->commit(); | > | 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | throw new \RuntimeException('Ce plan comptable est déjà installé'); } $db = DB::getInstance(); $db->begin(); $chart = new Chart; $chart->label = self::BUNDLED_CHARTS[$chart_code]; $chart->country = $country; $chart->code = $code; $chart->save(); $chart->importCSV($file); $db->commit(); |
︙ | ︙ |
Modified src/include/lib/Garradin/Accounting/Export.php from [ba557923cc] to [0ee6db2912].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <?php namespace Garradin\Accounting; use Garradin\Entities\Accounting\Line; use Garradin\Entities\Accounting\Transaction; use Garradin\Entities\Accounting\Year; use Garradin\Config; use Garradin\CSV; use Garradin\DB; use Garradin\Utils; class Export { const FULL = 'full'; const GROUPED = 'grouped'; const SIMPLE = 'simple'; | > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | <?php namespace Garradin\Accounting; use Garradin\Entities\Accounting\Line; use Garradin\Entities\Accounting\Transaction; use Garradin\Entities\Accounting\Year; use Garradin\Config; use Garradin\CSV; use Garradin\DB; use Garradin\Users\DynamicFields; use Garradin\Utils; class Export { const FULL = 'full'; const GROUPED = 'grouped'; const SIMPLE = 'simple'; |
︙ | ︙ | |||
120 121 122 123 124 125 126 | if (!array_key_exists($type, self::COLUMNS)) { throw new \InvalidArgumentException('Unknown type: ' . $type); } CSV::export( $format, | | | 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | if (!array_key_exists($type, self::COLUMNS)) { throw new \InvalidArgumentException('Unknown type: ' . $type); } CSV::export( $format, sprintf('%s - Export comptable - %s - %s', Config::getInstance()->org_name, self::NAMES[$type], $year->label), self::iterateExport($year->id(), $type), array_keys(self::COLUMNS[$type]) ); } static public function getExamples(Year $year) { |
︙ | ︙ | |||
148 149 150 151 152 153 154 | } return $out; } static protected function iterateExport(int $year_id, string $type): \Generator { | | | | | 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | } return $out; } static protected function iterateExport(int $year_id, string $type): \Generator { $id_field = DynamicFields::getNameFieldsSQL('u'); if (self::SIMPLE == $type) { $sql = 'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference, IFNULL(l1.reference, l2.reference) AS p_reference, a1.code AS debit_account, a2.code AS credit_account, l1.debit AS amount, IFNULL(p.code, p.label) AS project, GROUP_CONCAT(%s) AS linked_users FROM acc_transactions t INNER JOIN acc_transactions_lines l1 ON l1.id_transaction = t.id AND l1.debit != 0 INNER JOIN acc_transactions_lines l2 ON l2.id_transaction = t.id AND l2.credit != 0 INNER JOIN acc_accounts a1 ON a1.id = l1.id_account INNER JOIN acc_accounts a2 ON a2.id = l2.id_account LEFT JOIN acc_projects p ON p.id = l1.id_project LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id LEFT JOIN users u ON u.id = tu.id_user WHERE t.id_year = ? AND t.type != %d GROUP BY t.id ORDER BY t.date, t.id;'; $sql = sprintf($sql, $id_field, Transaction::TYPE_ADVANCED); } |
︙ | ︙ | |||
204 205 206 207 208 209 210 | ORDER BY t.date, t.id, l.id;'; } elseif (self::FULL == $type || self::GROUPED == $type) { $sql = 'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference, a.code AS account, a.label AS account_label, l.debit AS debit, l.credit AS credit, l.reference AS line_reference, l.label AS line_label, l.reconciled, IFNULL(p.code, p.label) AS project, | | | | 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | ORDER BY t.date, t.id, l.id;'; } elseif (self::FULL == $type || self::GROUPED == $type) { $sql = 'SELECT t.id, t.type, t.status, t.label, t.date, t.notes, t.reference, a.code AS account, a.label AS account_label, l.debit AS debit, l.credit AS credit, l.reference AS line_reference, l.label AS line_label, l.reconciled, IFNULL(p.code, p.label) AS project, GROUP_CONCAT(%s) AS linked_users FROM acc_transactions t INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id INNER JOIN acc_accounts a ON a.id = l.id_account LEFT JOIN acc_projects p ON p.id = l.id_project LEFT JOIN acc_transactions_users tu ON tu.id_transaction = t.id LEFT JOIN users u ON u.id = tu.id_user WHERE t.id_year = ? GROUP BY t.id, l.id ORDER BY t.date, t.id, l.id;'; $sql = sprintf($sql, $id_field); } else { |
︙ | ︙ |
Modified src/include/lib/Garradin/Accounting/Graph.php from [dad1eda3f9] to [f4acdcb5a4].
︙ | ︙ | |||
228 229 230 231 232 233 234 | return $out; } static protected function getColors() { $config = Config::getInstance(); | | | | 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | return $out; } static protected function getColors() { $config = Config::getInstance(); $c1 = $config->get('color1') ?: ADMIN_COLOR1; $c2 = $config->get('color2') ?: ADMIN_COLOR2; list($h, $s, $v) = Utils::rgbToHsv($c1); list($h1, $s, $v) = Utils::rgbToHsv($c2); $colors = []; for ($i = 0; $i < 5; $i++) { if ($i % 2 == 0) { |
︙ | ︙ |
Modified src/include/lib/Garradin/Accounting/Reports.php from [9a3d384a70] to [da4d116997].
︙ | ︙ | |||
176 177 178 179 180 181 182 | else { $where = self::getWhereClause($criterias); $sql = sprintf('SELECT position, SUM(balance) FROM acc_accounts_balances WHERE %s GROUP BY position;', $where); } $balances = DB::getInstance()->getAssoc($sql); | < < | 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | else { $where = self::getWhereClause($criterias); $sql = sprintf('SELECT position, SUM(balance) FROM acc_accounts_balances WHERE %s GROUP BY position;', $where); } $balances = DB::getInstance()->getAssoc($sql); return ($balances[Account::REVENUE] ?? 0) - ($balances[Account::EXPENSE] ?? 0); } static public function getBalancesSQL(array $parts = []) { return sprintf('SELECT %s id_year, id, label, code, type, debit, credit, position, %s, is_debt FROM ( |
︙ | ︙ | |||
573 574 575 576 577 578 579 | return $out; } /** * Grand livre */ | | | | 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 | return $out; } /** * Grand livre */ static public function getGeneralLedger(array $criterias, bool $simple = false): \Generator { $where = self::getWhereClause($criterias); $db = DB::getInstance(); if (!empty($criterias['projects_only'])) { $join = 'acc_projects a ON a.id = l.id_project'; } else { $join = 'acc_accounts a ON a.id = l.id_account'; } $sql = sprintf('SELECT t.id_year, a.id AS id_account, t.id, t.date, t.reference, l.debit, l.credit, l.reference AS line_reference, t.label, l.label AS line_label, a.label AS account_label, a.code AS account_code, a.type AS account_type FROM acc_transactions t INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id INNER JOIN %s WHERE %s ORDER BY a.code COLLATE U_NOCASE, t.date, t.id;', $join, $where); $account = null; |
︙ | ︙ | |||
620 621 622 623 624 625 626 | 'credit'=> 0, 'lines' => [], ]; } $row->date = \DateTime::createFromFormat('Y-m-d', $row->date); | > > > > > > | | 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 | 'credit'=> 0, 'lines' => [], ]; } $row->date = \DateTime::createFromFormat('Y-m-d', $row->date); $sum = $row->debit - $row->credit; if (Accounts::isReversed($simple, $row->account_type)) { $sum *= -1; } $account->sum += $sum; $account->debit += $row->debit; $account->credit += $row->credit; $debit += $row->debit; $credit += $row->credit; $row->running_sum = $account->sum; unset($row->account_code, $row->account_label, $row->id_account, $row->id_year); |
︙ | ︙ |
Added src/include/lib/Garradin/AdvancedSearch.php version [aede10b834].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | <?php namespace Garradin; abstract class AdvancedSearch { /** * From a single search string, returns a search object (stdClass) containing 3 properties: * - query (array, list of search conditions) * - order * - desc */ abstract public function simple(string $query, bool $allow_redirect = false): \stdClass; /** * Return list of columns. The format is similar to the one accepted in DynamicList. * * Those specific keys are also supported: * - 'normalize' (string) will normalize the user entry to a specific format (accepted: tel, money) * - 'null' (bool) if true, the user will be able to search for NULL values * - 'type' (string) type of HTML input */ abstract public function columns(): array; /** * Returns list of tables that should be documented for SQL queries */ abstract public function schemaTables(): array; /** * Returns list of tables the user has access to for SQL queries */ abstract public function tables(): array; /** * Builds a DynamicList object from the supplied search groups */ abstract public function make(string $query): DynamicList; /** * Returns default empty search groups */ abstract public function defaults(): \stdClass; public function makeList(string $query, string $tables, string $default_order, bool $default_desc, array $mandatory_columns = ['id']): DynamicList { $query = json_decode($query, true); if (null === $query) { throw new \InvalidArgumentException('Invalid JSON search object'); } $query = (object) $query; if (!isset($query->groups) || !is_array($query->groups)) { throw new \InvalidArgumentException('Invalid JSON search object: missing groups'); } $conditions = $this->build($query->groups); array_unshift($conditions->select, $default_order); // Always include default order foreach ($mandatory_columns as $c) { if (!in_array($c, $conditions->select)) { array_unshift($conditions->select, $c); // Always include } } // Only select columns that we want $select_columns = array_intersect_key($this->columns(), array_flip($conditions->select)); $order = $query->order ?? $default_order; if (!in_array($order, $select_columns)) { $order = $default_order; } DB::getInstance()->toggleUnicodeLike(true); $list = new DynamicList($select_columns, $tables, $conditions->where); $list->orderBy($order, $query->desc ?? $default_desc); return $list; } /** * Redirects to a URL if only one result is found for a simple search */ public function redirect(DynamicList $list): void { if ($list->count() != 1) { return; } $item = $list->iterate()->current(); Utils::redirect($item->id); } public function build(array $groups): \stdClass { $db = DB::getInstance(); $columns = $this->columns(); $select_columns = []; $query_columns = []; $query_groups = []; foreach ($groups as $group) { if (!isset($group['conditions'], $group['operator']) || !is_array($group['conditions']) || ($group['operator'] != 'AND' && $group['operator'] != 'OR')) { // Ignorer les groupes de conditions invalides continue; } $invalid = 0; $query_group_conditions = []; foreach ($group['conditions'] as $condition) { if (!isset($condition['column'], $condition['operator']) || (isset($condition['values']) && !is_array($condition['values']))) { // Ignorer les conditions invalides continue; } if (!array_key_exists($condition['column'], $columns)) { // Ignorer une condition qui se rapporte à une colonne // qui n'existe pas, cas possible si on reprend une recherche // après avoir modifié les fiches de membres $invalid++; continue; } $select_columns[] = $condition['column']; // Just append the column to the select if ($condition['operator'] == '1') { continue; } $query_columns[] = $condition['column']; $column = $columns[$condition['column']]; if (isset($column['where'])) { $query = sprintf($column['where'], $condition['operator']); } else { $name = $column['select'] ?? $condition['column']; $query = sprintf('%s %s', $name, $condition['operator']); } $values = isset($condition['values']) ? $condition['values'] : []; if (!empty($column['normalize'])) { if ($column['normalize'] == 'tel') { // Normaliser le numéro de téléphone $values = array_map(['Garradin\Utils', 'normalizePhoneNumber'], $values); } elseif ($column['normalize'] == 'money') { $values = array_map(['Garradin\Utils', 'moneyToInteger'], $values); } } // L'opérateur binaire est un peu spécial if ($condition['operator'] == '&') { $new_query = []; foreach ($values as $value) { $new_query[] = sprintf('%s (1 << %d)', $query, (int) $value); } $query = '(' . implode(' AND ', $new_query) . ')'; } // Remplacement de liste elseif (strpos($query, '??') !== false) { $values = array_map([$db, 'quote'], $values); $query = str_replace('??', implode(', ', $values), $query); } // Remplacement de recherche LIKE elseif (preg_match('/%\?%|%\?|\?%/', $query, $match)) { $value = str_replace(['%', '_'], ['\\%', '\\_'], reset($values)); $value = str_replace('?', $value, $match[0]); $query = str_replace($match[0], sprintf('%s ESCAPE \'\\\'', $db->quote($value)), $query); } // Remplacement de paramètre elseif (strpos($query, '?') !== false) { $expected = substr_count($query, '?'); $found = count($values); if ($expected != $found) { throw new \RuntimeException(sprintf('Operator %s expects at least %d parameters, only %d supplied', $condition['operator'], $expected, $found)); } for ($i = 0; $i < $expected; $i++) { $pos = strpos($query, '?'); $query = substr_replace($query, $db->quote(array_shift($values)), $pos, 1); } } $query_group_conditions[] = $query; } if (count($query_group_conditions)) { $query_groups[] = implode(' ' . $group['operator'] . ' ', $query_group_conditions); } } if (!count($query_groups) && count($groups) && $invalid) { throw new UserException('Cette recherche faisait référence à des champs qui n\'existent plus.' . "\n" . 'Elle ne comporte aucun critère valide. Il vaudrait mieux la supprimer.'); } return (object) [ 'select' => $select_columns, 'where' => count($query_groups) ? '(' . implode(') AND (', $query_groups) . ')' : '1' ]; } } |
Modified src/include/lib/Garradin/Config.php from [6f4c0c0f4e] to [38db93d71b].
1 2 3 4 5 6 | <?php namespace Garradin; use Garradin\Files\Files; use Garradin\Entities\Files\File; | > < > > < < < < < | < < | < < | < < | | < < < < < < < | | < | | | < | | < < < < < < < | < | < < < < < < < < < | | | | | | | > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | <?php namespace Garradin; use Garradin\Log; use Garradin\Files\Files; use Garradin\Entities\Files\File; use KD2\SMTP; use KD2\Graphics\Image; class Config extends Entity { const FILES = [ 'admin_background' => File::CONTEXT_CONFIG . '/admin_bg.png', 'admin_homepage' => File::CONTEXT_CONFIG . '/admin_homepage.skriv', 'admin_css' => File::CONTEXT_CONFIG . '/admin.css', 'logo' => File::CONTEXT_CONFIG . '/logo.png', 'icon' => File::CONTEXT_CONFIG . '/icon.png', 'favicon' => File::CONTEXT_CONFIG . '/favicon.png', 'signature' => File::CONTEXT_CONFIG . '/signature.png', ]; const FILES_TYPES = [ 'admin_background' => 'image', 'admin_css' => 'code', 'admin_homepage' => 'web', 'logo' => 'image', 'icon' => 'image', 'favicon' => 'image', 'signature' => 'image', ]; const FILES_PUBLIC = [ 'logo', 'icon', 'favicon', ]; protected string $org_name; protected ?string $org_infos; protected string $org_email; protected ?string $org_address; protected ?string $org_phone; protected ?string $org_web; protected string $currency; protected string $country; protected int $default_category; protected ?int $backup_frequency; protected ?int $backup_limit; protected ?int $last_chart_change; protected ?string $last_version_check; protected ?string $color1; protected ?string $color2; protected array $files = []; protected bool $site_disabled; protected int $log_retention; protected bool $analytical_set_all; static protected $_instance = null; static public function getInstance() { return self::$_instance ?: self::$_instance = new self; } |
︙ | ︙ | |||
124 125 126 127 128 129 130 | if (empty($config)) { return; } $default = array_fill_keys(array_keys($this->_types), null); $config = array_merge($default, $config); | < < | | > > > > > > > | 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | if (empty($config)) { return; } $default = array_fill_keys(array_keys($this->_types), null); $config = array_merge($default, $config); foreach ($this->_types as $key => $type) { $value = $config[$key]; if ($type[0] == '?' && $value === null) { continue; } } $this->load($config); } public function setCreateFlag(): void { foreach ($this->_types as $key => $t) { $this->_modified[$key] = null; } $this->files = array_map(fn($a) => null, self::FILES); } public function save(bool $selfcheck = true): bool { if (!count($this->_modified)) { return true; } |
︙ | ︙ | |||
159 160 161 162 163 164 165 | $db->begin(); foreach ($values as $key => $value) { $db->preparedQuery('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?);', $key, $value); } | < < | < < < | | < | < | < < | | | | < < < < < | | | | | | > < < < < < < < < < < < < < < | | 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | $db->begin(); foreach ($values as $key => $value) { $db->preparedQuery('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?);', $key, $value); } $db->commit(); $this->_modified = []; if (array_key_exists('log_retention', $values)) { Log::clean(); } return true; } public function delete(): bool { throw new \LogicException('Cannot delete config'); } public function importForm($source = null): void { if (null === $source) { $source = $_POST; } // N'enregistrer les couleurs que si ce ne sont pas les couleurs par défaut if (isset($source['color1'], $source['color2']) && ($source['color1'] == ADMIN_COLOR1 && $source['color2'] == ADMIN_COLOR2)) { $source['color1'] = null; $source['color2'] = null; } parent::importForm($source); } protected function _filterType(string $key, $value) { switch ($this->_types[$key]) { case 'int': return (int) $value; case 'bool': return (bool) $value; case 'string': return (string) $value; default: throw new \InvalidArgumentException(sprintf('"%s" has unknown type "%s"', $key, $this->_types[$key])); } } public function selfCheck(): void { $this->assert(trim($this->org_name) != '', 'Le nom de l\'association ne peut rester vide.'); $this->assert(trim($this->currency) != '', 'La monnaie ne peut rester vide.'); $this->assert(trim($this->country) != '' && Utils::getCountryName($this->country), 'Le pays ne peut rester vide.'); $this->assert(!isset($this->org_web) || filter_var($this->org_web, FILTER_VALIDATE_URL), 'L\'adresse URL du site web est invalide.'); $this->assert(trim($this->org_email) != '' && SMTP::checkEmailIsValid($this->org_email, false), 'L\'adresse e-mail de l\'association est invalide.'); $this->assert($this->log_retention >= 0, 'La durée de rétention doit être égale ou supérieur à zéro.'); // Files $this->assert(count($this->files) == count(self::FILES)); foreach ($this->files as $key => $value) { $this->assert(array_key_exists($key, self::FILES)); $this->assert(is_int($value) || is_null($value)); } $db = DB::getInstance(); $this->assert($db->test('users_categories', 'id = ?', $this->default_category), 'Catégorie de membres inconnue'); } public function file(string $key): ?File { if (!isset(self::FILES[$key])) { throw new \InvalidArgumentException('Invalid file key: ' . $key); } |
︙ | ︙ | |||
284 285 286 287 288 289 290 | } return null; } $params = $params ? $params . '&' : ''; | | | 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | } return null; } $params = $params ? $params . '&' : ''; return BASE_URL . self::FILES[$key] . '?' . $params . 'h=' . substr(md5($this->files[$key]), 0, 10); } public function hasFile(string $key): bool { return $this->files[$key] ? true : false; } |
︙ | ︙ | |||
325 326 327 328 329 330 331 | if ($f) { $f->delete(); } $f = null; } elseif ($upload) { | | | > > > > > > > | | 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 | if ($f) { $f->delete(); } $f = null; } elseif ($upload) { $f = Files::upload(Utils::dirname($path), $value, Utils::basename($path)); if ($type == 'image' && !$f->image) { $this->setFile($key, null); throw new UserException('Le fichier n\'est pas une image.'); } // Force favicon format if ($key == 'favicon') { $format = 'png'; $i = new Image($f->fullpath()); $i->cropResize(32, 32); $f->setContent($i->output($format, true)); } // Force icon format else if ($key == 'icon') { $format = 'png'; $i = new Image($f->fullpath()); $i->cropResize(512, 512); $f->setContent($i->output($format, true)); } // Force signature size else if ($key == 'signature') { $format = 'png'; $i = new Image($f->fullpath()); $i->resize(200, 200); $f->setContent($i->output($format, true)); } } elseif ($f) { $f->setContent($value); } else { $f = Files::createFromString($path, $value); } $files[$key] = $f ? $f->modified->getTimestamp() : null; $this->set('files', $files); return $f; } } |
Modified src/include/lib/Garradin/DB.php from [b153f0e4eb] to [8826d5d1a7].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | <?php namespace Garradin; use KD2\DB\SQLite3; use KD2\DB\DB_Exception; use KD2\ErrorManager; class DB extends SQLite3 { /** * Application ID pour SQLite * @link https://www.sqlite.org/pragma.html#pragma_application_id */ const APPID = 0x5da2d811; static protected $_instance = null; protected $_version = -1; static protected $unicode_patterns_cache = []; protected $_log_last = null; protected $_log_start = null; protected $_log_store = []; static public function getInstance() { if (null === self::$_instance) { self::$_instance = new DB('sqlite', ['file' => DB_FILE]); } | > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | <?php namespace Garradin; use KD2\DB\SQLite3; use KD2\DB\DB_Exception; use KD2\ErrorManager; use Garradin\Entities\Email\Email; class DB extends SQLite3 { /** * Application ID pour SQLite * @link https://www.sqlite.org/pragma.html#pragma_application_id */ const APPID = 0x5da2d811; static protected $_instance = null; protected $_version = -1; static protected $unicode_patterns_cache = []; protected $_log_last = null; protected $_log_start = null; protected $_log_store = []; protected $_schema_update = 0; static public function getInstance() { if (null === self::$_instance) { self::$_instance = new DB('sqlite', ['file' => DB_FILE]); } |
︙ | ︙ | |||
205 206 207 208 209 210 211 | // Activer les contraintes des foreign keys $this->db->exec('PRAGMA foreign_keys = ON;'); // 10 secondes $this->db->busyTimeout(10 * 1000); | < | 209 210 211 212 213 214 215 216 217 218 219 220 221 222 | // Activer les contraintes des foreign keys $this->db->exec('PRAGMA foreign_keys = ON;'); // 10 secondes $this->db->busyTimeout(10 * 1000); $mode = strtoupper(SQLITE_JOURNAL_MODE); $set_mode = $this->db->querySingle('PRAGMA journal_mode;'); $set_mode = strtoupper($set_mode); if ($set_mode !== $mode) { // WAL = performance enhancement // see https://www.cs.utexas.edu/~jaya/slides/apsys17-sqlite-slides.pdf |
︙ | ︙ | |||
228 229 230 231 232 233 234 | self::registerCustomFunctions($this->db); } static public function registerCustomFunctions($db) { $db->createFunction('dirname', [Utils::class, 'dirname']); $db->createFunction('basename', [Utils::class, 'basename']); | | > | > > > > > > > > > > > > | 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 | self::registerCustomFunctions($this->db); } static public function registerCustomFunctions($db) { $db->createFunction('dirname', [Utils::class, 'dirname']); $db->createFunction('basename', [Utils::class, 'basename']); $db->createFunction('unicode_like', [self::class, 'unicodeLike']); $db->createFunction('transliterate_to_ascii', [Utils::class, 'unicodeTransliterate']); $db->createFunction('email_hash', [Email::class, 'getHash']); $db->createCollation('U_NOCASE', [Utils::class, 'unicodeCaseComparison']); } public function toggleUnicodeLike(bool $enable): void { if ($enable) { $this->createFunction('like', [$this, 'unicodeLike']); } else { // We should revert LIKE to the default, but we can't currently (FIXME?) // see https://github.com/php/php-src/issues/10726 //$db->createFunction('like', null); } } public function version(): ?string { if (-1 === $this->_version) { $this->connect(); $this->_version = self::getVersion($this->db); } |
︙ | ︙ | |||
335 336 337 338 339 340 341 | } $this->db->exec(sprintf('PRAGMA user_version = %d;', $version)); } public function beginSchemaUpdate() { | > > | | > > > | | > | < < > > > > | 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 | } $this->db->exec(sprintf('PRAGMA user_version = %d;', $version)); } public function beginSchemaUpdate() { // Only start if not already taking place if ($this->_schema_update++ == 0) { $this->toggleForeignKeys(false); $this->begin(); } } public function commitSchemaUpdate() { // Only commit if last call if (--$this->_schema_update == 0) { $this->commit(); $this->toggleForeignKeys(true); } } public function lastErrorMsg() { return $this->db->lastErrorMsg(); } /** * @see https://www.sqlite.org/lang_altertable.html */ public function toggleForeignKeys(bool $enable): void { $this->connect(); if (!$enable) { $this->db->exec('PRAGMA legacy_alter_table = ON;'); $this->db->exec('PRAGMA foreign_keys = OFF;'); if ($this->firstColumn('PRAGMA foreign_keys;')) { throw new \LogicException('Cannot disable foreign keys in an already started transaction'); } } else { $this->db->exec('PRAGMA legacy_alter_table = OFF;'); $this->db->exec('PRAGMA foreign_keys = ON;'); } } |
︙ | ︙ |
Modified src/include/lib/Garradin/DynamicList.php from [6e9f9ec3ec] to [b3c71aaba9].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | <?php namespace Garradin; class DynamicList implements \Countable { protected $columns; protected $tables; protected $conditions; protected $group; protected $order; protected $modifier; protected $export_callback; protected $title = 'Liste'; protected $count = 'COUNT(*)'; protected $desc = true; protected $per_page = 100; protected $page = 1; private $count_result; public function __construct(array $columns, string $tables, string $conditions = '1') { $this->columns = $columns; $this->tables = $tables; $this->conditions = $conditions; $this->order = key($columns); } public function __isset($key) { return property_exists($this, $key); } public function __get($key) { return $this->$key; } public function setTitle(string $title) { $this->title = $title; } public function setModifier(callable $fn) { $this->modifier = $fn; | > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | <?php namespace Garradin; use Garradin\Users\Session; class DynamicList implements \Countable { protected $columns; protected $tables; protected $conditions; protected $group; protected $order; protected $modifier; protected $export_callback; protected $title = 'Liste'; protected $count = 'COUNT(*)'; protected $desc = true; protected $per_page = 100; protected $page = 1; protected array $parameters = []; private $count_result; public function __construct(array $columns, string $tables, string $conditions = '1') { $this->columns = $columns; $this->tables = $tables; $this->conditions = $conditions; $this->order = key($columns); } public function __isset($key) { return property_exists($this, $key); } public function __get($key) { return $this->$key; } public function setParameter($key, $value) { $this->parameters[$key] = $value; } public function setTitle(string $title) { $this->title = $title; } public function setModifier(callable $fn) { $this->modifier = $fn; |
︙ | ︙ | |||
107 108 109 110 111 112 113 | foreach ($this->iterate(true) as $row) { $out[] = $row; } return $out; } | < < < < < | 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | foreach ($this->iterate(true) as $row) { $out[] = $row; } return $out; } public function orderURL(string $order, bool $desc) { $query = array_merge($_GET, ['o' => $order, 'd' => (int) $desc]); $url = Utils::getSelfURI($query); return $url; } |
︙ | ︙ | |||
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | } $columns[$alias] = $export ? $properties['label'] : $properties; } return $columns; } public function getExportHeaderColumns(): array { return $this->getHeaderColumns(true); } public function iterate(bool $include_hidden = true) { | > > > > > | | | 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | } $columns[$alias] = $export ? $properties['label'] : $properties; } return $columns; } public function countHeaderColumns(): int { return count($this->getHeaderColumns()); } public function getExportHeaderColumns(): array { return $this->getHeaderColumns(true); } public function iterate(bool $include_hidden = true) { foreach (DB::getInstance()->iterate($this->SQL(), $this->parameters) as $row) { if ($this->modifier) { call_user_func_array($this->modifier, [&$row]); } foreach ($this->columns as $key => $config) { if (empty($config['label']) && !$include_hidden) { unset($row->$key); } } yield $row; } } public function SQL() { $start = ($this->page - 1) * $this->per_page; $columns = []; $db = DB::getInstance(); foreach ($this->columns as $alias => $properties) { // Skip columns that require a certain order (eg. calculating a running sum) |
︙ | ︙ | |||
225 226 227 228 229 230 231 | if (null !== $this->per_page) { $sql .= sprintf(' LIMIT %d,%d', $start, $this->per_page); } return $sql; } | | > > > > > > > > > > > > > > > > > > > > > | > > > > > | > > > > > > | > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | | | | < | 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 | if (null !== $this->per_page) { $sql .= sprintf(' LIMIT %d,%d', $start, $this->per_page); } return $sql; } public function loadFromQueryString(): void { $export = $_POST['_dl_export'] ?? ($_GET['export'] ?? null); $page = $_POST['_dl_page'] ?? ($_GET['p'] ?? null); $order = null; $desc = null; $hash = null; $preferences = null; $u = null; if ($u = Session::getLoggedUser()) { $hash = md5(json_encode([$this->tables, $this->conditions, $this->columns, $this->group])); $preferences = $u->getPreference('list_' . $hash) ?? null; $order = $preferences->o ?? null; $desc = $preferences->d ?? null; } if (!empty($_POST['_dl_order'])) { $order = substr($_POST['_dl_order'], 1); $desc = substr($_POST['_dl_order'], 0, 1) == '>' ? true : false; } elseif (!empty($_GET['o'])) { $order = $_GET['o']; $desc = !empty($_GET['d']); } if ($export) { $this->export($this->title, $export); exit; } // Save current order, if different than default if ($u && $hash && (($order != ($preferences->o ?? null) && $order != $this->order) || ($desc != ($preferences->d ?? null) && $desc != $this->desc))) { $u->setPreference('list_' . $hash, ['o' => $order, 'd' => $desc]); } if ($order) { $this->orderBy($order, $desc); } if ($page) { $this->page = (int) $page; } if ($nb = Session::getPreference('page_size')) { $this->setPageSize((int) $nb); } } public function isPaginated(): bool { if (null === $this->per_page) { return false; } return $this->count() > $this->per_page; } public function getHTMLPagination(bool $use_buttons = false): string { if (!$this->isPaginated()) { return ''; } $pagination = Utils::getGenericPagination($this->page, $this->count(), $this->per_page); if (empty($pagination)) { return ''; } $url = Utils::getModifiedURL('?p=%d'); $out = '<ul class="pagination">'; foreach ($pagination as $page) { $out .= sprintf('<li class="%s">', $page['class'] ?? ''); if (!empty($use_buttons)) { $out .= sprintf('<button type="submit" name="_dl_page" value="%d">%s</button>', $page['id'], htmlspecialchars($page['label'])); } else { $out .= sprintf('<a accesskey="%s" href="%s">%s</a>', $page['accesskey'] ?? '', str_replace('%d', $page['id'], $url), htmlspecialchars($page['label']) ); } $out .= "</li>\n"; } $out .= '</ul>'; return $out; } } |
Added src/include/lib/Garradin/Email/Emails.php version [a281f58f81].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 | <?php namespace Garradin\Email; use Garradin\Config; use Garradin\DB; use Garradin\DynamicList; use Garradin\Plugins; use Garradin\UserException; use Garradin\Entities\Email\Email; use Garradin\Entities\Users\User; use Garradin\Users\DynamicFields; use Garradin\UserTemplate\UserTemplate; use Garradin\Web\Render\Render; use Garradin\Web\Skeleton; use const Garradin\{USE_CRON, MAIL_RETURN_PATH, DISABLE_EMAIL}; use const Garradin\{SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SECURITY}; use KD2\SMTP; use KD2\Security; use KD2\Mail_Message; use KD2\DB\EntityManager as EM; class Emails { const RENDER_FORMATS = [ null => 'Texte brut', Render::FORMAT_MARKDOWN => 'MarkDown', ]; /** * Email sending contexts */ const CONTEXT_BULK = 1; const CONTEXT_PRIVATE = 2; const CONTEXT_SYSTEM = 0; /** * When we reach that number of fails, the address is treated as permanently invalid, unless reset by a verification. */ const FAIL_LIMIT = 5; /** * Add a message to the sending queue using templates * @param int $context * @param array $recipients List of recipients, which can be a list of email addresses, or a list of User entities, or a list of: * ['variables' => [...], 'user' => User] * @param string $sender * @param string $subject * @param UserTemplate|string $content * @return void */ static public function queue(int $context, array $recipients, ?string $sender, string $subject, $content, ?string $render = null): void { if (DISABLE_EMAIL) { return; } $list = []; // Build email list foreach ($recipients as $r) { $variables = []; $user = null; $pgp_key = null; $emails = []; if (is_array($r) && isset($r['user'])) { $user = $r['user']; } elseif (is_object($r)) { $user = $r; } if (isset($user->pgp_key)) { $pgp_key = $user->pgp_key; } if (!is_object($r)) { $pgp_key ??= $r['pgp_key'] ?? null; $variables = $r['variables'] ?? []; } if (is_string($r) || (is_array($r) && isset($r['email']))) { $emails[] = strtolower($r['email'] ?? $r); } // From Users::iterateEmailsBy... elseif (is_object($r) && isset($r->_email)) { $emails[] = strtolower($r->_email); } elseif ($user && $user instanceof User) { $emails = $user->getEmails(); } else { continue; } // Ignore invalid addresses foreach ($emails as $key => $value) { if (!preg_match('/.+@.+\..+$/', $value)) { unset($emails[$key]); } } if (!count($emails)) { continue; } $data = compact('user', 'variables', 'pgp_key'); foreach ($emails as $value) { $list[$value] = $data; } } if (!count($list)) { return; } $recipients = $list; unset($list); if (Plugins::fireSignal('email.queue.before', compact('context', 'recipients', 'sender', 'subject', 'content', 'render'))) { // queue handling was done by a plugin return; } $template = ($content instanceof UserTemplate) ? $content : null; $skel = null; $content_html = null; if ($template) { $template->toggleSafeMode(true); } $db = DB::getInstance(); $db->begin(); $st = $db->prepare('INSERT INTO emails_queue (sender, subject, recipient, recipient_hash, recipient_pgp_key, content, content_html, context) VALUES (:sender, :subject, :recipient, :recipient_hash, :recipient_pgp_key, :content, :content_html, :context);'); if ($render) { $skel = new Skeleton('email.html'); } foreach ($recipients as $to => $data) { $variables = (array)$data['variables']; // We won't try to reject invalid/optout recipients here, // it's done in the queue clearing (more efficient) $hash = Email::getHash($to); $content_html = null; if ($template) { $template->assignArray((array) $variables, null, false); // Disable HTML escaping for plaintext emails $template->setEscapeDefault(null); $content = $template->fetch(); if ($render) { $content_html = $template->fetch(); } } if ($render) { $content_html = Render::render($render, null, $content_html ?? $content); } if ($content_html) { // Wrap HTML content in the email skeleton $content_html = $skel->fetch([ 'html' => $content_html, 'recipient' => $to, 'data' => $variables, 'context' => $context, 'from' => $sender, ]); } if (Plugins::fireSignal('email.queue.insert', compact('context', 'to', 'sender', 'subject', 'content', 'render', 'hash', 'content_html') + ['pgp_key' => $data['pgp_key'] ?? null])) { // queue insert was done by a plugin continue; } $st->bindValue(':sender', $sender); $st->bindValue(':subject', $subject); $st->bindValue(':context', $context); $st->bindValue(':recipient', $to); $st->bindValue(':recipient_pgp_key', $variables['pgp_key'] ?? null); $st->bindValue(':recipient_hash', $hash); $st->bindValue(':content', $content); $st->bindValue(':content_html', $content_html); $st->execute(); $st->reset(); $st->clear(); } $db->commit(); if (Plugins::fireSignal('email.queue.after', compact('context', 'recipients', 'sender', 'subject', 'content', 'render'))) { return; } // If no crontab is used, then the queue should be run now if (!USE_CRON) { self::runQueue(); } // Always send system emails right away elseif ($context == self::CONTEXT_SYSTEM) { self::runQueue(self::CONTEXT_SYSTEM); } } /** * Return an Email entity from the optout code */ static public function getEmailFromOptout(string $code): ?Email { $hash = base64_decode(str_pad(strtr($code, '-_', '+/'), strlen($code) % 4, '=', STR_PAD_RIGHT)); if (!$hash) { return null; } $hash = bin2hex($hash); return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', $hash); } /** * Sets the address as invalid (no email can be sent to this address ever) */ static public function markAddressAsInvalid(string $address): void { $e = self::getEmail($address); if (!$e) { return; } $e->set('invalid', true); $e->set('optout', false); $e->set('verified', false); $e->save(); } /** * Return an Email entity from an email address */ static public function getEmail(string $address): ?Email { return EM::findOne(Email::class, 'SELECT * FROM @TABLE WHERE hash = ?;', Email::getHash(strtolower($address))); } /** * Return or create a new email entity */ static public function getOrCreateEmail(string $address): Email { $address = strtolower($address); $e = self::getEmail($address); if (!$e) { $e = new Email; $e->added = new \DateTime; $e->hash = $e::getHash($address); $e->validate($address); $e->save(); } return $e; } /** * Run the queue of emails that are waiting to be sent */ static public function runQueue(?int $context = null): ?int { $db = DB::getInstance(); $queue = self::listQueueAndMarkAsSending($context); $ids = []; $save_sent = function () use (&$ids, $db) { if (!count($ids)) { return null; } $db->exec(sprintf('UPDATE emails_queue SET sending = 2 WHERE %s;', $db->where('id', $ids))); $ids = []; }; $limit_time = strtotime('1 month ago'); $count = 0; // listQueue nettoie déjà la queue foreach ($queue as $row) { // We allow system emails to be sent to invalid addresses after a while, and to optout addresses all the time if ($row->optout || $row->invalid || $row->fail_count >= self::FAIL_LIMIT) { if ($row->context != self::CONTEXT_SYSTEM || (!$row->optout && $row->last_sent > $limit_time)) { self::deleteFromQueue($row->id); continue; } } // Create email address in database if (!$row->email_hash) { $email = self::getOrCreateEmail($row->recipient); if (!$email->canSend()) { // Email address is invalid, skip self::deleteFromQueue($row->id); continue; } } $headers = [ 'From' => $row->sender, 'To' => $row->recipient, 'Subject' => $row->subject, ]; try { self::send($row->context, $row->recipient_hash, $headers, $row->content, $row->content_html, $row->recipient_pgp_key); } catch (\Exception $e) { // If sending fails, at least save what has been sent so far // so they won't get re-sent again $save_sent(); throw $e; } $ids[] = $row->id; $count++; // Mark messages as sent from time to time // to avoid starting from the beginning if the queue is killed // and also avoid passing too many IDs to SQLite at once if (count($ids) >= 50) { $save_sent(); } } // Update emails list and send count // then delete messages from queue $db->exec(sprintf(' BEGIN; UPDATE emails_queue SET sending = 2 WHERE %s; INSERT OR IGNORE INTO %s (hash) SELECT recipient_hash FROM emails_queue WHERE sending = 2; UPDATE %2$s SET sent_count = sent_count + 1, last_sent = datetime() WHERE hash IN (SELECT recipient_hash FROM emails_queue WHERE sending = 2); DELETE FROM emails_queue WHERE sending = 2; END;', $db->where('id', $ids), Email::TABLE)); return $count; } /** * Lists the queue, marks listed elements as "sending" * @return array */ static protected function listQueueAndMarkAsSending(?int $context = null): array { $queue = self::listQueue($context); if (!count($queue)) { return $queue; } $ids = []; foreach ($queue as $row) { $ids[] = $row->id; } $db = DB::getInstance(); $db->update('emails_queue', ['sending' => 1, 'sending_started' => new \DateTime], $db->where('id', $ids)); return $queue; } /** * Returns the lits of emails waiting to be sent, except invalid ones and emails that haved failed too much * * DO NOT USE for sending, use listQueueAndMarkAsSending instead, or there might be multiple processes sending * the same email over and over. * * @param int|null $context Context to list, leave NULL to have all contexts * @return array */ static protected function listQueue(?int $context = null): array { // Clean-up the queue from reject emails self::purgeQueueFromRejected(); // Reset messages that failed during the queue run self::resetFailed(); $condition = null === $context ? '' : sprintf(' AND context = %d', $context); return DB::getInstance()->get(sprintf('SELECT q.*, e.optout, e.verified, e.hash AS email_hash, e.invalid, e.fail_count, strftime(\'%%s\', e.last_sent) AS last_sent FROM emails_queue q LEFT JOIN emails e ON e.hash = q.recipient_hash WHERE q.sending = 0 %s;', $condition)); } static public function countQueue(): int { return DB::getInstance()->count('emails_queue'); } /** * Supprime de la queue les messages liés à des adresses invalides * ou qui ne souhaitent plus recevoir de message * @return boolean */ static protected function purgeQueueFromRejected(): void { DB::getInstance()->delete('emails_queue', 'recipient_hash IN (SELECT hash FROM emails WHERE (invalid = 1 OR fail_count >= ?) AND last_sent >= datetime(\'now\', \'-1 month\'));', self::FAIL_LIMIT); } /** * If emails have been marked as sending but sending failed, mark them for resend after a while */ static protected function resetFailed(): void { $sql = 'UPDATE emails_queue SET sending = 0, sending_started = NULL WHERE sending = 1 AND sending_started < datetime(\'now\', \'-3 hours\');'; DB::getInstance()->exec($sql); } /** * Supprime un message de la queue d'envoi * @param integer $id * @return boolean */ static protected function deleteFromQueue($id) { return DB::getInstance()->delete('emails_queue', 'id = ?', (int)$id); } static public function listRejectedUsers(): DynamicList { $db = DB::getInstance(); $columns = [ 'identity' => [ 'label' => 'Membre', 'select' => DynamicFields::getNameFieldsSQL('u'), ], 'email' => [ 'label' => 'Adresse', 'select' => 'u.email', ], 'user_id' => [ 'select' => 'u.id', ], 'hash' => [ ], 'status' => [ 'label' => 'Statut', 'select' => sprintf('CASE WHEN e.optout = 1 THEN \'Désinscription\' WHEN e.invalid = 1 THEN \'Invalide\' WHEN e.fail_count >= %d THEN \'Trop d\'\'erreurs\' WHEN e.verified = 1 THEN \'Vérifiée\' ELSE \'\' END', self::FAIL_LIMIT), ], 'sent_count' => [ 'label' => 'Messages envoyés', ], 'fail_log' => [ 'label' => 'Journal d\'erreurs', ], 'last_sent' => [ 'label' => 'Dernière tentative d\'envoi', ], 'optout' => [], 'fail_count' => [], ]; $tables = 'emails e INNER JOIN users u ON u.email IS NOT NULL AND u.email != \'\' AND e.hash = email_hash(u.email)'; $conditions = sprintf('e.optout = 1 OR e.invalid = 1 OR e.fail_count >= %d', self::FAIL_LIMIT); $list = new DynamicList($columns, $tables, $conditions); $list->orderBy('last_sent', true); $list->setModifier(function (&$row) { $row->last_sent = $row->last_sent ? new \DateTime($row->last_sent) : null; }); return $list; } static protected function send(int $context, string $recipient_hash, array $headers, string $content, ?string $content_html, ?string $pgp_key = null): void { $message = new Mail_Message; $message->setHeaders($headers); if (!$message->getFrom()) { $message->setHeader('From', self::getFromHeader()); } $message->setMessageId(); // Append unsubscribe, except for password reminders if ($context != self::CONTEXT_SYSTEM) { $url = Email::getOptoutURL($recipient_hash); // RFC 8058 $message->setHeader('List-Unsubscribe', sprintf('<%s>', $url)); $message->setHeader('List-Unsubscribe-Post', 'Unsubscribe=Yes'); $optout_text = "Vous recevez ce message car vous êtes dans nos contacts.\n" . "Pour ne plus jamais recevoir de message de notre part cliquez ici :\n"; $content .= "\n\n-- \n" . $optout_text . $url; if (null !== $content_html) { $optout_text = '<hr style="border-top: 2px solid #999; background: none;" /><p style="color: #000; background: #fff; padding: 10px; text-align: center; font-size: 9pt">' . nl2br(htmlspecialchars($optout_text)); $optout_text.= sprintf('<br /><a href="%s" style="color: blue; text-decoration: underline; padding: 5px; border-radius: 5px; background: #ddd;">Me désinscrire</a></p>', $url); if (stripos($content_html, '</body>') !== false) { $content_html = str_ireplace('</body>', $optout_text . '</body>', $content_html); } else { $content_html .= $optout_text; } } } $message->setBody($content); if (null !== $content_html) { $message->addPart('text/html', $content_html); } $config = Config::getInstance(); $message->setHeader('Return-Path', MAIL_RETURN_PATH ?? $config->org_email); $message->setHeader('X-Auto-Response-Suppress', 'All'); // This is to avoid getting auto-replies from Exchange servers static $can_use_encryption = null; if (null === $can_use_encryption) { $can_use_encryption = Security::canUseEncryption(); } if ($pgp_key && $can_use_encryption) { $message->encrypt($pgp_key); } self::sendMessage($context, $message); } static public function sendMessage(int $context, Mail_Message $message): void { if (DISABLE_EMAIL) { return; } $email_sent_via_plugin = Plugins::fireSignal('email.send.before', compact('context', 'message')); if ($email_sent_via_plugin) { return; } if (SMTP_HOST) { $const = '\KD2\SMTP::' . strtoupper(SMTP_SECURITY); $secure = constant($const); $smtp = new SMTP(SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, $secure); $smtp->send($message); } else { $message->send(); } Plugins::fireSignal('email.send.after', compact('context', 'message')); } /** * Handle a bounce message * @param string $raw_message Raw MIME message from SMTP */ static public function handleBounce(string $raw_message): ?array { $message = new Mail_Message; $message->parse($raw_message); $return = $message->identifyBounce(); if (Plugins::fireSignal('email.bounce', compact('message', 'return', 'raw_message'))) { return null; } if (!$return) { return null; } if ($return['type'] == 'autoreply') { // Ignore auto-responders return $return; } elseif ($return['type'] == 'genuine') { // Forward emails that are not automatic to the organization email $config = Config::getInstance(); $new = new Mail_Message; $new->setHeaders([ 'To' => $config->org_email, 'Subject' => 'Réponse à un message que vous avez envoyé', ]); $new->setBody('Veuillez trouver ci-joint une réponse à un message que vous avez envoyé à un de vos membre.'); $new->attachMessage($message->output()); self::sendMessage(self::CONTEXT_SYSTEM, $new); return $return; } return self::handleManualBounce($return['recipient'], $return['type'], $return['message']); } static public function handleManualBounce(string $recipient, string $type, ?string $message): ?array { $return = compact('recipient', 'type', 'message'); $email = self::getOrCreateEmail($return['recipient']); if (!$email) { return null; } Plugins::fireSignal('email.bounce', compact('email', 'return')); $email->hasFailed($return); $email->save(); return $return; } static public function getFromHeader(string $name = null, string $email = null): string { $config = Config::getInstance(); if (null === $name) { $name = $config->org_name; } if (null === $email) { $email = $config->org_email; } $name = str_replace('"', '\\"', $name); $name = str_replace(',', '', $name); // Remove commas return sprintf('"%s" <%s>', $name, $email); } } |
Added src/include/lib/Garradin/Email/Templates.php version [b8273f469e].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | <?php namespace Garradin\Email; use Garradin\Entities\Users\User; use Garradin\Email\Emails; use Garradin\Users\DynamicFields; use Garradin\Template; use Garradin\Utils; use const Garradin\{ADMIN_URL}; class Templates { static protected function send($to, string $template, array $variables = []) { $tpl = Template::getInstance(); $tpl->assign($variables); $tpl->setEscapeType('disable'); $body = trim($tpl->fetch('emails/' . $template)); $subject = $tpl->getTemplateVars('subject'); if (!$subject) { throw new \LogicException('Template did not define a subject'); } Emails::queue(Emails::CONTEXT_SYSTEM, [$to], null, $subject, $body); } static public function loginChanged(User $user): void { $login_field = DynamicFields::getLoginField(); self::send($user, 'login_changed.tpl', ['new_login' => $user->$login_field]); } static public function passwordRecovery(string $email, string $recovery_url, ?string $pgp_key): void { self::send(compact('email', 'pgp_key'), 'password_recovery.tpl', compact('recovery_url')); } static public function passwordChanged(User $user): void { $ip = Utils::getIP(); $login_field = DynamicFields::getLoginField(); $login = $user->$login_field; self::send($user, 'password_changed.tpl', compact('ip', 'login')); } static public function verifyAddress(string $email, string $verify_url): void { self::send($email, 'verify_email.tpl', compact('verify_url')); } } |
Modified src/include/lib/Garradin/Entities/API_Credentials.php from [f8f4575a16] to [f98c777978].
1 2 3 4 | <?php namespace Garradin\Entities; | | > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | <?php namespace Garradin\Entities; use Garradin\Users\Session; use Garradin\Entity; class API_Credentials extends Entity { const NAME = 'Identifiants API'; const TABLE = 'api_credentials'; protected ?int $id; protected string $label; protected string $key; protected string $secret; protected \DateTime $created; |
︙ | ︙ |
Modified src/include/lib/Garradin/Entities/Accounting/Account.php from [a2a16c338e] to [0d077ef548].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <?php namespace Garradin\Entities\Accounting; use DateTimeInterface; use Garradin\Config; use Garradin\CSV_Custom; use Garradin\DB; use Garradin\DynamicList; use Garradin\Entity; use Garradin\Utils; use Garradin\UserException; use Garradin\ValidationException; use Garradin\Accounting\Charts; class Account extends Entity { const TABLE = 'acc_accounts'; const NONE = 0; // Actif const ASSET = 1; | > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <?php namespace Garradin\Entities\Accounting; use DateTimeInterface; use Garradin\Config; use Garradin\CSV_Custom; use Garradin\DB; use Garradin\DynamicList; use Garradin\Entity; use Garradin\Utils; use Garradin\UserException; use Garradin\ValidationException; use Garradin\Accounting\Accounts; use Garradin\Accounting\Charts; class Account extends Entity { const NAME = 'Compte'; const PRIVATE_URL = '!acc/charts/accounts/edit.php?id=%d'; const TABLE = 'acc_accounts'; const NONE = 0; // Actif const ASSET = 1; |
︙ | ︙ | |||
246 247 248 249 250 251 252 253 254 255 256 257 258 259 | 'id_project' => [ 'select' => 'l.id_project', ], 'project_code' => [ 'select' => 'IFNULL(p.code, SUBSTR(p.label, 1, 10) || \'…\')', 'label' => 'Projet', ], 'status' => [ 'select' => 't.status', ], ]; protected ?int $id; protected int $id_chart; | > > > > | 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 | 'id_project' => [ 'select' => 'l.id_project', ], 'project_code' => [ 'select' => 'IFNULL(p.code, SUBSTR(p.label, 1, 10) || \'…\')', 'label' => 'Projet', ], 'locked' => [ 'label' => '', 'select' => 't.hash IS NOT NULL', ], 'status' => [ 'select' => 't.status', ], ]; protected ?int $id; protected int $id_chart; |
︙ | ︙ | |||
560 561 562 563 564 565 566 | /** * Renvoie TRUE si le solde du compte est inversé en vue simplifiée (= crédit - débit, au lieu de débit - crédit) * @return boolean */ public function isReversed(bool $simple, int $id_year): bool { | > | > | | 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 | /** * Renvoie TRUE si le solde du compte est inversé en vue simplifiée (= crédit - débit, au lieu de débit - crédit) * @return boolean */ public function isReversed(bool $simple, int $id_year): bool { $is_reversed = Accounts::isReversed($simple, $this->type); if (!$is_reversed) { return $is_reversed; } $position = $this->getPosition($id_year); if ($position == self::ASSET || $position == self::EXPENSE) { return false; } |
︙ | ︙ | |||
631 632 633 634 635 636 637 638 639 640 641 642 643 644 | yield $row; } if (!$only_non_reconciled) { yield (object) ['sum' => $sum, 'reconciled_sum' => $reconciled_sum, 'date' => $end_date]; } } public function getDepositJournal(int $year_id, array $checked = []): DynamicList { $columns = [ 'id' => [ 'label' => 'Num.', 'select' => 't.id', | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 | yield $row; } if (!$only_non_reconciled) { yield (object) ['sum' => $sum, 'reconciled_sum' => $reconciled_sum, 'date' => $end_date]; } } public function mergeReconcileJournalAndCSV(\Generator $journal, CSV_Custom $csv) { $lines = []; $csv = iterator_to_array($csv->iterate()); $journal = iterator_to_array($journal); $i = 0; $sum = 0; foreach ($csv as $k => &$line) { try { $date = \DateTime::createFromFormat('!d/m/Y', $line->date); $line->amount = (substr($line->amount, 0, 1) == '-' ? -1 : 1) * Utils::moneyToInteger($line->amount); if (!$date) { throw new UserException('Date invalide : ' . $line->date); } $line->date = $date; } catch (UserException $e) { throw new UserException(sprintf('Ligne %d : %s', $k, $e->getMessage())); } } unset($line); foreach ($journal as $j) { $id = $j->date->format('Ymd') . '.' . $i++; $row = (object) ['csv' => null, 'journal' => $j]; if (isset($j->debit)) { foreach ($csv as &$line) { if (!isset($line->date)) { continue; } // Match date, amount and label if ($j->date->format('Ymd') == $line->date->format('Ymd') && ($j->credit * -1 == $line->amount || $j->debit == $line->amount) && strtolower($j->label) == strtolower($line->label)) { $row->csv = $line; $line = null; break; } } } $lines[$id] = $row; } unset($line, $row, $j); // Second round to match only amount and label foreach ($lines as $row) { if ($row->csv || !isset($row->journal->debit)) { continue; } $j = $row->journal; foreach ($csv as &$line) { if (!isset($line->date)) { continue; } if ($j->date->format('Ymd') == $line->date->format('Ymd') && ($j->credit * -1 == $line->amount || $j->debit == $line->amount)) { $row->csv = $line; $line = null; break; } } } unset($j); // Then add CSV lines on the right foreach ($csv as $line) { if (null == $line) { continue; } $id = $line->date->format('Ymd') . '.' . ($i++); $lines[$id] = (object) ['csv' => $line, 'journal' => null]; } ksort($lines); $prev = null; foreach ($lines as &$line) { $line->add = false; if (isset($line->csv)) { $sum += $line->csv->amount; $line->csv->running_sum = $sum; if ($prev && ($prev->date->format('Ymd') != $line->csv->date->format('Ymd') || $prev->label != $line->csv->label)) { $prev = null; } } if (isset($line->csv) && isset($line->journal)) { $prev = null; } if (isset($line->csv) && !isset($line->journal) && !$prev) { $line->add = true; $prev = $line->csv; } } return $lines; } public function getDepositJournal(int $year_id, array $checked = []): DynamicList { $columns = [ 'id' => [ 'label' => 'Num.', 'select' => 't.id', |
︙ | ︙ | |||
848 849 850 851 852 853 854 | $this->_chart ??= Charts::get($this->id_chart); return $this->_chart; } public function save(bool $selfcheck = true): bool { $this->setLocalRules(); | < | 973 974 975 976 977 978 979 980 981 982 983 984 985 986 | $this->_chart ??= Charts::get($this->id_chart); return $this->_chart; } public function save(bool $selfcheck = true): bool { $this->setLocalRules(); DB::getInstance()->exec(sprintf('REPLACE INTO config (key, value) VALUES (\'last_chart_change\', %d);', time())); return parent::save($selfcheck); } public function position_name(): string { |
︙ | ︙ |
Modified src/include/lib/Garradin/Entities/Accounting/Chart.php from [fe9131dd28] to [6960581c55].
︙ | ︙ | |||
10 11 12 13 14 15 16 17 18 19 20 21 22 23 | use Garradin\UserException; use Garradin\Accounting\Accounts; use KD2\DB\EntityManager; class Chart extends Entity { const TABLE = 'acc_charts'; protected ?int $id; protected string $label; protected ?string $country = null; protected ?string $code; protected bool $archived = false; | > > > | 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | use Garradin\UserException; use Garradin\Accounting\Accounts; use KD2\DB\EntityManager; class Chart extends Entity { const NAME = 'Plan comptable'; const PRIVATE_URL = '!acc/charts/accounts/all.php?id=%d'; const TABLE = 'acc_charts'; protected ?int $id; protected string $label; protected ?string $country = null; protected ?string $code; protected bool $archived = false; |
︙ | ︙ |
Modified src/include/lib/Garradin/Entities/Accounting/Transaction.php from [7f9d0baf60] to [0a0f54d4f9].
1 2 3 4 5 6 | <?php namespace Garradin\Entities\Accounting; use KD2\DB\EntityManager; | | | > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | <?php namespace Garradin\Entities\Accounting; use KD2\DB\EntityManager; use Garradin\Config; use Garradin\DB; use Garradin\Entity; use Garradin\Form; use Garradin\Utils; use Garradin\UserException; use Garradin\Users\DynamicFields; use Garradin\Files\Files; use Garradin\Entities\Files\File; use Garradin\Accounting\Accounts; use Garradin\Accounting\Projects; use Garradin\Accounting\Years; use Garradin\ValidationException; class Transaction extends Entity { const NAME = 'Écriture'; const PRIVATE_URL = '!acc/transactions/details.php?id=%d'; const TABLE = 'acc_transactions'; const TYPE_ADVANCED = 0; const TYPE_REVENUE = 1; const TYPE_EXPENSE = 2; const TYPE_TRANSFER = 3; const TYPE_DEBT = 4; |
︙ | ︙ | |||
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | 'Avancé', 'Recette', 'Dépense', 'Virement', 'Dette', 'Créance', ]; protected ?int $id; protected ?int $type = null; protected int $status = 0; protected string $label; protected ?string $notes = null; protected ?string $reference = null; protected \KD2\DB\Date $date; | > > > > > > > > > > > > > > > > > < < > | 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | 'Avancé', 'Recette', 'Dépense', 'Virement', 'Dette', 'Créance', ]; const LOCKED_PROPERTIES = [ 'label', 'reference', 'date', 'id_year', 'prev_id', 'prev_hash', ]; const LOCKED_LINE_PROPERTIES = [ 'id_account', 'debit', 'credit', 'label', 'reference', ]; protected ?int $id; protected ?int $type = null; protected int $status = 0; protected string $label; protected ?string $notes = null; protected ?string $reference = null; protected \KD2\DB\Date $date; protected ?string $hash = null; protected ?int $prev_id = null; protected ?string $prev_hash = null; protected int $id_year; protected ?int $id_creator = null; protected ?int $id_related = null; protected $_lines; |
︙ | ︙ | |||
375 376 377 378 379 380 381 | WHERE l.id_transaction = ?;', $year->chart()->id, $this->id() ); foreach ($lines as $l) { $line = new Line; | < < < < | < < < < < < < < < | | > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | | > > > > > > > > > > | 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 | WHERE l.id_transaction = ?;', $year->chart()->id, $this->id() ); foreach ($lines as $l) { $line = new Line; foreach ($copy as $field) { // Do not copy id_account when it is null, as it will trigger an error (invalid entity) if ($field == 'id_account' && !isset($l->$field)) { continue; } $line->$field = $l->$field; } $new->addLine($line); } // Only set date if valid if ($this->date >= $year->start_date && $this->date <= $year->end_date) { $new->date = clone $this->date; } return $new; } public function payment_reference(): ?string { $line = current($this->getLines()); if (!$line) { return null; } return $line->reference; } public function getHash(): string { if (!$this->id_year) { throw new \LogicException('Il n\'est pas possible de hasher un mouvement qui n\'est pas associé à un exercice'); } $hash = hash_init('sha256'); $values = $this->asArray(true); $values = array_intersect_key($values, array_flip(self::LOCKED_PROPERTIES)); hash_update($hash, implode(',', array_keys($values))); hash_update($hash, implode(',', $values)); foreach ($this->getLines() as $line) { $values = $line->asArray(true); $values = array_intersect_key($values, array_flip(self::LOCKED_LINE_PROPERTIES)); hash_update($hash, implode(',', array_keys($values))); hash_update($hash, implode(',', $values)); } return hash_final($hash, false); } public function isVerified(): bool { if (!$this->prev_id) { return false; } if (!$this->prev_hash) { return false; } return $this->verify(); } public function isLocked(): bool { // locking just got set if ($this->hash && array_key_exists('hash', $this->_modified) && $this->_modified['hash'] === null) { return false; } return $this->hash === null ? false : true; } public function canSaveChanges(): bool { if (!$this->isLocked()) { return true; } if ($this->isModified('hash')) { return false; } foreach (self::LOCKED_PROPERTIES as $prop) { if ($this->isModified($prop)) { return false; } } foreach ($this->getLines() as $line) { foreach (self::LOCKED_LINE_PROPERTIES as $prop) { if ($line->isModified($prop)) { return false; } } } return true; } public function assertCanBeModified(): void { // Allow to change the status if (count($this->_modified) === 1 && array_key_exists('status', $this->_modified)) { return; } // We allow to change notes and id_project in a locked transaction if (!$this->canSaveChanges()) { throw new ValidationException('Il n\'est pas possible de modifier une écriture qui a été verrouillée'); } $db = DB::getInstance(); if ($db->test(Year::TABLE, 'id = ? AND closed = 1', $this->id_year)) { throw new ValidationException('Il n\'est pas possible de créer ou modifier une écriture dans un exercice clôturé'); } } public function verify(): bool { return hash_equals($this->getHash(), $this->hash); } public function lock(): void { // Select last locked transaction $prev = DB::getInstance()->first('SELECT MAX(id) AS id, hash FROM acc_transactions WHERE hash IS NOT NULL AND id_year = ?;', $this->id_year); $this->set('prev_id', $prev->id ?? null); $this->set('prev_hash', $prev->hash ?? null); $this->set('hash', $this->getHash()); $this->save(); } public function addLine(Line $line) { $this->_lines[] = $line; } public function sum(): int |
︙ | ︙ | |||
472 473 474 475 476 477 478 | if ($this->type == self::TYPE_DEBT || $this->type == self::TYPE_CREDIT) { // Debts and credits add a waiting status if (!$this->exists()) { $this->addStatus(self::STATUS_WAITING); } } | < | < < < < < < < < < < < < | 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 | if ($this->type == self::TYPE_DEBT || $this->type == self::TYPE_CREDIT) { // Debts and credits add a waiting status if (!$this->exists()) { $this->addStatus(self::STATUS_WAITING); } } $this->assertCanBeModified(); $this->selfCheck(); $lines = $this->getLinesWithAccounts(); // Self check lines before saving Transaction foreach ($lines as $i => $l) { $line = $l->line; |
︙ | ︙ | |||
521 522 523 524 525 526 527 528 529 530 531 532 533 534 | } if ($this->exists() && $this->status & self::STATUS_ERROR) { // Remove error status when changed $this->removeStatus(self::STATUS_ERROR); } $db->begin(); if (!parent::save()) { return false; } foreach ($lines as $line) { | > | 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 | } if ($this->exists() && $this->status & self::STATUS_ERROR) { // Remove error status when changed $this->removeStatus(self::STATUS_ERROR); } $db = DB::getInstance(); $db->begin(); if (!parent::save()) { return false; } foreach ($lines as $line) { |
︙ | ︙ | |||
590 591 592 593 594 595 596 | $this->assert(strlen($this->label) <= 200, 'Le champ libellé ne peut faire plus de 200 caractères.'); $this->assert(!isset($this->reference) || strlen($this->reference) <= 200, 'Le champ numéro de pièce comptable ne peut faire plus de 200 caractères.'); $this->assert(!isset($this->notes) || strlen($this->notes) <= 2000, 'Le champ remarques ne peut faire plus de 2000 caractères.'); $this->assert(!empty($this->date), 'Le champ date ne peut rester vide.'); $this->assert(null !== $this->id_year, 'Aucun exercice spécifié.'); $this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type d\'écriture inconnu : ' . $this->type); | | | 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 | $this->assert(strlen($this->label) <= 200, 'Le champ libellé ne peut faire plus de 200 caractères.'); $this->assert(!isset($this->reference) || strlen($this->reference) <= 200, 'Le champ numéro de pièce comptable ne peut faire plus de 200 caractères.'); $this->assert(!isset($this->notes) || strlen($this->notes) <= 2000, 'Le champ remarques ne peut faire plus de 2000 caractères.'); $this->assert(!empty($this->date), 'Le champ date ne peut rester vide.'); $this->assert(null !== $this->id_year, 'Aucun exercice spécifié.'); $this->assert(array_key_exists($this->type, self::TYPES_NAMES), 'Type d\'écriture inconnu : ' . $this->type); $this->assert(null === $this->id_creator || $db->test('users', 'id = ?', $this->id_creator), 'Le membre créateur de l\'écriture n\'existe pas ou plus'); $is_in_year = $db->test(Year::TABLE, 'id = ? AND start_date <= ? AND end_date >= ?', $this->id_year, $this->date->format('Y-m-d'), $this->date->format('Y-m-d')); if (!$is_in_year) { $year = Years::get($this->id_year); throw new ValidationException(sprintf('La date (%s) de l\'écriture ne correspond pas à l\'exercice "%s" : la date doit être entre le %s et le %s.', Utils::shortDate($this->date), |
︙ | ︙ | |||
630 631 632 633 634 635 636 | $total += $line->credit; $total -= $line->debit; } $this->assert(0 === $total, sprintf('Écriture non équilibrée : déséquilibre (%s) entre débits et crédits', Utils::money_format($total))); | | < | | < < | 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 | $total += $line->credit; $total -= $line->debit; } $this->assert(0 === $total, sprintf('Écriture non équilibrée : déséquilibre (%s) entre débits et crédits', Utils::money_format($total))); // Foreign keys constraints will check for validity of id_creator and id_year $this->assert(!$this->id_related || $db->test('acc_transactions', 'id = ?', $this->id_related), 'L\'écriture liée indiquée n\'existe pas'); $this->assert(!$this->id_related || !$this->exists() || $this->id_related != $this->id, 'Il n\'est pas possible de lier une écriture à elle-même'); parent::selfCheck(); } public function importFromDepositForm(?array $source = null): void { if (null === $source) { $source = $_POST; } if (empty($source['amount'])) { throw new UserException('Montant non précisé'); } $this->type = self::TYPE_ADVANCED; $amount = $source['amount']; $account = Form::getSelectorValue($source['account_transfer']); if (!$account) { throw new ValidationException('Aucun compte de dépôt n\'a été sélectionné'); } $line = new Line; $line->importForm([ 'debit' => $amount, 'credit' => 0, 'id_account' => $account, ]); |
︙ | ︙ | |||
749 750 751 752 753 754 755 | // Add lines if (isset($source['lines']) && is_array($source['lines'])) { $this->resetLines(); $db = DB::getInstance(); foreach ($source['lines'] as $i => $line) { | < < < < < < < | > > > > > | 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 | // Add lines if (isset($source['lines']) && is_array($source['lines'])) { $this->resetLines(); $db = DB::getInstance(); foreach ($source['lines'] as $i => $line) { if (isset($line['account_selector'])) { $line['id_account'] = Form::getSelectorValue($line['account_selector']); } elseif (isset($line['account'])) { if (empty($this->id_year) && empty($source['id_year'])) { throw new ValidationException('L\'identifiant de l\'exercice comptable n\'est pas précisé.'); } $id_chart = $id_chart ?? $db->firstColumn('SELECT id_chart FROM acc_years WHERE id = ?;', $source['id_year'] ?? $this->id_year); $line['id_account'] = $db->firstColumn('SELECT id FROM acc_accounts WHERE code = ? AND id_chart = ?;', $line['account'], $id_chart); if (empty($line['id_account'])) { throw new ValidationException(sprintf('Le compte avec le code "%s" sur la ligne %d n\'existe pas.', $line['account'], $i+1)); } } if (empty($line['id_account'])) { throw new ValidationException(sprintf('Ligne %d : aucun compte n\'a été sélectionné', $i + 1)); } $l = new Line; $l->importForm($line); $this->addLine($l); } } |
︙ | ︙ | |||
954 955 956 957 958 959 960 | $db->begin(); $sql = sprintf('DELETE FROM acc_transactions_users WHERE id_transaction = ? AND %s AND id_service_user IS NULL;', $db->where('id_user', 'NOT IN', $users)); $db->preparedQuery($sql, $this->id()); foreach ($users as $id) { | | | 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 | $db->begin(); $sql = sprintf('DELETE FROM acc_transactions_users WHERE id_transaction = ? AND %s AND id_service_user IS NULL;', $db->where('id_user', 'NOT IN', $users)); $db->preparedQuery($sql, $this->id()); foreach ($users as $id) { $db->preparedQuery('INSERT OR IGNORE INTO acc_transactions_users (id_transaction, id_user, id_service_user) VALUES (?, ?, NULL);', $this->id(), $id); } $db->commit(); } public function checkLinkedUsersChange(array $users): bool { |
︙ | ︙ | |||
976 977 978 979 980 981 982 | return true; } public function listLinkedUsers(): array { $db = EntityManager::getInstance(self::class)->DB(); | | | | | | | | 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 | return true; } public function listLinkedUsers(): array { $db = EntityManager::getInstance(self::class)->DB(); $identity_column = DynamicFields::getNameFieldsSQL('u'); $sql = sprintf('SELECT u.id, %s AS identity, l.id_service_user FROM users u INNER JOIN acc_transactions_users l ON l.id_user = u.id WHERE l.id_transaction = ? ORDER BY id;', $identity_column); return $db->get($sql, $this->id()); } public function listLinkedUsersAssoc(): array { $db = EntityManager::getInstance(self::class)->DB(); $identity_column = DynamicFields::getNameFieldsSQL('u'); $sql = sprintf('SELECT u.id, %s AS identity, l.id_service_user FROM users u INNER JOIN acc_transactions_users l ON l.id_user = u.id WHERE l.id_transaction = ? AND l.id_service_user IS NULL;', $identity_column); return $db->getAssoc($sql, $this->id()); } public function unlinkServiceUser(int $id): void { $db = EntityManager::getInstance(self::class)->DB(); |
︙ | ︙ |
Modified src/include/lib/Garradin/Entities/Accounting/Year.php from [b488c48138] to [19edb1963d].
︙ | ︙ | |||
9 10 11 12 13 14 15 16 17 18 19 20 21 22 | use Garradin\Utils; use Garradin\Accounting\Accounts; use Garradin\Files\Files; use Garradin\Entities\Files\File; class Year extends Entity { const TABLE = 'acc_years'; protected $id; protected $label; protected $start_date; protected $end_date; protected $closed = 0; | > > > | 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | use Garradin\Utils; use Garradin\Accounting\Accounts; use Garradin\Files\Files; use Garradin\Entities\Files\File; class Year extends Entity { const NAME = 'Exercice'; const PRIVATE_URL = '!acc/years/reports/graphs.php?year=%d'; const TABLE = 'acc_years'; protected $id; protected $label; protected $start_date; protected $end_date; protected $closed = 0; |
︙ | ︙ | |||
88 89 90 91 92 93 94 | $t = new Transaction; $t->import([ 'id_year' => $this->id(), 'label' => sprintf('Exercice réouvert le %s', date('d/m/Y à H:i:s')), 'type' => Transaction::TYPE_ADVANCED, 'date' => $this->end_date->format('d/m/Y'), 'id_creator' => $user_id, | < > > > | 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | $t = new Transaction; $t->import([ 'id_year' => $this->id(), 'label' => sprintf('Exercice réouvert le %s', date('d/m/Y à H:i:s')), 'type' => Transaction::TYPE_ADVANCED, 'date' => $this->end_date->format('d/m/Y'), 'id_creator' => $user_id, 'notes' => 'Écriture automatique créée lors de la réouverture, à des fins de transparence. Cette écriture ne peut pas être supprimée ni modifiée.', ]); $line = new Line; $line->import([ 'debit' => 0, 'credit' => 1, 'id_account' => $closing_id, ]); $t->addLine($line); $line = new Line; $line->import([ 'debit' => 1, 'credit' => 0, 'id_account' => $closing_id, ]); $t->addLine($line); // Lock transaction $t->lock(); $t->save(); } /** * Splits an accounting year between the current year and another one, at a given date * Any transaction after the given date will be moved to the target year. |
︙ | ︙ |
Added src/include/lib/Garradin/Entities/Email/Email.php version [36e29843f4].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | <?php declare(strict_types=1); namespace Garradin\Entities\Email; use Garradin\Entity; use Garradin\UserException; use Garradin\Email\Emails; use Garradin\Email\Templates as EmailsTemplates; use KD2\SMTP; use const Garradin\{WWW_URL, SECRET_KEY}; class Email extends Entity { const TABLE = 'emails'; /** * Antispam services that require to do a manual action to accept emails */ const BLACKLIST_MANUAL_VALIDATION_MX = '/mailinblack\.com|spamenmoins\.com/'; const COMMON_DOMAINS = ['laposte.net', 'gmail.com', 'hotmail.fr', 'hotmail.com', 'wanadoo.fr', 'free.fr', 'sfr.fr', 'yahoo.fr', 'orange.fr', 'live.fr', 'outlook.fr', 'yahoo.com', 'neuf.fr', 'outlook.com', 'icloud.com', 'riseup.net', 'vivaldi.net', 'aol.com', 'gmx.de', 'lilo.org', 'mailo.com', 'protonmail.com']; protected int $id; protected string $hash; protected bool $verified = false; protected bool $optout = false; protected bool $invalid = false; protected int $sent_count = 0; protected int $fail_count = 0; protected ?string $fail_log; protected \DateTime $added; protected ?\DateTime $last_sent; /** * Normalize email address and create a hash from this */ static public function getHash(string $email): string { $email = strtolower(trim($email)); $host = substr($email, strrpos($email, '@')+1); $host = idn_to_ascii($host); $email = substr($email, 0, strrpos($email, '@')+1) . $host; return sha1($email); } static public function getOptoutURL(string $hash = null): string { $hash = hex2bin($hash); $hash = base64_encode($hash); // Make base64 hash valid for URLs $hash = rtrim(strtr($hash, '+/', '-_'), '='); return sprintf('%s?un=%s', WWW_URL, $hash); } public function getVerificationCode(): string { $code = sha1($this->hash . SECRET_KEY); return substr($code, 0, 10); } public function sendVerification(string $email): void { if (self::getHash($email) !== $this->hash) { throw new UserException('Adresse email inconnue'); } $verify_url = self::getOptoutURL($this->hash) . '&v=' . $this->getVerificationCode(); EmailsTemplates::verifyAddress($email, $verify_url); } public function verify(string $code): bool { if ($code !== $this->getVerificationCode()) { return false; } $this->set('verified', true); $this->set('optout', false); $this->set('invalid', false); $this->set('fail_count', 0); $this->set('fail_log', null); return true; } public function validate(string $email): bool { if (!$this->canSend()) { return false; } try { self::validateAddress($email); } catch (UserException $e) { $this->hasFailed(['type' => 'permanent', 'message' => $e->getMessage()]); return false; } return true; } static public function validateAddress(string $email): void { $pos = strrpos($email, '@'); if ($pos === false) { throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'); } $user = substr($email, 0, $pos); $host = substr($email, $pos+1); // Ce domaine n'existe pas (MX inexistant), erreur de saisie courante if ($host == 'gmail.fr') { throw new UserException('Adresse invalide : "gmail.fr" n\'existe pas, il faut utiliser "gmail.com"'); } if (preg_match('![/@]!', $user)) { throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'); } if (!SMTP::checkEmailIsValid($email, false)) { if (!trim($host)) { throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'); } foreach (self::COMMON_DOMAINS as $common_domain) { similar_text($common_domain, $host, $percent); if ($percent > 90) { throw new UserException(sprintf('Adresse e-mail invalide : avez-vous fait une erreur, par exemple "%s" à la place de "%s" ?', $host, $common_domain)); } } throw new UserException('Adresse e-mail invalide : vérifiez que vous n\'avez pas fait une faute de frappe.'); } // Windows does not support MX lookups if (PHP_OS_FAMILY == 'Windows') { return; } getmxrr($host, $mx_list); if (empty($mx_list)) { throw new UserException('Adresse e-mail invalide (le domaine indiqué n\'a pas de service e-mail) : vérifiez que vous n\'avez pas fait une faute de frappe.'); } foreach ($mx_list as $mx) { if (preg_match(self::BLACKLIST_MANUAL_VALIDATION_MX, $mx)) { throw new UserException('Adresse e-mail invalide : impossible d\'envoyer des mails à un service (de type mailinblack ou spamenmoins) qui demande une validation manuelle de l\'expéditeur. Merci de choisir une autre adresse e-mail.'); } } } public function canSend(): bool { if (!empty($this->optout)) { return false; } if (!empty($this->invalid)) { return false; } if ($this->hasReachedFailLimit()) { return false; } return true; } public function hasReachedFailLimit(): bool { return !empty($this->fail_count) && ($this->fail_count >= Emails::FAIL_LIMIT); } public function incrementSentCount(): void { $this->set('sent_count', $this->sent_count+1); } public function setOptout(): void { $this->set('optout', true); $this->appendFailLog('Demande de désinscription'); } public function appendFailLog(string $message): void { $log = $this->fail_log ?? ''; if ($log) { $log .= "\n"; } $log .= date('d/m/Y H:i:s - ') . trim($message); $this->set('fail_log', $log); } public function hasFailed(array $return): void { if (!isset($return['type'])) { throw new \InvalidArgumentException('Bounce email type not supplied in argument.'); } // Treat complaints as opt-out if ($return['type'] == 'complaint') { $this->set('optout', true); $this->appendFailLog("Un signalement de spam a été envoyé par le destinataire.\n: " . $return['message']); } elseif ($return['type'] == 'permanent') { $this->set('invalid', true); $this->set('fail_count', $this->fail_count+1); $this->appendFailLog($return['message']); } elseif ($return['type'] == 'temporary') { $this->set('fail_count', $this->fail_count+1); $this->appendFailLog($return['message']); } } } |
Added src/include/lib/Garradin/Entities/Email/Mailing.php version [221022b622].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | <?php namespace Garradin\Entities\Email; use Garradin\Config; use Garradin\CSV; use Garradin\UserException; use Garradin\Email\Emails; use Garradin\Users\DynamicFields; use Garradin\UserTemplate\UserTemplate; use Garradin\Web\Render\Render; class Mailing { const RENDER_FORMATS = [ null => 'Texte brut', Render::FORMAT_SKRIV => 'SkrivML', Render::FORMAT_MARKDOWN => 'MarkDown', ]; protected string $subject; protected string $body; protected ?string $render_format = null; /** * Create a mass mailing */ static public function create(iterable $recipients, string $subject, string $message, bool $send_copy, ?string $render): \stdClass { $list = []; foreach ($recipients as $recipient) { if (empty($recipient->email)) { continue; } $list[$recipient->email] = $recipient; } if (!count($list)) { throw new UserException('La liste de destinataires sélectionnée ne comporte aucun membre, ou aucun avec une adresse e-mail renseignée.'); } $html = null; $tpl = null; $random = array_rand($list); if (false !== strpos($message, '{{')) { $tpl = new UserTemplate; $tpl->setCode($message); $tpl->toggleSafeMode(true); $tpl->assignArray((array)$list[$random]); $tpl->setEscapeDefault(null); try { if (!$render) { // Disable HTML escaping for plaintext emails $message = $tpl->fetch(); } else { $html = $tpl->fetch(); } } catch (\KD2\Brindille_Exception $e) { throw new UserException('Erreur de syntaxe dans le corps du message :' . PHP_EOL . $e->getPrevious()->getMessage(), 0, $e); } } if ($render) { $html = Render::render($render, null, $html ?? $message); } elseif (null !== $html) { $html = '<pre>' . $html . '</pre>'; } else { $html = '<pre>' . htmlspecialchars(wordwrap($message)) . '</pre>'; } $recipients = $list; $config = Config::getInstance(); $sender = sprintf('"%s" <%s>', $config->org_name, $config->org_email); $message = (object) compact('recipients', 'subject', 'message', 'sender', 'tpl', 'send_copy', 'render'); $message->preview = (object) [ 'to' => $random, // Not required to be a valid From header, this is just a preview 'from' => $sender, 'subject' => $subject, 'html' => $html, ]; return $message; } /** * Send a mass mailing */ static public function send(\stdClass $mailing): void { if (!isset($mailing->recipients, $mailing->subject, $mailing->message, $mailing->send_copy)) { throw new \InvalidArgumentException('Invalid $mailing object'); } if (!count($mailing->recipients)) { throw new UserException('Aucun destinataire de la liste ne possède d\'adresse email.'); } Emails::queue(Emails::CONTEXT_BULK, $mailing->recipients, null, // Default sender $mailing->subject, $mailing->tpl ?? $mailing->message, $mailing->render ?? null ); if ($mailing->send_copy) { $config = Config::getInstance(); Emails::queue(Emails::CONTEXT_BULK, [$config->org_email => null], null, $mailing->subject, $mailing->message); } } static public function export(string $format, \stdClass $mailing): void { $rows = $mailing->recipients; $id_field = DynamicFields::getNameFieldsSQL('u'); foreach ($rows as $key => &$row) { $row = [$key, $row->$id_field ?? '']; } unset($row); CSV::export($format, 'Destinataires message collectif', $rows, ['Adresse e-mail', 'Identité']); } } |
Modified src/include/lib/Garradin/Entities/Files/File.php from [8291d7abd8] to [b30d4ce2b3].
1 2 3 4 5 6 7 8 9 10 | <?php namespace Garradin\Entities\Files; use KD2\Graphics\Image; use KD2\DB\EntityManager as EM; use KD2\Security; use Garradin\DB; use Garradin\Entity; | > > | | > > > > | | | | | | | | | | < < < < < < < < < < < < | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | <?php namespace Garradin\Entities\Files; use KD2\Graphics\Image; use KD2\Graphics\Blob; use KD2\DB\EntityManager as EM; use KD2\Security; use Garradin\Config; use Garradin\DB; use Garradin\Entity; use Garradin\Plugins; use Garradin\Template; use Garradin\UserException; use Garradin\ValidationException; use Garradin\Users\Session; use Garradin\Static_Cache; use Garradin\Utils; use Garradin\Entities\Web\Page; use Garradin\Web\Render\Render; use Garradin\Web\Router; use Garradin\Web\Cache as Web_Cache; use KD2\WebDAV\WOPI; use Garradin\Files\WebDAV\Storage; use Garradin\Files\Files; use const Garradin\{WWW_URL, BASE_URL, ENABLE_XSENDFILE, SECRET_KEY, WOPI_DISCOVERY_URL, SHARED_CACHE_ROOT}; /** * This is a virtual entity, it cannot be saved to a SQL table */ class File extends Entity { const TABLE = 'files'; protected ?int $id; /** * Parent directory of file */ protected ?string $parent = null; /** * File name */ protected string $name; /** * Complete file path (parent + '/' + name) */ protected string $path; /** * Type of file: file or directory */ protected int $type = self::TYPE_FILE; protected ?string $mime = null; protected ?int $size = null; protected \DateTime $modified; protected bool $image; const TYPE_FILE = 1; const TYPE_DIRECTORY = 2; const TYPE_LINK = 3; /** * Tailles de miniatures autorisées, pour ne pas avoir 500 fichiers générés avec 500 tailles différentes |
︙ | ︙ | |||
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 | const THUMB_SIZE_SMALL = '500px'; const CONTEXT_DOCUMENTS = 'documents'; const CONTEXT_USER = 'user'; const CONTEXT_TRANSACTION = 'transaction'; const CONTEXT_CONFIG = 'config'; const CONTEXT_WEB = 'web'; const CONTEXT_SKELETON = 'skel'; const CONTEXTS_NAMES = [ self::CONTEXT_DOCUMENTS => 'Documents', self::CONTEXT_USER => 'Membre', self::CONTEXT_TRANSACTION => 'Écriture comptable', self::CONTEXT_CONFIG => 'Configuration', self::CONTEXT_WEB => 'Site web', self::CONTEXT_SKELETON => 'Squelettes', ]; const IMAGE_TYPES = [ 'image/png', 'image/gif', 'image/jpeg', | > > > > > > > > | 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | const THUMB_SIZE_SMALL = '500px'; const CONTEXT_DOCUMENTS = 'documents'; const CONTEXT_USER = 'user'; const CONTEXT_TRANSACTION = 'transaction'; const CONTEXT_CONFIG = 'config'; const CONTEXT_WEB = 'web'; const CONTEXT_MODULES = 'modules'; const CONTEXT_TRASH = 'trash'; /** * @deprecated */ const CONTEXT_SKELETON = 'skel'; const CONTEXTS_NAMES = [ self::CONTEXT_DOCUMENTS => 'Documents', self::CONTEXT_USER => 'Membre', self::CONTEXT_TRANSACTION => 'Écriture comptable', self::CONTEXT_CONFIG => 'Configuration', self::CONTEXT_WEB => 'Site web', self::CONTEXT_MODULES => 'Modules', self::CONTEXT_TRASH => 'Corbeille', self::CONTEXT_SKELETON => 'Squelettes', ]; const IMAGE_TYPES = [ 'image/png', 'image/gif', 'image/jpeg', |
︙ | ︙ | |||
132 133 134 135 136 137 138 | 'image/webp', 'image/svg+xml', 'text/plain', 'text/html', ]; // https://book.hacktricks.xyz/pentesting-web/file-upload | | < > > > > > > > > > > | > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | > | 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 | 'image/webp', 'image/svg+xml', 'text/plain', 'text/html', ]; // https://book.hacktricks.xyz/pentesting-web/file-upload const FORBIDDEN_EXTENSIONS = '!^(?:cgi|exe|sh|bash|com|pif|jspx?|jar|js[wxv]|action|do|php(?:s|\d+)?|pht|phtml?|shtml|phar|htaccess|inc|cfml?|cfc|dbm|swf|pl|perl|py|pyc|asp|so)$!i'; static public function getColumns(): array { return array_keys((new self)->_types); } public function selfCheck(): void { $this->assert($this->type === self::TYPE_DIRECTORY || $this->type === self::TYPE_FILE, 'Unknown file type'); $this->assert($this->type === self::TYPE_DIRECTORY || $this->size !== null, 'File size must be set'); $this->assert(trim($this->name) !== '', 'Le nom de fichier ne peut rester vide'); $this->assert(strlen($this->path), 'Le chemin ne peut rester vide'); $this->assert(strlen($this->parent) || '' === $this->parent, 'Le chemin ne peut rester vide'); } public function context(): string { return strtok($this->path, '/'); } public function parent(): File { return Files::get($this->parent); } public function fullpath(): string { $path = Files::callStorage('getFullPath', $this); if (null === $path) { throw new \RuntimeException('File does not exist: ' . $this->path); } return $path; } public function etag(): string { return md5($this->path . $this->size . $this->modified->getTimestamp()); } /** * Return TRUE if the file can be previewed natively in a browser * @return bool */ public function canPreview(): bool { if (in_array($this->mime, self::PREVIEW_TYPES)) { return true; } if (!WOPI_DISCOVERY_URL) { return false; } if ($this->getWopiURL()) { return true; } return false; } public function moveToTrash(): void { if ($this->context() == self::CONTEXT_TRASH) { return; } $this->touch(); $this->move(self::CONTEXT_TRASH . '/' . $this->parent); } public function restoreFromTrash(): ?string { if ($this->context() != self::CONTEXT_TRASH) { return null; } $parent = substr($this->parent, strlen(self::CONTEXT_TRASH . '/')); // Move to original parent path if (Files::exists($parent)) { $this->move($parent); } // Parent directory no longer exists, move file to documents root, // but under a new name to make sure it doesn't overwrite an existing file else { $new_name = sprintf('Restauré de la corbeille - %s - %s', date('d-m-Y His'), $this->name); $parent = self::CONTEXT_DOCUMENTS; $this->rename($parent . '/' . $new_name); } return $parent; } public function delete(): bool { Files::callStorage('checkLock'); Web_Cache::delete($this->uri()); // Delete actual file content Files::callStorage('delete', $this); Plugins::fireSignal('files.delete', ['file' => $this]); // clean up thumbnails foreach (self::ALLOWED_THUMB_SIZES as $key => $operations) { Static_Cache::remove(sprintf(self::THUMB_CACHE_ID, $this->pathHash(), $key)); } DB::getInstance()->delete('files_search', 'path = ? OR path LIKE ?', $this->path, $this->path . '/%'); // Delete entity if it exists if ($this->exists()) { return parent::delete(); } return true; } |
︙ | ︙ | |||
226 227 228 229 230 231 232 233 | /** * Rename a file, this can include moving it (the UNIX way) * @param string $new_path Target path * @return bool */ public function rename(string $new_path): bool { self::validatePath($new_path); | > > | > | > > | > > > | | > > | | | < | < | | > > > > > > | > > > | > | > | < | > > > > > > | > > > > > > > | | > | > > | | > | | > > | > > > > | > | | > | < > > < > > | | | | > > > | > | | | > > | 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 | /** * Rename a file, this can include moving it (the UNIX way) * @param string $new_path Target path * @return bool */ public function rename(string $new_path): bool { $name = Utils::basename($new_path); self::validatePath($new_path); self::validateFileName($name); self::validateCanHTML($name, $new_path); if ($new_path == $this->path) { throw new UserException(sprintf('Impossible de renommer "%s" lui-même', $this->path)); } if (0 === strpos($new_path . '/', $this->path . '/')) { if ($this->type != self::TYPE_DIRECTORY) { throw new UserException(sprintf('Impossible de renommer "%s" vers "%s"', $this->path, $new_path)); } } Files::ensureDirectoryExists(Utils::dirname($new_path)); $return = Files::callStorage('move', $this, $new_path); Plugins::fireSignal('files.move', ['file' => $this, 'new_path' => $new_path]); $escaped = strtr($this->path, ['%' => '!%', '_' => '!_', '!' => '!!']); // Rename references in files_search DB::getInstance()->preparedQuery('UPDATE files_search SET path = ? || SUBSTR(path, 1+LENGTH(?)) WHERE path LIKE ?;', $new_path . '/', $this->path . '/', $escaped . '%'); return $return; } /** * Copy the current file to a new location * @param string $target Target path * @return self */ public function copy(string $target): self { return Files::createFromPath($target, Files::callStorage('getFullPath', $this)); } public function setContent(string $content): self { $this->set('modified', new \DateTime); $this->store(['content' => rtrim($content)]); return $this; } /** * Store contents in file, either from a local path, from a binary string or from a pointer * * @param array $source [path, content or pointer] * @param string $source_content * @param bool $index_search Set to FALSE if you don't want the document to be indexed in the file search * @return self */ public function store(array $source, bool $index_search = true): self { if (!$this->path || !$this->name) { throw new \LogicException('Cannot store a file that does not have a target path and name'); } if ($this->type == self::TYPE_DIRECTORY) { throw new \LogicException('Cannot store a directory'); } if (!isset($source['path']) && !isset($source['content']) && !isset($source['pointer'])) { throw new \InvalidArgumentException('Unknown source type'); } elseif (count($source) != 1) { throw new \InvalidArgumentException('Invalid source type'); } $delete_after = false; $path = $content = $pointer = null; extract($source); if ($path) { $this->set('size', filesize($path)); Files::checkQuota($this->size); } elseif (null !== $content) { $this->set('size', strlen($content)); Files::checkQuota($this->size); } elseif ($pointer) { // See https://github.com/php/php-src/issues/9441 if (stream_get_meta_data($pointer)['uri'] == 'php://input') { while (!feof($pointer)) { fread($pointer, 8192); } } elseif (0 !== fseek($pointer, 0, SEEK_END)) { throw new \RuntimeException('Stream is not seekable'); } $this->set('size', ftell($pointer)); fseek($pointer, 0, SEEK_SET); Files::checkQuota($this->size); } // Check that it's a real image if ($this->image) { if ($path) { $blob = file_get_contents($path, false, null, 0, 1000); } elseif ($pointer) { $blob = fread($pointer, 1000); fseek($pointer, 0, SEEK_SET); } else { $blob = substr($content, 0, 1000); } if ($size = Blob::getSize($blob)) { // This is to avoid pixel flood attacks if ($size[0] > 8000 || $size[1] > 8000) { throw new ValidationException('Cette image est trop grande (taille max 8000 x 8000 pixels)'); } // Recompress PNG files from base64, assuming they are coming // from JS canvas which doesn't know how to gzip (d'oh!) if ($size[2] == 'image/png' && null !== $content) { $i = Image::createFromBlob($content); $content = $i->output('png', true); $this->set('size', strlen($content)); unset($i); } } elseif ($type = Blob::getType($blob)) { // WebP is fine, but we cannot get its size } else { // Not an image $this->set('image', false); } } Files::callStorage('checkLock'); // If a file of the same name already exists, define a new name if (Files::callStorage('exists', $this->path) && !$this->exists()) { $pos = strrpos($this->name, '.'); $new_name = substr($this->name, 0, $pos) . '.' . substr(sha1(random_bytes(16)), 0, 10) . substr($this->name, $pos); $this->set('name', $new_name); } if (!isset($this->modified)) { $this->set('modified', new \DateTime); } if (null !== $path) { $return = Files::callStorage('storePath', $this, $path); } elseif (null !== $content) { $return = Files::callStorage('storeContent', $this, $content); } else { $return = Files::callStorage('storePointer', $this, $pointer); fclose($pointer); } if (!$return) { throw new UserException('Le fichier n\'a pas pu être enregistré.'); } Plugins::fireSignal('files.store', ['file' => $this]); if ($index_search && $content) { $this->indexForSearch($content); } else { $this->removeFromSearch(); } // clean up thumbnails foreach (self::ALLOWED_THUMB_SIZES as $key => $operations) { Static_Cache::remove(sprintf(self::THUMB_CACHE_ID, $this->pathHash(), $key)); } Web_Cache::delete($this->uri()); return $this; } public function indexForSearch(?string $source_content, ?string $title = null, ?string $forced_mime = null): void { $mime = $forced_mime ?? $this->mime; |
︙ | ︙ | |||
398 399 400 401 402 403 404 | public function removeFromSearch(): void { $db = DB::getInstance(); $db->preparedQuery('DELETE FROM files_search WHERE path = ?;', $this->path); } | < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | | 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 | public function removeFromSearch(): void { $db = DB::getInstance(); $db->preparedQuery('DELETE FROM files_search WHERE path = ?;', $this->path); } /** * Returns true if this is a vector or bitmap image * as 'image' property is only for bitmaps * @return boolean */ public function isImage(): bool { if ($this->image || $this->mime == 'image/svg+xml') { return true; } return false; } public function isDir(): bool { return $this->type == self::TYPE_DIRECTORY; } public function iconShape(): ?string { if ($this->isImage()) { return 'image'; } elseif ($this->isDir()) { return 'directory'; } $ext = substr($this->name, strrpos($this->name, '.') + 1); $ext = strtolower($ext); switch ($ext) { case 'ods': case 'xls': case 'xlsx': case 'csv': return 'table'; case 'odt': case 'doc': case 'docx': case 'rtf': return 'document'; case 'pdf': return 'pdf'; case 'odp': case 'ppt': case 'pptx': return 'gallery'; case 'txt': case 'skriv': return 'text'; case 'md': return 'markdown'; case 'html': case 'css': case 'js': case 'tpl': return 'code'; case 'mkv': case 'mp4': case 'avi': case 'ogm': case 'ogv': return 'video'; } return 'document'; } /** * Full URL with https://... */ public function url(bool $download = false): string { $base = in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_MODULES, self::CONTEXT_CONFIG]) ? WWW_URL : BASE_URL; $url = $base . $this->uri(); if ($download) { $url .= '?download'; } return $url; |
︙ | ︙ | |||
673 674 675 676 677 678 679 680 681 682 683 684 685 | if (is_int($size)) { $size .= 'px'; } $size = isset(self::ALLOWED_THUMB_SIZES[$size]) ? $size : key(self::ALLOWED_THUMB_SIZES); return sprintf('%s?%dpx', $this->url(), $size); } /** * Envoie le fichier au client HTTP */ public function serve(?Session $session = null, bool $download = false, ?string $share_hash = null, ?string $share_password = null): void { | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | | | | > > > > > > > > > > > > > > > > > | | | 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 | if (is_int($size)) { $size .= 'px'; } $size = isset(self::ALLOWED_THUMB_SIZES[$size]) ? $size : key(self::ALLOWED_THUMB_SIZES); return sprintf('%s?%dpx', $this->url(), $size); } public function link(Session $session, ?string $thumb = null, bool $allow_edit = false) { if ($thumb == 'auto') { if ($this->isImage()) { $thumb = '150px'; } else { $thumb = 'icon'; } } if ($thumb == 'icon') { $label = sprintf('<span data-icon="%s"></span>', Utils::iconUnicode($this->iconShape())); } elseif ($thumb) { $label = sprintf('<img src="%s" alt="%s" />', htmlspecialchars($this->thumb_url($thumb)), htmlspecialchars($this->name)); } else { $label = preg_replace('/[_.-]/', '­$0', htmlspecialchars($this->name)); } if ($allow_edit && $this->canWrite($session) && $this->editorType()) { $attrs = sprintf('href="%s" target="_dialog" data-dialog-class="fullscreen"', Utils::getLocalURL('!common/files/edit.php?p=') . rawurlencode($this->path)); } elseif ($this->canPreview($session)) { $attrs = sprintf('href="%s" target="_dialog" data-mime="%s"', Utils::getLocalURL('!common/files/preview.php?p=') . rawurlencode($this->path), $this->mime); } else { $attrs = sprintf('href="%s" target="_blank"', $this->url(true)); } return sprintf('<a %s>%s</a>', $attrs, $label); } /** * Envoie le fichier au client HTTP */ public function serve(?Session $session = null, bool $download = false, ?string $share_hash = null, ?string $share_password = null): void { $can_access = $this->canRead(); if (!$can_access && $share_hash) { $can_access = $this->checkShareLink($share_hash, $share_password); if (!$can_access && $this->checkShareLinkRequiresPassword($share_hash)) { $tpl = Template::getInstance(); $has_password = (bool) $share_password; $tpl->assign(compact('can_access', 'has_password')); $tpl->display('ask_share_password.tpl'); return; } } if (!$can_access) { header('HTTP/1.1 403 Forbidden', true, 403); throw new UserException('Vous n\'avez pas accès à ce fichier.', 403); return; } // Only simple files can be served, not directories if ($this->type != self::TYPE_FILE) { header('HTTP/1.1 404 Not Found', true, 404); throw new UserException('Page non trouvée', 404); } $path = Files::callStorage('getFullPath', $this); $content = null === $path ? Files::callStorage('fetch', $this) : null; $this->_serve($path, $content, $download); if (in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_CONFIG])) { Web_Cache::link($this->uri(), $path); } } public function serveAuto(?Session $session = null, array $params = []): void { $found_sizes = array_intersect_key($params, self::ALLOWED_THUMB_SIZES); $size = key($found_sizes); if ($size && $this->image) { $this->serveThumbnail($session, $size); } else { $this->serve($session, isset($params['download'])); } } /** * Envoie une miniature à la taille indiquée au client HTTP */ public function serveThumbnail(?Session $session = null, string $size = null): void { if (!$this->canRead($session)) { header('HTTP/1.1 403 Forbidden', true, 403); throw new UserException('Accès interdit', 403); return; } if (!$this->image) { throw new UserException('Il n\'est pas possible de fournir une miniature pour un fichier qui n\'est pas une image.'); } |
︙ | ︙ | |||
768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 | } catch (\RuntimeException $e) { throw new UserException('Impossible de créer la miniature'); } } $this->_serve($destination, null); } /** * Servir un fichier local en HTTP * @param string $path Chemin vers le fichier local * @param string $type Type MIME du fichier * @param string $name Nom du fichier avec extension * @param integer $size Taille du fichier en octets (facultatif) */ protected function _serve(?string $path, ?string $content, bool $download = false): void { if ($this->isPublic()) { | > > > > | | 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 | } catch (\RuntimeException $e) { throw new UserException('Impossible de créer la miniature'); } } $this->_serve($destination, null); if (in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_CONFIG])) { Web_Cache::link($this->uri(), $destination, $size); } } /** * Servir un fichier local en HTTP * @param string $path Chemin vers le fichier local * @param string $type Type MIME du fichier * @param string $name Nom du fichier avec extension * @param integer $size Taille du fichier en octets (facultatif) */ protected function _serve(?string $path, ?string $content, bool $download = false): void { if ($this->isPublic()) { Utils::HTTPCache($this->etag(), $this->modified->getTimestamp()); } else { // Disable browser cache header('Pragma: private'); header('Expires: -1'); header('Cache-Control: private, must-revalidate, post-check=0, pre-check=0'); } |
︙ | ︙ | |||
807 808 809 810 811 812 813 | $type .= ';charset=utf-8'; } header(sprintf('Content-Type: %s', $type)); header(sprintf('Content-Disposition: %s; filename="%s"', $download ? 'attachment' : 'inline', $this->name)); // Utilisation de XSendFile si disponible | | < < < < < | < < < < < < | 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 | $type .= ';charset=utf-8'; } header(sprintf('Content-Type: %s', $type)); header(sprintf('Content-Disposition: %s; filename="%s"', $download ? 'attachment' : 'inline', $this->name)); // Utilisation de XSendFile si disponible if (null !== $path && Router::xSendFile($path)) { return; } // Désactiver gzip if (function_exists('apache_setenv')) { @apache_setenv('no-gzip', 1); } |
︙ | ︙ | |||
860 861 862 863 864 865 866 | return Files::callStorage('fetch', $this); } public function render(?string $user_prefix = null) { $editor_type = $this->renderFormat(); | | | | | | | > | | | < < | > | | < | < | | | < | | | > > > | > | | > > | | | | < < | > | < > > | | | < < < | > | | | > > > > > | | > > | | > | < < | | | | > | | > | > > | | | < | | > > | > > > | > | | | | | < | < < < > | > | > > | | | < | | > > > > | > > > | | > | | > > > > < < < < < < > > > > > > > > > > > > > > > > > > > > > > > | < < < > > > > > > > | > > > | > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 | return Files::callStorage('fetch', $this); } public function render(?string $user_prefix = null) { $editor_type = $this->renderFormat(); if ($editor_type == 'skriv' || $editor_type == 'markdown') { return Render::render($editor_type, $this, $this->fetch(), $user_prefix); } elseif ($editor_type == 'text') { return sprintf('<pre>%s</pre>', htmlspecialchars($this->fetch())); } else { throw new \LogicException('Cannot render file of this type'); } } public function canRead(Session $session = null): bool { // Web pages and config files are always public if ($this->isPublic()) { return true; } $session ??= Session::getInstance(); return $session->checkFilePermission($this->path, 'read'); } public function canShare(Session $session = null): bool { $session ??= Session::getInstance(); if (!$session->isLogged()) { return false; } return $session->checkFilePermission($this->path, 'share'); } public function canWrite(Session $session = null): bool { $session ??= Session::getInstance(); if (!$session->isLogged()) { return false; } return $session->checkFilePermission($this->path, 'write'); } public function canDelete(Session $session = null): bool { $session ??= Session::getInstance(); if (!$session->isLogged()) { return false; } return $session->checkFilePermission($this->path, 'delete'); } public function canMoveTo(string $destination, Session $session = null): bool { $session ??= Session::getInstance(); if (!$session->isLogged()) { return false; } return $session->checkFilePermission($this->path, 'move') && $this->canDelete() && self::canCreate($destination); } public function canCopyTo(string $destination, Session $session = null): bool { $session ??= Session::getInstance(); if (!$session->isLogged()) { return false; } return $this->canRead() && self::canCreate($destination); } public function canCreateDirHere(Session $session = null) { if (!$this->isDir()) { return false; } $session ??= Session::getInstance(); if (!$session->isLogged()) { return false; } return $session->checkFilePermission($this->path, 'mkdir'); } static public function canCreateDir(string $path, Session $session = null) { $session ??= Session::getInstance(); if (!$session->isLogged()) { return false; } return $session->checkFilePermission($path, 'mkdir'); } public function canCreateHere(Session $session = null): bool { if (!$this->isDir()) { return false; } $session ??= Session::getInstance(); if (!$session->isLogged()) { return false; } return $session->checkFilePermission($this->path, 'create'); } public function canRename(Session $session = null): bool { return $this->canCreate($this->parent, $session); } static public function canCreate(string $path, Session $session = null): bool { $session ??= Session::getInstance(); if (!$session->isLogged()) { return false; } return $session->checkFilePermission($path, 'create'); } public function pathHash(): string { return sha1($this->path); } public function isPublic(): bool { $context = $this->context(); if ($context == self::CONTEXT_MODULES || $context == self::CONTEXT_WEB) { return true; } if ($context == self::CONTEXT_CONFIG) { $file = array_search($this->path, Config::FILES); if ($file && in_array($file, Config::FILES_PUBLIC)) { return true; } } return false; } public function path_uri(): string { return rawurlencode($this->path); } static public function filterName(string $name): string { return preg_replace('/[^\w\d\p{L}_. -]+/iu', '-', trim($name)); } static public function validateFileName(string $name): void { if (0 === strpos($name, '.ht')) { throw new ValidationException('Nom de fichier interdit'); } if (strpos($name, "\0") !== false) { throw new ValidationException('Nom de fichier invalide'); } if (strlen($name) > 250) { throw new ValidationException('Nom de fichier trop long'); } $extension = strtolower(substr($name, strrpos($name, '.')+1)); if (preg_match(self::FORBIDDEN_EXTENSIONS, $extension)) { throw new ValidationException('Extension de fichier non autorisée, merci de renommer le fichier avant envoi.'); } } static public function validatePath(string $path): array { if (false != strpos($path, '..')) { throw new ValidationException('Chemin invalide: ' . $path); } $parts = explode('/', $path); if (count($parts) < 1) { throw new ValidationException('Chemin invalide: ' . $path); } $context = array_shift($parts); if (!array_key_exists($context, self::CONTEXTS_NAMES)) { throw new ValidationException('Chemin invalide: ' . $path); } $name = array_pop($parts); $ref = implode('/', $parts); return [$context, $ref ?: null, $name]; } /** * Only admins can create or rename files to .html / .js * This is to avoid XSS attacks from a non-admin user */ static public function validateCanHTML(string $name, string $path, ?Session $session = null): void { if (!preg_match('/\.(?:htm|js|xhtm)/', $name)) { return; } $session ??= Session::getInstance(); if (0 === strpos($path, self::CONTEXT_MODULES . '/web') && $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN)) { return; } if ($session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) { return; } throw new ValidationException('Seuls les administrateurs peuvent créer des fichiers de ce type.'); } public function renderFormat(): ?string { if (substr($this->name, -6) == '.skriv') { $format = Render::FORMAT_SKRIV; } elseif (substr($this->name, -3) == '.md') { $format = Render::FORMAT_MARKDOWN; } elseif (substr($this->mime, 0, 5) == 'text/' && $this->mime != 'text/html') { $format = 'text'; } else { $format = null; } return $format; } public function editorType(): ?string { static $text_extensions = ['css', 'txt', 'xml', 'html', 'htm', 'tpl']; $ext = substr($this->name, strrpos($this->name, '.') + 1); $format = $this->renderFormat(); if ($format == Render::FORMAT_SKRIV || $format == Render::FORMAT_MARKDOWN) { return 'web'; } elseif ($format == 'text' || in_array($ext, $text_extensions)) { return 'code'; } elseif (!WOPI_DISCOVERY_URL) { return null; } if ($this->getWopiURL()) { return 'wopi'; } return null; } public function getWopiURL(): ?string { if (!WOPI_DISCOVERY_URL) { return null; } $cache_file = SHARED_CACHE_ROOT . '/wopi.json'; static $data = null; if (null === $data) { // We are caching discovery for 15 days, there is no need to request the server all the time if (file_exists($cache_file) && filemtime($cache_file) >= 3600*24*15) { $data = json_decode(file_get_contents($cache_file), true); } if (!$data) { try { $data = WOPI::discover(WOPI_DISCOVERY_URL); file_put_contents($cache_file, json_encode($data)); } catch (\RuntimeException $e) { return null; } } } $ext = substr($this->name, strrpos($this->name, '.') + 1); $url = null; if (isset($data['extensions'][$ext]['edit'])) { $url = $data['extensions'][$ext]['edit']; } elseif (isset($data['mimetypes'][$this->mime]['edit'])) { $url = $data['mimetypes'][$this->mime]['edit']; } return $url; } public function editorHTML(bool $readonly = false): ?string { $url = $this->getWopiURL(); if (!$url) { return null; } $wopi = new WOPI; $url = $wopi->setEditorOptions($url, [ // Undocumented editor parameters // see https://github.com/nextcloud/richdocuments/blob/2338e2ff7078040d54fc0c70a96c8a1b860f43a0/src/helpers/url.js#L49 'lang' => 'fr', //'closebutton' => 1, //'revisionhistory' => 1, //'title' => 'Test', 'permission' => $readonly || !$this->canWrite() ? 'readonly' : '', ]); $wopi->setStorage(new Storage(Session::getInstance())); return $wopi->getEditorHTML($url, $this->path); } public function export(): array { return $this->asArray(true) + ['url' => $this->url()]; } /** * Returns a sharing link for a file, valid * @param int $expiry Expiry, in hours * @param string|null $password * @return string */ |
︙ | ︙ | |||
1139 1140 1141 1142 1143 1144 1145 | return false; } $hash_check = $this->_createShareHash($expiry, $password); return hash_equals($hash, $hash_check); } | | > > > > > > > > > > > > > > > > > > > | 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 | return false; } $hash_check = $this->_createShareHash($expiry, $password); return hash_equals($hash, $hash_check); } public function touch($date = null) { Files::callStorage('touch', $this->path, $date); } public function getReadOnlyPointer() { return Files::callStorage('getReadOnlyPointer', $this); } public function getRecursiveSize(): int { if ($this->type == self::TYPE_FILE) { return $this->size; } return Files::callStorage('getDirectorySize', $this->path); } } |
Added src/include/lib/Garradin/Entities/Module.php version [4d3800b17a].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | <?php namespace Garradin\Entities; use Garradin\Entity; use Garradin\DB; use Garradin\Plugins; use Garradin\Files\Files; use Garradin\UserTemplate\UserTemplate; use Garradin\Users\Session; use Garradin\Web\Cache; use Garradin\Entities\Files\File; use const Garradin\{ROOT, WWW_URL}; class Module extends Entity { const ROOT = File::CONTEXT_MODULES; const DIST_ROOT = ROOT . '/modules'; const META_FILE = 'module.ini'; const ICON_FILE = 'icon.svg'; const README_FILE = 'README.md'; const CONFIG_FILE = 'config.html'; const INDEX_FILE = 'index.html'; // Snippets, don't forget to create alias constant in UserTemplate\Modules class const SNIPPET_TRANSACTION = 'snippets/transaction_details.html'; const SNIPPET_USER = 'snippets/user_details.html'; const SNIPPET_HOME_BUTTON = 'snippets/home_button.html'; const SNIPPETS = [ self::SNIPPET_HOME_BUTTON => 'Icône sur la page d\'accueil', self::SNIPPET_USER => 'En bas de la fiche d\'un membre', self::SNIPPET_TRANSACTION => 'En bas de la fiche d\'une écriture', ]; const TABLE = 'modules'; protected ?int $id; /** * Directory name */ protected string $name; protected string $label; protected ?string $description; protected ?string $author; protected ?string $author_url; protected ?string $restrict_section; protected ?int $restrict_level; protected bool $home_button; protected bool $menu; protected ?\stdClass $config; protected bool $enabled; protected bool $web; public function selfCheck(): void { $this->assert(preg_match('/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/', $this->name), 'Nom unique de module invalide: ' . $this->name); $this->assert(trim($this->label) !== '', 'Le libellé ne peut rester vide'); } /** * Fills information from module.ini file */ public function updateFromINI(bool $use_local = true): bool { if ($use_local && ($file = Files::get($this->path(self::META_FILE)))) { $ini = $file->fetch(); } elseif (file_exists($this->distPath(self::META_FILE))) { $ini = file_get_contents($this->distPath(self::META_FILE)); } else { return false; } $ini = @parse_ini_string($ini, false, \INI_SCANNER_TYPED); if (empty($ini)) { return false; } $ini = (object) $ini; if (!isset($ini->name)) { return false; } $this->set('label', $ini->name); $this->set('description', $ini->description ?? null); $this->set('author', $ini->author ?? null); $this->set('author_url', $ini->author_url ?? null); $this->set('web', !empty($ini->web)); $this->set('home_button', !empty($ini->home_button)); $this->set('menu', !empty($ini->menu)); $this->set('restrict_section', $ini->restrict_section ?? null); $this->set('restrict_level', isset($ini->restrict_section, $ini->restrict_level, Session::ACCESS_WORDS[$ini->restrict_level]) ? Session::ACCESS_WORDS[$ini->restrict_level] : null); return true; } public function updateTemplates(): void { $check = self::SNIPPETS + [self::CONFIG_FILE => 'Config']; $templates = []; $db = DB::getInstance(); $db->begin(); $db->delete('modules_templates', 'id_module = ' . (int)$this->id()); foreach ($check as $file => $label) { if (Files::exists($this->path($file)) || file_exists($this->distPath($file))) { $templates[] = $file; $db->insert('modules_templates', ['id_module' => $this->id(), 'name' => $file]); } } $db->commit(); } public function icon_url(): ?string { if (!$this->hasFile(self::ICON_FILE)) { return null; } return $this->url(self::ICON_FILE); } public function path(string $file = null): string { return self::ROOT . '/' . $this->name . ($file ? '/' . $file : ''); } public function distPath(string $file = null): string { return self::DIST_ROOT . '/' . $this->name . ($file ? '/' . $file : ''); } public function dir(): ?File { return Files::get(self::ROOT . $this->name); } public function hasFile(string $file): bool { return $this->hasLocalFile($file) || $this->hasDistFile($file); } public function hasDist(): bool { return file_exists($this->distPath()); } public function hasLocal(): bool { return Files::exists($this->path()); } public function hasLocalFile(string $path): bool { return Files::exists($this->path($path)); } public function hasDistFile(string $path): bool { return file_exists($this->distPath($path)); } public function hasConfig(): bool { return DB::getInstance()->test('modules_templates', 'id_module = ? AND name = ?', $this->id(), self::CONFIG_FILE); } public function hasData(): bool { return DB::getInstance()->test('sqlite_master', 'type = \'table\' AND name = ?', sprintf('modules_data_%s', $this->name)); } public function canDelete(): bool { return !empty($this->config) || $this->hasLocal() || $this->hasData(); } public function delete(): bool { $dir = $this->dir(); if ($dir) { $dir->delete(); } DB::getInstance()->exec(sprintf('DROP TABLE IF EXISTS modules_data_%s', $this->name)); return parent::delete(); } public function url(string $file = '', array $params = null) { if (null !== $params) { $params = '?' . http_build_query($params); } return sprintf('%sm/%s/%s%s', WWW_URL, $this->name, $file, $params); } public function isValidPath(string $path): bool { return (bool) preg_match('!^(?:[\w\d_-]+/)*[\w\d_-]+(?:\.[\w\d_-]+)*$!i', $path); } public function validatePath(string $path): void { if (!$this->isValidPath($path)) { throw new \InvalidArgumentException('Invalid skeleton name'); } } public function template(string $file) { if ($file == self::CONFIG_FILE) { Session::getInstance()->requireAccess(Session::SECTION_CONFIG, Session::ACCESS_ADMIN); } $this->validatePath($file); $ut = new UserTemplate($this->name . '/' . $file); $ut->assign('module', array_merge($this->asArray(false), ['url' => $this->url()])); return $ut; } public function fetch(string $file, array $params): string { $ut = $this->template($file); $ut->assignArray($params); return $ut->fetch(); } public function serve(string $path, bool $has_local_file, array $params = []): void { if (UserTemplate::isTemplate($path)) { if ($this->web) { $this->serveWeb($path, $params); return; } else { $ut = $this->template($path); $ut->serve($params); } } // Serve a static file from a user module elseif ($has_local_file) { $file->serve(); } // Serve a static file (from "modules" in original source code) else { $type = $this->getFileTypeFromExtension($path); $real_path = $this->distPath($path); // Create symlink to static file Cache::link($path, $real_path); http_response_code(200); header(sprintf('Content-Type: %s;charset=utf-8', $type), true); readfile($real_path); flush(); } } public function serveWeb(string $path, array $params): void { $uri = $params['uri'] ?? null; // Fire signal before display of a web page $plugin_params = ['path' => $path, 'uri' => $uri, 'module' => $this]; if (Plugins::fireSignal('web.request.before', $plugin_params)) { return; } $type = null; $ut = $this->template($path); $ut->assignArray($params); extract($ut->fetchWithType()); if ($uri && preg_match('!html|xml|text!', $type) && $ut->get('nocache')) { $cache = true; } else { $cache = false; } $plugin_params['type'] = $type; $plugin_params['cache'] = $cache; // Call plugins, allowing them to modify the content if (Plugins::fireSignal('web.request', $plugin_params, $content)) { return; } header(sprintf('Content-Type: %s;charset=utf-8', $type), true); if ($type == 'application/pdf') { Utils::streamPDF($content); } else { echo $content; } if ($cache) { Web_Cache::store($uri, $content); } Plugins::fireSignal('web.request.after', $plugin_params, $content); } public function getFileTypeFromExtension(string $path): ?string { $dot = strrpos($path, '.'); // Templates with no extension are returned as HTML by default // unless {{:http type=...}} is used if ($dot === false) { return 'text/html'; } // Templates with no extension are returned as HTML by default // unless {{:http type=...}} is used if ($dot === false) { return 'text/html'; } $ext = substr($path, $dot+1); // Common types switch ($ext) { case 'txt': return 'text/plain'; case 'html': case 'htm': case 'tpl': case 'btpl': case 'skel': return 'text/html'; case 'xml': return 'text/xml'; case 'css': return 'text/css'; case 'js': return 'text/javascript'; case 'png': case 'gif': case 'webp': return 'image/' . $ext; case 'svg': return 'image/svg+xml'; case 'jpeg': case 'jpg': return 'image/jpeg'; default: return null; } } } |
Added src/include/lib/Garradin/Entities/Plugin.php version [4be69a9176].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 | <?php namespace Garradin\Entities; use Garradin\Entity; use Garradin\DB; use Garradin\Plugins; use Garradin\Template; use Garradin\UserException; use Garradin\Files\Files; use Garradin\UserTemplate\UserTemplate; use Garradin\Users\Session; use \KD2\HTML\Markdown; use Garradin\Entities\Files\File; use const Garradin\{PLUGINS_ROOT, WWW_URL, ROOT, ADMIN_URL}; class Plugin extends Entity { const META_FILE = 'plugin.ini'; const CONFIG_FILE = 'admin/config.php'; const INDEX_FILE = 'admin/index.php'; const ICON_FILE = 'admin/icon.svg'; const INSTALL_FILE = 'install.php'; const UPGRADE_FILE = 'upgrade.php'; const UNINSTALL_FILE = 'uninstall.php'; const README_FILE = 'admin/README.md'; const PROTECTED_FILES = [ self::META_FILE, self::INSTALL_FILE, self::UPGRADE_FILE, self::UNINSTALL_FILE, ]; const MIME_TYPES = [ 'css' => 'text/css', 'gif' => 'image/gif', 'htm' => 'text/html', 'html' => 'text/html', 'ico' => 'image/x-ico', 'jpe' => 'image/jpeg', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'js' => 'application/javascript', 'pdf' => 'application/pdf', 'png' => 'image/png', 'xml' => 'text/xml', 'svg' => 'image/svg+xml', 'webp' => 'image/webp', 'md' => 'text/x-markdown', ]; const TABLE = 'plugins'; protected ?int $id; /** * Directory name */ protected string $name; protected string $label; protected string $version; protected ?string $description; protected ?string $author; protected ?string $author_url; protected bool $home_button; protected bool $menu; protected ?string $restrict_section; protected ?int $restrict_level; protected ?\stdClass $config; protected bool $enabled; protected ?string $_broken_message = null; public function hasCode(): bool { return Plugins::exists($this->name); } public function selfCheck(): void { $this->assert(preg_match('/^' . Plugins::NAME_REGEXP . '$/', $this->name), 'Nom unique d\'extension invalide: ' . $this->name); $this->assert(isset($this->label) && trim($this->label) !== '', sprintf('%s : le nom de l\'extension ("name") ne peut rester vide', $this->name)); $this->assert(isset($this->label) && trim($this->version) !== '', sprintf('%s : la version ne peut rester vide', $this->name)); if ($this->hasCode() || $this->enabled) { $this->assert(!$this->menu || $this->hasFile(self::INDEX_FILE), 'Le fichier admin/index.php n\'existe pas alors que la directive "menu" est activée.'); $this->assert(!$this->home_button || $this->hasFile(self::INDEX_FILE), 'Le fichier admin/index.php n\'existe pas alors que la directive "home_button" est activée.'); $this->assert(!$this->home_button || $this->hasFile(self::ICON_FILE), 'Le fichier admin/icon.svg n\'existe pas alors que la directive "home_button" est activée.'); } } public function setBrokenMessage(string $str) { $this->_broken_message = $str; } public function getBrokenMessage(): ?string { return $this->_broken_message; } /** * Fills information from plugin.ini file */ public function updateFromINI(): bool { $ini = parse_ini_file($this->path(self::META_FILE), false, \INI_SCANNER_TYPED); if (empty($ini)) { return false; } $ini = (object) $ini; if (!isset($ini->name)) { return false; } $this->assert(empty($ini->min_version) || version_compare(\Garradin\garradin_version(), $ini->min_version, '>='), sprintf('L\'extension "%s" nécessite Paheko version %s ou supérieure.', $this->name, $ini->min_version)); $this->set('label', $ini->name); $this->set('version', $ini->version); $this->set('description', $ini->description ?? null); $this->set('author', $ini->author ?? null); $this->set('author_url', $ini->author_url ?? null); $this->set('home_button', !empty($ini->home_button)); $this->set('menu', !empty($ini->menu)); $this->set('restrict_section', $ini->restrict_section ?? null); $this->set('restrict_level', isset($ini->restrict_section, $ini->restrict_level, Session::ACCESS_WORDS[$ini->restrict_level]) ? Session::ACCESS_WORDS[$ini->restrict_level] : null); return true; } public function icon_url(): ?string { if (!$this->hasFile(self::ICON_FILE)) { return null; } return $this->url(self::ICON_FILE); } public function path(string $file = null): string { return Plugins::getPath($this->name) . ($file ? '/' . $file : ''); } public function hasFile(string $file): bool { return file_exists($this->path($file)); } public function hasConfig(): bool { return $this->hasFile(self::CONFIG_FILE); } public function url(string $file = '', array $params = null) { if (null !== $params) { $params = '?' . http_build_query($params); } if (substr($file, 0, 6) == 'admin/') { $url = ADMIN_URL; $file = substr($file, 6); } else { $url = WWW_URL; } return sprintf('%sp/%s/%s%s', $url, $this->name, $file, $params); } public function getConfig(string $key = null) { if (is_null($key)) { return $this->config; } if (property_exists($this->config, $key)) { return $this->config->$key; } return null; } public function setConfigProperty(string $key, $value = null) { if (null === $this->config) { $this->config = new \stdClass; } if (is_null($value)) { unset($this->config->$key); } else { $this->config->$key = $value; } $this->_modified['config'] = true; } public function setConfig(\stdClass $config) { $this->config = $config; $this->_modified['config'] = true; } /** * Associer un signal à un callback du plugin * @param string $signal Nom du signal (par exemple boucle.agenda pour la boucle de type AGENDA) * @param mixed $callback Callback, sous forme d'un nom de fonction ou de méthode statique * @return boolean TRUE */ public function registerSignal(string $signal, callable $callback): void { $callable_name = ''; if (!is_callable($callback, true, $callable_name) || !is_string($callable_name)) { throw new \LogicException('Le callback donné n\'est pas valide.'); } // pour empêcher d'appeler des méthodes de Garradin après un import de base de données "hackée" if (strpos($callable_name, 'Garradin\\Plugin\\') !== 0) { throw new \LogicException('Le callback donné n\'utilise pas le namespace Garradin\\Plugin : ' . $callable_name); } $db = DB::getInstance(); $callable_name = str_replace('Garradin\\Plugin\\', '', $callable_name); $db->preparedQuery('INSERT OR REPLACE INTO plugins_signals VALUES (?, ?, ?);', [$signal, $this->name, $callable_name]); } public function unregisterSignal(string $signal): void { DB::getInstance()->preparedQuery('DELETE FROM plugins_signals WHERE plugin = ? AND signal = ?;', [$this->name, $signal]); } public function delete(): bool { if ($this->hasFile(self::UNINSTALL_FILE)) { $this->call(self::UNINSTALL_FILE, true); } $db = DB::getInstance(); $db->delete('plugins_signals', 'plugin = ?', $this->name); return parent::delete(); } /** * Renvoie TRUE si le plugin a besoin d'être mis à jour * (si la version notée dans la DB est différente de la version notée dans paheko_plugin.ini) * @return boolean TRUE si le plugin doit être mis à jour, FALSE sinon */ public function needUpgrade(): bool { $infos = (object) parse_ini_file($this->path(self::META_FILE), false); if (version_compare($this->version, $infos->version, '!=')) { return true; } return false; } /** * Mettre à jour le plugin * Appelle le fichier upgrade.php dans l'archive si celui-ci existe. */ public function upgrade(): void { $this->updateFromINI(); if ($this->hasFile(self::UPGRADE_FILE)) { $this->call(self::UPGRADE_FILE, true); } $this->save(); } public function oldVersion(): ?string { return $this->getModifiedProperty('version'); } public function call(string $file, bool $allow_protected = false): void { $file = ltrim($file, './'); if (preg_match('!(?:\.\.|[/\\\\]\.|\.[/\\\\])!', $file)) { throw new \UnexpectedValueException('Chemin de fichier incorrect.'); } if (!$allow_protected && in_array($file, self::PROTECTED_FILES)) { throw new UserException('Le fichier ' . $file . ' ne peut être appelé par cette méthode.'); } $path = $this->path($file); if (!file_exists($path)) { throw new UserException(sprintf('Le fichier "%s" n\'existe pas dans le plugin "%s"', $file, $this->name)); } if (is_dir($path)) { throw new UserException(sprintf('Sécurité : impossible de lister le répertoire "%s" du plugin "%s".', $file, $this->name)); } $is_private = (0 === strpos($file, 'admin/')); // Créer l'environnement d'exécution du plugin if (substr($file, -4) === '.php') { if (substr($file, 0, 6) == 'admin/' || substr($file, 0, 7) == 'public/') { define('Garradin\PLUGIN_ROOT', $this->path()); define('Garradin\PLUGIN_URL', WWW_URL . 'p/' . $this->name . '/'); define('Garradin\PLUGIN_ADMIN_URL', WWW_URL .'admin/p/' . $this->name . '/'); define('Garradin\PLUGIN_QSP', '?'); $tpl = Template::getInstance(); if ($is_private) { require ROOT . '/www/admin/_inc.php'; $tpl->assign('current', 'plugin_' . $this->name); } $tpl->assign('plugin', $this); $tpl->assign('plugin_url', \Garradin\PLUGIN_URL); $tpl->assign('plugin_admin_url', \Garradin\PLUGIN_ADMIN_URL); $tpl->assign('plugin_root', \Garradin\PLUGIN_ROOT); } $plugin = $this; include $path; } elseif (substr($file, -3) === '.md' && $is_private) { $md = new Markdown; header('Content-Type: text/html'); printf('<!DOCYPE html><head> <style type="text/css">body { font-family: Verdana, sans-serif; padding: .5em; margin: 0; background: #fff; color: #000; }</style> <link rel="stylesheet" type="text/css" href="%scss.php" /></head><body>', ADMIN_URL); echo $md->text(file_get_contents($path)); } else { // Récupération du type MIME à partir de l'extension $pos = strrpos($path, '.'); $ext = substr($path, $pos+1); $mime = self::MIME_TYPES[$ext] ?? 'text/plain'; header('Content-Type: ' .$mime); header('Content-Length: ' . filesize($path)); header('Cache-Control: public, max-age=3600'); header('Last-Modified: ' . date(DATE_RFC7231, filemtime($path))); readfile($path); } } public function route(string $uri): void { $uri = ltrim($uri, '/'); if (0 === strpos($uri, 'admin/')) { if (!Session::getInstance()->isLogged()) { Utils::redirect('!login.php'); } } else { $uri = 'public/' . $uri; } if (!$uri || substr($uri, -1) == '/') { $uri .= 'index.php'; } try { $this->call($uri); } catch (\UnexpectedValueException $e) { http_response_code(404); throw new UserException($e->getMessage()); } } public function isAvailable(): bool { return $this->hasFile(self::META_FILE); } } |
Added src/include/lib/Garradin/Entities/Search.php version [3878b72b56].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | <?php namespace Garradin\Entities; use Garradin\AdvancedSearch; use Garradin\CSV; use Garradin\DB; use Garradin\DynamicList; use Garradin\Entity; use Garradin\UserException; use Garradin\Accounting\AdvancedSearch as Accounting_AdvancedSearch; use Garradin\Users\AdvancedSearch as Users_AdvancedSearch; use KD2\DB\DB_Exception; class Search extends Entity { const NAME = 'Recherche enregistrée'; const TABLE = 'searches'; const TYPE_JSON = 'json'; const TYPE_SQL = 'sql'; const TYPE_SQL_UNPROTECTED = 'sql_unprotected'; const TYPES = [ self::TYPE_JSON => 'Recherche avancée', self::TYPE_SQL => 'Recherche SQL', self::TYPE_SQL_UNPROTECTED => 'Recherche SQL non protégée', ]; const TARGET_USERS = 'users'; const TARGET_ACCOUNTING = 'accounting'; const TARGET_ALL = 'all'; const TARGETS = [ self::TARGET_USERS => 'Membres', self::TARGET_ACCOUNTING => 'Comptabilité', ]; protected ?int $id; protected ?int $id_user = null; protected string $label; protected \DateTime $created; protected string $target; protected string $type; protected string $content; protected $_result = null; protected $_as = null; public function selfCheck(): void { parent::selfCheck(); $this->assert(strlen('label') > 0, 'Le champ libellé doit être renseigné'); $this->assert(strlen('label') <= 500, 'Le champ libellé est trop long'); $db = DB::getInstance(); if ($this->id_user !== null) { $this->assert($db->test('users', 'id = ?', $data['id_user']), 'Numéro de membre inconnu'); } $this->assert(array_key_exists($this->type, self::TYPES)); $this->assert(array_key_exists($this->target, self::TARGETS)); $this->assert(strlen($this->content), 'Le contenu de la recherche ne peut être vide'); if ($this->type === self::TYPE_JSON) { $this->assert(json_decode($this->content) !== null, 'Recherche invalide pour le type JSON'); } } public function getDynamicList(): DynamicList { if ($this->type == self::TYPE_JSON) { return $this->getAdvancedSearch()->make($this->content); } else { throw new \LogicException('SQL search cannot be used as dynamic list'); } } public function getAdvancedSearch(): AdvancedSearch { if ($this->target == self::TARGET_ACCOUNTING) { $class = 'Garradin\Accounting\AdvancedSearch'; } else { $class = 'Garradin\Users\AdvancedSearch'; } if (null === $this->_as || !is_a($this->_as, $class)) { $this->_as = new $class; } return $this->_as; } public function transformToSQL() { if ($this->type != self::TYPE_JSON) { throw new \LogicException('Cannot transform a non-JSON search to SQL'); } $sql = $this->getDynamicList()->SQL(); // Remove indentation $sql = preg_replace('/^\s*/m', '', $sql); $this->set('content', $sql); $this->set('type', self::TYPE_SQL); } public function SQL(?int $force_limit = 100, ?array $force_select = null): string { if ($this->type == self::TYPE_JSON) { $sql = $this->getDynamicList()->SQL(); } else { $sql = $this->content; } $has_limit = preg_match('/LIMIT\s+\d+/i', $sql); // force LIMIT if ($force_limit && !$has_limit) { $sql = preg_replace('/;?\s*$/', '', $sql); $sql .= ' LIMIT ' . (int) $force_limit; } elseif (!$force_limit && $has_limit) { $sql = preg_replace('/LIMIT\s+.*;?\s*$/', '', $sql); } if ($force_select) { $sql = preg_replace('/^\s*SELECT\s+(.*?)\s+FROM\s+/Uis', 'SELECT $1, ' . implode(', ', $force_select) . ' FROM ', $sql); } $sql = trim($sql, "\n\r\t; "); return $sql; } /** * Returns a SQLite3Result for the current search */ public function query(?int $force_limit = 100, ?string $force_select = null): \SQLite3Result { if (null !== $this->_result) { return $this->_result; } $sql = $this->SQL($force_limit, $force_select); $allowed_tables = $this->getProtectedTables(); $db = DB::getInstance(); try { $db->toggleUnicodeLike(true); $st = $db->protectSelect($allowed_tables, $sql); $this->_result = $db->execute($st); return $this->_result; } catch (DB_Exception $e) { throw new UserException('Erreur dans la requête : ' . $e->getMessage(), 0, $e); } finally { $db->toggleUnicodeLike(false); } } public function getHeader(): array { $r = $this->query(); $columns = []; for ($i = 0; $i < $r->numColumns(); $i++) { $columns[] = $r->columnName($i); } return $columns; } public function iterateResults(): iterable { $r = $this->query(); while ($row = $r->fetchArray(\SQLITE3_NUM)) { yield $row; } } public function countResults(): int { $sql = $this->SQL(); $sql = preg_replace('/^\s*SELECT\s+(.*?)\s+FROM\s+/Uis', 'SELECT COUNT(*) FROM ', $sql); $allowed_tables = $this->getProtectedTables(); $db = DB::getInstance(); try { $db->toggleUnicodeLike(true); $st = $db->protectSelect($allowed_tables, $sql); $r = $db->execute($st); $count = (int) $r->fetchArray(\SQLITE3_NUM)[0] ?? 0; $r->finalize(); $st->close(); return $count; } catch (DB_Exception $e) { throw new UserException('Erreur dans la requête : ' . $e->getMessage(), 0, $e); } finally { $db->toggleUnicodeLike(false); } } public function export(string $format) { CSV::export($format, 'Recherche', $this->iterateResults(), $this->getHeader()); } public function schema(): array { $out = []; $db = DB::getInstance(); foreach ($this->getAdvancedSearch()->schemaTables() as $table => $comment) { $schema = $db->getTableSchema($table); $schema['comment'] = $comment; $out[$table] = $schema; } return $out; } public function getProtectedTables(): ?array { if ($this->type != self::TYPE_SQL || $this->target == self::TARGET_ALL) { return null; } $list = $this->getAdvancedSearch()->tables(); $tables = []; foreach ($list as $name) { $tables[$name] = null; } return $tables; } public function getGroups(): array { if ($this->type != self::TYPE_JSON) { throw new \LogicException('Only JSON searches can use this method'); } return json_decode($this->content, true)['groups']; } public function quick(string $query): DynamicList { $this->content = json_encode($this->getAdvancedSearch()->simple($query, false)); $this->type = self::TYPE_JSON; return $this->getDynamicList(); } } |
Modified src/include/lib/Garradin/Entities/Services/Fee.php from [7689846f7f] to [550e36a6a6].
1 2 3 4 | <?php namespace Garradin\Entities\Services; | < > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | <?php namespace Garradin\Entities\Services; use Garradin\DB; use Garradin\DynamicList; use Garradin\Entity; use Garradin\Form; use Garradin\ValidationException; use Garradin\Utils; use Garradin\Users\DynamicFields; use Garradin\Entities\Accounting\Account; use Garradin\Entities\Accounting\Project; use Garradin\Entities\Accounting\Year; use KD2\DB\EntityManager; use KD2\DB\DB_Exception; class Fee extends Entity { const NAME = 'Tarif'; const PRIVATE_URL = '!services/fees/details.php?id=%d'; const TABLE = 'services_fees'; protected ?int $id; protected string $label; protected ?string $description = null; protected ?int $amount = null; protected ?string $formula = null; |
︙ | ︙ | |||
39 40 41 42 43 44 45 | public function importForm(array $source = null) { if (null === $source) { $source = $_POST; } | | | | 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | public function importForm(array $source = null) { if (null === $source) { $source = $_POST; } if (isset($source['account'])) { $source['id_account'] = Form::getSelectorValue($source['account']); } if (isset($source['amount_type'])) { if ($source['amount_type'] == 2) { $source['amount'] = null; } elseif ($source['amount_type'] == 1) { |
︙ | ︙ | |||
104 105 106 107 108 109 110 | } return null; } protected function getFormulaSQL() { | | | > | | | | | | | | | 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 | } return null; } protected function getFormulaSQL() { return sprintf('SELECT %s FROM users WHERE id = ?;', $this->formula); } protected function checkFormula(): ?string { try { $db = DB::getInstance(); $sql = $this->getFormulaSQL(); $db->protectSelect(['users' => null], $sql); return null; } catch (DB_Exception $e) { return $e->getMessage(); } } public function service() { return EntityManager::findOneById(Service::class, $this->id_service); } public function allUsersList(): DynamicList { $identity = DynamicFields::getNameFieldsSQL('u'); $columns = [ 'id_user' => [ 'select' => 'su.id_user', ], 'user_number' => [ 'label' => 'Numéro de membre', 'select' => 'u.' . DynamicFields::getNumberField(), 'export_only' => true, ], 'identity' => [ 'label' => 'Membre', 'select' => $identity, ], 'paid' => [ 'label' => 'Payé ?', 'select' => 'su.paid', 'order' => 'su.paid %s, su.date %1$s', ], 'paid_amount' => [ 'label' => 'Montant payé', 'select' => 'SUM(l.credit)', ], 'date' => [ 'label' => 'Date', 'select' => 'su.date', ], ]; $tables = 'services_users su INNER JOIN users u ON u.id = su.id_user INNER JOIN services_fees sf ON sf.id = su.id_fee INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_fee) AS su2 ON su2.id = su.id LEFT JOIN acc_transactions_users tu ON tu.id_service_user = su.id LEFT JOIN acc_transactions_lines l ON l.id_transaction = tu.id_transaction'; $conditions = sprintf('su.id_fee = %d AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id()); $list = new DynamicList($columns, $tables, $conditions); $list->groupBy('su.id_user'); $list->orderBy('paid', true); $list->setCount('COUNT(DISTINCT su.id_user)'); $list->setExportCallback(function (&$row) { $row->paid_amount = $row->paid_amount ? Utils::money_format($row->paid_amount, '.', '', false) : null; }); return $list; } public function activeUsersList(): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('su.id_fee = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL) AND su.paid = 1 AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id()); $list->setConditions($conditions); return $list; } public function unpaidUsersList(): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('su.id_fee = %d AND su.paid = 0 AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id()); $list->setConditions($conditions); return $list; } public function expiredUsersList(): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('su.id_fee = %d AND su.expiry_date < date() AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id()); $list->setConditions($conditions); return $list; } public function getUsers(bool $paid_only = false): array { $where = $paid_only ? 'AND paid = 1' : ''; $id_field = Config::getInstance()->champ_identite; $sql = sprintf('SELECT su.id_user, u.%s FROM services_users su INNER JOIN membres u ON u.id = su.id_user WHERE su.id_fee = ? %s;', $id_field, $where); return DB::getInstance()->getAssoc($sql, $this->id()); } } |
Modified src/include/lib/Garradin/Entities/Services/Reminder.php from [52650ee9cf] to [b93b0af1a0].
1 2 3 4 5 6 7 | <?php namespace Garradin\Entities\Services; use Garradin\DynamicList; use Garradin\Entity; use Garradin\ValidationException; | | > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <?php namespace Garradin\Entities\Services; use Garradin\DynamicList; use Garradin\Entity; use Garradin\ValidationException; use Garradin\Users\DynamicFields; use KD2\DB\EntityManager; class Reminder extends Entity { const TABLE = 'services_reminders'; protected $id; |
︙ | ︙ | |||
62 63 64 65 66 67 68 | } parent::importForm($source); } public function sentList(): DynamicList { | | | | | 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | } parent::importForm($source); } public function sentList(): DynamicList { $id_field = DynamicFields::getNameFieldsSQL('u'); $columns = [ 'id_user' => [ 'select' => 'srs.id_user', ], 'identity' => [ 'label' => 'Membre', 'select' => $id_field, ], 'email' => [ 'label' => 'Adresse e-mail', 'select' => 'm.email', ], 'date' => [ 'label' => 'Date d\'envoi', 'select' => 'srs.sent_date', 'order' => 'srs.sent_date %s, srs.id %1$s', ], ]; $tables = 'services_reminders_sent srs INNER JOIN users u ON u.id = srs.id_user'; $conditions = sprintf('srs.id_reminder = %d', $this->id()); $list = new DynamicList($columns, $tables, $conditions); $list->orderBy('date', true); return $list; } } |
Modified src/include/lib/Garradin/Entities/Services/Service.php from [1019ca2d5b] to [e17dcfffdc].
1 2 3 4 | <?php namespace Garradin\Entities\Services; | < > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <?php namespace Garradin\Entities\Services; use Garradin\DB; use Garradin\DynamicList; use Garradin\Entity; use Garradin\ValidationException; use Garradin\Utils; use Garradin\Users\DynamicFields; use Garradin\Services\Fees; class Service extends Entity { const NAME = 'Activité'; const PRIVATE_URL = '!services/fees/?id=%d'; const TABLE = 'services'; protected $id; protected $label; protected $description; protected $duration; protected $start_date; |
︙ | ︙ | |||
67 68 69 70 71 72 73 | public function fees() { return new Fees($this->id()); } public function allUsersList(): DynamicList { | | | | | 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 | public function fees() { return new Fees($this->id()); } public function allUsersList(): DynamicList { $id_field = DynamicFields::getNameFieldsSQL('u'); $columns = [ 'id_user' => [ ], 'end_date' => [ ], 'user_number' => [ 'label' => 'Numéro de membre', 'select' => 'u.' . DynamicFields::getNumberField(), 'export_only' => true, ], 'identity' => [ 'label' => 'Membre', 'select' => $id_field, ], 'status' => [ 'label' => 'Statut', 'select' => 'CASE WHEN su.expiry_date < date() THEN -1 WHEN su.expiry_date >= date() THEN 1 ELSE 0 END', ], 'paid' => [ 'label' => 'Payé ?', |
︙ | ︙ | |||
106 107 108 109 110 111 112 | 'date' => [ 'label' => 'Date d\'inscription', 'select' => 'su.date', ], ]; $tables = 'services_users su | | | | | | | | | 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | 'date' => [ 'label' => 'Date d\'inscription', 'select' => 'su.date', ], ]; $tables = 'services_users su INNER JOIN users u ON u.id = su.id_user INNER JOIN services s ON s.id = su.id_service LEFT JOIN services_fees sf ON sf.id = su.id_fee INNER JOIN (SELECT id, MAX(date) FROM services_users GROUP BY id_user, id_service) AS su2 ON su2.id = su.id'; $conditions = sprintf('su.id_service = %d AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id()); $list = new DynamicList($columns, $tables, $conditions); $list->groupBy('su.id_user'); $list->orderBy('paid', true); $list->setCount('COUNT(DISTINCT su.id_user)'); return $list; } public function activeUsersList(): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('su.id_service = %d AND (su.expiry_date >= date() OR su.expiry_date IS NULL) AND su.paid = 1 AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id()); $list->setConditions($conditions); return $list; } public function unpaidUsersList(): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('su.id_service = %d AND su.paid = 0 AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id()); $list->setConditions($conditions); return $list; } public function expiredUsersList(): DynamicList { $list = $this->allUsersList(); $conditions = sprintf('su.id_service = %d AND su.expiry_date < date() AND u.id_category NOT IN (SELECT id FROM users_categories WHERE hidden = 1)', $this->id()); $list->setConditions($conditions); return $list; } public function getUsers(bool $paid_only = false) { $where = $paid_only ? 'AND paid = 1' : ''; $id_field = DynamicFields::getNameFieldsSQL('u'); $sql = sprintf('SELECT su.id_user, %s FROM services_users su INNER JOIN users u ON u.id = su.id_user WHERE su.id_service = ? %s;', $id_field, $where); return DB::getInstance()->getAssoc($sql, $this->id()); } public function long_label(): string { if ($this->duration) { $duration = sprintf('%d jours', $this->duration); |
︙ | ︙ |
Modified src/include/lib/Garradin/Entities/Services/Service_User.php from [b960ae24c8] to [dcc6fb2e78].
1 2 3 4 5 6 | <?php namespace Garradin\Entities\Services; use Garradin\DB; use Garradin\Entity; | | > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | <?php namespace Garradin\Entities\Services; use Garradin\DB; use Garradin\Entity; use Garradin\Form; use Garradin\ValidationException; use Garradin\Services\Fees; use Garradin\Services\Services; use Garradin\Users\Users; use Garradin\Accounting\Transactions; use Garradin\Entities\Accounting\Transaction; use Garradin\Entities\Accounting\Line; use KD2\DB\Date; class Service_User extends Entity |
︙ | ︙ | |||
33 34 35 36 37 38 39 | protected $_service, $_fee; public function selfCheck(): void { $this->assert($this->id_service, 'Aucune activité spécifiée'); $this->assert($this->id_user, 'Aucun membre spécifié'); | | > | 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | protected $_service, $_fee; public function selfCheck(): void { $this->assert($this->id_service, 'Aucune activité spécifiée'); $this->assert($this->id_user, 'Aucun membre spécifié'); $this->assert(!$this->isDuplicate(), 'Cette activité a déjà été enregistrée pour ce membre, ce tarif et cette date'); $db = DB::getInstance(); // don't allow an id_fee that does not match a service if (null !== $this->id_fee && !$db->test(Fee::TABLE, 'id = ? AND id_service = ?', $this->id_fee, $this->id_service)) { $this->set('id_fee', null); } } public function isDuplicate(bool $using_date = true): bool { if (!isset($this->id_user, $this->id_service)) { throw new \LogicException('Entity does not define either user or service'); } $params = [ 'id_user' => $this->id_user, 'id_service' => $this->id_service, 'id_fee' => $this->id_fee, ]; if ($using_date) { $params['date'] = $this->date->format('Y-m-d'); } $where = array_map(fn($k) => sprintf('%s = ?', $k), array_keys($params)); |
︙ | ︙ | |||
149 150 151 152 153 154 155 | throw new ValidationException('Le tarif indiqué ne possède pas d\'exercice lié'); } if (empty($source['amount'])) { throw new ValidationException('Montant non précisé'); } | > | > | | 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | throw new ValidationException('Le tarif indiqué ne possède pas d\'exercice lié'); } if (empty($source['amount'])) { throw new ValidationException('Montant non précisé'); } $account = Form::getSelectorValue($source['account_selector'] ?? null); if (!$account) { throw new ValidationException('Aucune compte n\'a été sélectionné.'); } $label = $this->service()->label; if ($this->fee()->label != $label) { $label .= ' - ' . $this->fee()->label; } $label .= sprintf(' (%s)', Users::getName($this->id_user)); $transaction = Transactions::create(array_merge($source, [ 'type' => Transaction::TYPE_REVENUE, 'label' => $label, 'id_project' => $source['id_project'] ?? $this->fee()->id_project, 'simple' => [Transaction::TYPE_REVENUE => [ 'credit' => [$this->fee()->id_account => null], |
︙ | ︙ |
Modified src/include/lib/Garradin/Entities/Users/Category.php from [2e075cd1f0] to [d1c87dba42].
1 2 3 4 | <?php namespace Garradin\Entities\Users; | > | | | | > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | <?php namespace Garradin\Entities\Users; use Garradin\Users\Session; use Garradin\Config; use Garradin\DB; use Garradin\Entity; use Garradin\UserException; use Garradin\Utils; class Category extends Entity { const NAME = 'Catégorie de membre'; const PRIVATE_URL = '!config/categories/edit.php?id=%d'; const TABLE = 'users_categories'; protected $id; protected $name; protected $hidden = 0; |
︙ | ︙ | |||
38 39 40 41 42 43 44 | 'perm_connect' => 'int', 'perm_config' => 'int', ]; const PERMISSIONS = [ 'connect' => [ 'label' => 'Les membres de cette catégorie peuvent-ils se connecter ?', | | | | | | | | | | | | | 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 | 'perm_connect' => 'int', 'perm_config' => 'int', ]; const PERMISSIONS = [ 'connect' => [ 'label' => 'Les membres de cette catégorie peuvent-ils se connecter ?', 'shape' => Utils::ICONS['login'], 'options' => [ Session::ACCESS_NONE => 'N\'a pas le droit de se connecter', Session::ACCESS_READ => 'A le droit de se connecter', ], ], 'users' => [ 'label' => 'Gestion des membres', 'shape' => Utils::ICONS['users'], 'options' => [ Session::ACCESS_NONE => 'Pas d\'accès', Session::ACCESS_READ => 'Lecture uniquement (peut voir les informations personnelles de tous les membres, y compris leurs inscriptions à des activités)', Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter et modifier des membres, mais pas les supprimer ni les changer de catégorie, peut inscrire des membres à des activités, peut envoyer des messages collectifs)', Session::ACCESS_ADMIN => 'Administration (peut tout faire)', ], ], 'accounting' => [ 'label' => 'Comptabilité', 'shape' => Utils::ICONS['money'], 'options' => [ Session::ACCESS_NONE => 'Pas d\'accès', Session::ACCESS_READ => 'Lecture uniquement (peut lire toutes les informations de tous les exercices)', Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter des écritures, mais pas les modifier ni les supprimer)', Session::ACCESS_ADMIN => 'Administration (peut tout faire)', ], ], 'documents' => [ 'label' => 'Documents', 'shape' => Utils::ICONS['folder'], 'options' => [ Session::ACCESS_NONE => 'Pas d\'accès', Session::ACCESS_READ => 'Lecture uniquement (peut lire tous les fichiers)', Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter, modifier et déplacer des fichiers, mais pas les supprimer)', Session::ACCESS_ADMIN => 'Administration (peut tout faire, notamment mettre des fichiers dans la corbeille)', ], ], 'web' => [ 'label' => 'Gestion du site web', 'shape' => Utils::ICONS['globe'], 'options' => [ Session::ACCESS_NONE => 'Pas d\'accès', Session::ACCESS_READ => 'Lecture uniquement (peut lire les pages)', Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter et modifier des pages et catégories, mais pas les supprimer)', Session::ACCESS_ADMIN => 'Administration (peut tout faire)', ], ], 'config' => [ 'label' => 'Les membres de cette catégorie peuvent-ils modifier la configuration ?', 'shape' => Utils::ICONS['settings'], 'options' => [ Session::ACCESS_NONE => 'Ne peut pas modifier la configuration', Session::ACCESS_ADMIN => 'Peut modifier la configuration', ], ], ]; |
︙ | ︙ | |||
111 112 113 114 115 116 117 | } public function delete(): bool { $db = DB::getInstance(); $config = Config::getInstance(); | | | | > > > > > > > > > > > | 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | } public function delete(): bool { $db = DB::getInstance(); $config = Config::getInstance(); if ($this->id() == $config->get('default_category')) { throw new UserException('Il est interdit de supprimer la catégorie définie par défaut dans la configuration.'); } if ($db->test('users', 'id_category = ?', $this->id())) { throw new UserException('La catégorie contient encore des membres, il n\'est pas possible de la supprimer.'); } return parent::delete(); } public function setAllPermissions(int $access): void { foreach (self::PERMISSIONS as $key => $perm) { // Restrict to the maximum access level, as some permissions only allow up to READ $perm_access = min($access, max(array_keys($perm['options']))); $this->set('perm_' . $key, $perm_access); } } public function getPermissions(): array { $out = []; foreach (self::PERMISSIONS as $key => $perm) { $out[$key] = $this->{'perm_' . $key}; } return $out; } } |
Added src/include/lib/Garradin/Entities/Users/DynamicField.php version [74af5ec509].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 | <?php declare(strict_types=1); namespace Garradin\Entities\Users; use Garradin\Config; use Garradin\DB; use Garradin\Entity; use Garradin\Utils; use Garradin\Entities\Files\File; use Garradin\Files\Files; use Garradin\Users\DynamicFields; use KD2\DB\Date; class DynamicField extends Entity { const NAME = 'Champ de fiche membre'; const TABLE = 'config_users_fields'; protected ?int $id; protected string $name; /** * Order of field in form * @var int */ protected int $sort_order; protected string $type; protected string $label; protected ?string $help; /** * TRUE if the field is required */ protected bool $required = false; /** * 0 = only admins can read this field (private) * 1 = admins + the user themselves can read it */ protected int $read_access = 0; /** * 0 = only admins can write this field * 1 = admins + the user themselves can change it */ protected int $write_access = 0; /** * Use in users list table? */ protected bool $list_table = false; /** * Multiple options (JSON) for select and multiple fields */ protected ?array $options = []; /** * Default value */ protected ?string $default_value; /** * SQL code for generated fields */ protected ?string $sql; /** * System use */ protected int $system = 0; const PASSWORD = 0x01 << 1; const LOGIN = 0x01 << 2; const NUMBER = 0x01 << 3; const NAMES = 0x01 << 4; const PRESET = 0x01 << 5; const ACCESS_ADMIN = 0; const ACCESS_USER = 1; const TYPES = [ 'email' => 'Adresse E-Mail', 'url' => 'Adresse URL', 'checkbox' => 'Case à cocher', 'date' => 'Date', 'datetime' => 'Date et heure', 'month' => 'Mois et année', 'year' => 'Année', 'file' => 'Fichier', 'password' => 'Mot de passe', 'number' => 'Nombre', 'tel' => 'Numéro de téléphone', 'select' => 'Sélecteur à choix unique', 'multiple' => 'Sélecteur à choix multiple', 'country' => 'Sélecteur de pays', 'text' => 'Texte', 'textarea' => 'Texte multi-lignes', 'generated' => 'Calculé', ]; const PHP_TYPES = [ 'email' => '?string', 'url' => '?string', 'checkbox' => 'bool', 'date' => '?' . Date::class, 'datetime' => '?DateTime', 'month' => '?string', 'year' => '?int', 'file' => '?string', 'password' => '?string', 'number' => '?int|float', 'tel' => '?string', 'select' => '?string', 'multiple' => 'int', 'country' => '?string', 'text' => '?string', 'textarea' => '?string', 'generated'=> 'dynamic', ]; const SQL_TYPES = [ 'email' => 'TEXT', 'url' => 'TEXT', 'checkbox' => 'INTEGER NOT NULL DEFAULT 0', 'date' => 'TEXT', 'datetime' => 'TEXT', 'month' => 'TEXT', 'year' => 'INTEGER', 'file' => 'TEXT', 'password' => 'TEXT', 'number' => 'INTEGER', 'tel' => 'TEXT', 'select' => 'TEXT', 'multiple' => 'INTEGER NOT NULL DEFAULT 0', 'country' => 'TEXT', 'text' => 'TEXT', 'textarea' => 'TEXT', 'generated'=> 'GENERATED', ]; const SEARCH_TYPES = [ 'email', 'text', 'textarea', 'url', ]; const LOGIN_FIELD_TYPES = [ 'email', 'url', 'text', 'number', 'tel', ]; const NAME_FIELD_TYPES = [ 'text', 'select', 'number', 'url', 'email', ]; const SQL_CONSTRAINTS = [ 'checkbox' => '%1s = 1 OR %1s = 0', 'date' => '%1s IS NULL OR (date(%1$s) IS NOT NULL AND date(%1s) = %1$s)', 'datetime' => '%1s IS NULL OR (date(%1$s) IS NOT NULL AND date(%1s) = %1$s)', 'month' => '%1s IS NULL OR (date(%1s || \'-03\') = %1$s || \'-03\')', // Use 3rd day to avoid any potential issue with timezones ]; const SYSTEM_FIELDS = [ 'id' => '?int', 'id_category' => 'int', 'pgp_key' => '?string', 'otp_secret' => '?string', 'date_login' => '?DateTime', 'date_updated' => '?DateTime', 'id_parent' => '?int', 'is_parent' => 'bool', 'preferences' => '?stdClass', ]; const SYSTEM_FIELDS_SQL = [ 'id INTEGER PRIMARY KEY,', 'id_category INTEGER NOT NULL REFERENCES users_categories(id),', 'date_login TEXT NULL CHECK (date_login IS NULL OR datetime(date_login) = date_login),', 'date_updated TEXT NULL CHECK (date_updated IS NULL OR datetime(date_updated) = date_updated),', 'otp_secret TEXT NULL,', 'pgp_key TEXT NULL,', 'id_parent INTEGER NULL REFERENCES users(id) ON DELETE SET NULL CHECK (id_parent IS NULL OR is_parent = 0),', 'is_parent INTEGER NOT NULL DEFAULT 0,', 'preferences TEXT NULL,' ]; public function delete(): bool { if (!$this->canDelete()) { throw new ValidationException('Ce champ est utilisé en interne, il n\'est pas possible de le supprimer'); } if ($this->type == 'file') { foreach (Files::glob(File::CONTEXT_USER . '/*/' . $this->name) as $file) { $file->delete(); } } return parent::delete(); } public function canSetDefaultValue(): bool { return in_array($this->type ?? null, ['text', 'textarea', 'number', 'select', 'multiple']); } public function isPreset(): bool { return (bool) ($this->system & self::PRESET); } public function isGenerated(): bool { return isset($this->type) && $this->type == 'generated'; } public function canDelete(): bool { if ($this->system & self::PASSWORD || $this->system & self::NUMBER || $this->system & self::NAMES || $this->system & self::LOGIN) { return false; } return true; } public function hasSearchCache(): bool { return in_array($this->type, DynamicField::SEARCH_TYPES); } public function selfCheck(): void { // Disallow name change if the field exists if ($this->exists()) { $this->assert(!$this->isModified('name')); $this->assert(!$this->isModified('type')); } $this->name = strtolower($this->name); $this->assert($this->read_access == self::ACCESS_ADMIN || $this->read_access == self::ACCESS_USER); $this->assert($this->write_access == self::ACCESS_ADMIN || $this->write_access == self::ACCESS_USER); $this->assert(!array_key_exists($this->name, self::SYSTEM_FIELDS), 'Ce nom de champ est déjà utilisé par un champ système, merci d\'en choisir un autre.'); $this->assert(preg_match('!^[a-z][a-z0-9]*(_[a-z0-9]+)*$!', $this->name), 'Le nom du champ est invalide : ne sont acceptés que les lettres minuscules et les chiffres (éventuellement séparés par un underscore).'); $this->assert(trim($this->label) != '', 'Le libellé est obligatoire.'); $this->assert($this->type && array_key_exists($this->type, self::TYPES), 'Type de champ invalide.'); if ($this->system & self::PASSWORD) { $this->assert($this->type == 'password', 'Le champ mot de passe ne peut être d\'un type différent de mot de passe.'); } $this->assert(!($this->type == 'multiple' || $this->type == 'select') || !empty($this->options), 'Le champ nécessite de comporter au moins une option possible.'); $db = DB::getInstance(); if (!$this->exists()) { $this->assert(!$db->test(self::TABLE, 'name = ?', $this->name), 'Ce nom de champ est déjà utilisé par un autre champ: ' . $this->name); } else { $this->assert(!$db->test(self::TABLE, 'name = ? AND id != ?', $this->name, $this->id()), 'Ce nom de champ est déjà utilisé par un autre champ.'); } if ($this->exists()) { $this->assert($this->system & self::PRESET || !array_key_exists($this->name, DynamicFields::getInstance()->getPresets()), 'Ce nom de champ est déjà utilisé par un champ pré-défini.'); } if (self::SQL_TYPES[$this->type] == 'GENERATED') { try { $db->protectSelect(['users' => []], sprintf('SELECT %s FROM users;', $this->sql)); } catch (\KD2\DB_Exception $e) { throw new ValidationException('Le code SQL du champ calculé est invalide: ' . $e->getMessage(), 0, $e); } } } public function importForm(array $source = null) { if (null === $source) { $source = $_POST; } $source['required'] = !empty($source['required']) ? true : false; $source['list_table'] = !empty($source['list_table']) ? true : false; return parent::importForm($source); } } |
Deleted src/include/lib/Garradin/Entities/Users/Email.php version [a217d30bd0].
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Added src/include/lib/Garradin/Entities/Users/User.php version [e06cd81d9d].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 | <?php declare(strict_types=1); namespace Garradin\Entities\Users; use KD2\DB\EntityManager; use Garradin\DB; use Garradin\Config; use Garradin\Entity; use Garradin\Form; use Garradin\Log; use Garradin\Utils; use Garradin\UserException; use Garradin\ValidationException; use Garradin\Files\Files; use Garradin\Users\Categories; use Garradin\Email\Emails; use Garradin\Email\Templates as EmailTemplates; use Garradin\Users\DynamicFields; use Garradin\Users\Session; use Garradin\Users\Users; use Garradin\Entities\Files\File; use KD2\SMTP; use KD2\DB\EntityManager as EM; /** * WARNING: do not use $user->property = 'value' to set a property value on this class * as they will not be saved using save(). Please use $user->set('property', 'value'). * * This is because dynamic properties are set as public, and __set is not called. * TODO: change to storing properties in an array */ #[\AllowDynamicProperties] class User extends Entity { const NAME = 'Membre'; const PRIVATE_URL = '!users/details.php?id=%d'; const MINIMUM_PASSWORD_LENGTH = 8; const TABLE = 'users'; const PREFERENCES = [ 'folders_gallery' => false, 'page_size' => 100, 'accounting_expert' => false, 'dark_theme' => false, ]; protected bool $_loading = false; public function __construct() { $this->reloadProperties(); parent::__construct(); } protected function reloadProperties(): void { if (empty(self::$_types_cache[static::class])) { $types = DynamicField::SYSTEM_FIELDS; $fields = DynamicFields::getInstance()->all(); foreach ($fields as $key => $config) { $types[$key] = DynamicField::PHP_TYPES[$config->type]; } self::$_types_cache[static::class] = $types; } $this->_types = self::$_types_cache[static::class]; $this->_loading = true; foreach ($this->_types as $key => $v) { if (!property_exists($this, $key)) { $this->$key = null; } } $this->_loading = false; } public function __wakeup(): void { $this->reloadProperties(); } public function set(string $key, $value, bool $loose = false, bool $check_for_changes = true) { if ($this->_loading && $value === null) { $this->$key = $value; return; } // Don't bother for type with generated columns // also don't set it as modified as we don't save the value if ($this->_types[$key] == 'dynamic') { $this->$key = $value; return; } // Filter double/triple spaces instead of double spaces, // to help users who try to log in, see https://fossil.kd2.org/paheko/info/c3295fe0af72e4b3 if (is_string($value) && false !== strpos($value, ' ') && DynamicFields::get($key)->type == 'text') { $value = preg_replace('![ ]{2,}!', ' ', $value); } return parent::set($key, $value, $loose, $check_for_changes); } public function selfCheck(): void { $this->assert(!empty($this->id_category), 'Aucune catégorie n\'a été sélectionnée.'); $df = DynamicFields::getInstance(); foreach ($df->all() as $field) { if (!$field->required) { continue; } $this->assert(null !== $this->{$field->name}, sprintf('"%s" : ce champ est requis', $field->label)); $this->assert('' !== trim((string)$this->{$field->name}), sprintf('"%s" : ce champ ne peut être vide', $field->label)); } // Check email addresses foreach (DynamicFields::getEmailFields() as $field) { $this->assert($this->$field === null || SMTP::checkEmailIsValid($this->$field, false), 'Cette adresse email n\'est pas valide.'); } // check user number $field = DynamicFields::getNumberField(); $this->assert($this->$field !== null && ctype_digit((string)$this->$field), 'Numéro de membre invalide : ne peut contenir que des chiffres'); $db = DB::getInstance(); if (!$this->exists()) { $number_exists = $db->test(self::TABLE, sprintf('%s = ?', $db->quoteIdentifier($field)), $this->$field); } else { $number_exists = $db->test(self::TABLE, sprintf('%s = ? AND id != ?', $db->quoteIdentifier($field)), $this->$field, $this->id()); } $this->assert(!$number_exists, 'Ce numéro de membre est déjà attribué à un autre membre.'); $field = DynamicFields::getLoginField(); if ($this->$field !== null) { if (!$this->exists()) { $login_exists = $db->test(self::TABLE, sprintf('%s = ? COLLATE NOCASE', $db->quoteIdentifier($field)), $this->$field); } else { $login_exists = $db->test(self::TABLE, sprintf('%s = ? COLLATE NOCASE AND id != ?', $db->quoteIdentifier($field)), $this->$field, $this->id()); } $this->assert(!$login_exists, sprintf('Le champ "%s" (utilisé comme identifiant de connexion) est déjà utilisé par un autre membre. Il doit être unique pour chaque membre.', $df->fieldByKey($field)->label)); } if ($this->id_parent !== null) { $this->assert(!$this->is_parent, 'Un membre ne peut être responsable et rattaché en même temps.'); $this->assert($this->id_parent > 0, 'Invalid parent ID'); $this->assert(!$this->exists() || $this->id_parent != $this->id(), 'Invalid parent ID'); $this->assert(!$db->test(self::TABLE, 'id = ? AND id_parent IS NOT NULL', $this->id_parent), 'Le membre sélectionné comme responsable est déjà rattaché à un autre membre.'); } } public function delete(): bool { $session = Session::getInstance(); if ($session->isLogged()) { $user = $session->getUser(); if ($user->id == $this->id) { throw new UserException('Il n\'est pas possible de supprimer son propre compte. Merci de demander à un autre administrateur de le faire.'); } } Files::delete($this->attachementsDirectory()); return parent::delete(); } public function asArray(bool $for_database = false): array { $out = parent::asArray($for_database); // Remove generated columns if ($for_database) { foreach (DynamicFields::getInstance()->all() as $field) { if ($field->type != 'generated') { continue; } unset($out[$field->name]); } } return $out; } public function save(bool $selfcheck = true): bool { if (!count($this->_modified) && $this->exists()) { return true; } $columns = array_intersect(DynamicFields::getInstance()->getSearchColumns(), array_keys($this->_modified)); $login_field = DynamicFields::getLoginField(); $login_modified = $this->_modified[$login_field] ?? null; $password_modified = $this->_modified['password'] ?? null; $this->set('date_updated', new \DateTime); parent::save($selfcheck); // We are not using a trigger as it would make modifying the users table from outside impossible // (because the transliterate_to_ascii function does not exist) if (count($columns)) { DynamicFields::getInstance()->rebuildUserSearchCache($this->id()); } if ($login_modified && $this->password) { EmailTemplates::loginChanged($this); Log::add(Log::LOGIN_CHANGE, null, $this->id()); } if ($password_modified && $this->password && $this->id == Session::getUserId()) { EmailTemplates::passwordChanged($this); } if ($password_modified) { Log::add(Log::LOGIN_PASSWORD_CHANGE, null, $this->id()); } return true; } public function category(): Category { return Categories::get($this->id_category); } public function attachementsDirectory(): string { return File::CONTEXT_USER . '/' . $this->id(); } public function listFiles(): array { $files = []; foreach (Files::listForContext(File::CONTEXT_USER, (string) $this->id()) as $dir) { foreach (Files::list($dir->path) as $file) { $files[] = $file; } } return $files; } public function number(): ?string { $field = DynamicFields::getNumberField(); return $this->$field; } public function setNumberIfEmpty(): void { $field = DynamicFields::getNumberField(); if ($this->$field) { return; } $new = DB::getInstance()->firstColumn(sprintf('SELECT MAX(%s) + 1 FROM %s;', $field, User::TABLE)); $this->set($field, $new); } public function name(): string { $out = []; foreach (DynamicFields::getNameFields() as $key) { $out[] = $this->$key; } return implode(' ', $out); } public function importForm(array $source = null) { if (null === $source) { $source = $_POST; } // Don't allow changing security credentials from form unset($source['id_category'], $source['password'], $source['otp_secret'], $source['pgp_key']); if (isset($source['id_parent']) && is_array($source['id_parent'])) { $source['id_parent'] = Form::getSelectorValue($source['id_parent']); } return parent::importForm($source); } public function importSecurityForm(bool $user_mode = true, array $source = null) { if (null === $source) { $source = $_POST; } $allowed = ['password', 'password_check', 'password_confirmed', 'password_delete', 'otp_secret', 'otp_disable', 'pgp_key', 'otp_code']; $source = array_intersect_key($source, array_flip($allowed)); $session = Session::getInstance(); if ($user_mode && !Session::getInstance()->checkPassword($source['password_check'] ?? null, $this->password)) { $this->assert( $session->checkPassword($source['password_check'] ?? null, $this->password), 'Le mot de passe fourni ne correspond pas au mot de passe actuel. Merci de bien vouloir renseigner votre mot de passe courant pour confirmer les changements.' ); } if (!empty($source['password_delete'])) { $source['password'] = null; } elseif (isset($source['password'])) { $source['password'] = trim($source['password']); // Maximum bcrypt password length $this->assert(strlen($source['password']) <= 72, sprintf('Le mot de passe doit faire moins de %d caractères.', 72)); $this->assert(strlen($source['password']) >= self::MINIMUM_PASSWORD_LENGTH, sprintf('Le mot de passe doit faire au moins %d caractères.', self::MINIMUM_PASSWORD_LENGTH)); $this->assert(hash_equals($source['password'], trim($source['password_confirmed'] ?? '')), 'La vérification du mot de passe doit être identique au mot de passe.'); $this->assert(!$session->isPasswordCompromised($source['password']), 'Le mot de passe choisi figure dans une liste de mots de passe compromis (piratés), il ne peut donc être utilisé ici. Si vous l\'avez utilisé sur d\'autres sites il est recommandé de le changer sur ces autres sites également.'); $source['password'] = $session::hashPassword($source['password']); } if (!empty($source['otp_disable'])) { $source['otp_secret'] = null; } elseif (isset($source['otp_secret'])) { $this->assert(trim($source['otp_code'] ?? '') !== '', 'Le code TOTP doit être renseigné pour confirmer l\'opération'); $this->assert($session->checkOTP($source['otp_secret'], $source['otp_code']), 'Le code TOTP entré n\'est pas valide.'); } if (!empty($source['pgp_key'])) { $this->assert($session->getPGPFingerprint($source['pgp_key']), 'Clé PGP invalide : impossible de récupérer l\'empreinte de la clé.'); } // Don't allow user to change password if the password field cannot be changed by user if ($user_mode && !$this->canChangePassword()) { unset($source['password'], $source['password_check']); } return parent::importForm($source); } public function getEmails(): array { $out = []; foreach (DynamicFields::getEmailFields() as $f) { if (trim($this->$f)) { $out[] = strtolower($this->$f); } } return $out; } public function canEmail(): bool { return count($this->getEmails()) > 0; } public function getNameAndEmail(): string { $email_field = DynamicFields::getFirstEmailField(); return sprintf('"%s" <%s>', $this->name(), $this->{$email_field}); } public function isChild(): bool { return (bool) $this->id_parent; } public function getParentName(): ?string { if (!$this->isChild()) { return null; } return Users::getName($this->id_parent); } public function getParentSelector(): ?array { if (!$this->isChild()) { return null; } return [$this->id_parent => $this->getParentName()]; } public function hasChildren(): bool { return DB::getInstance()->test(self::TABLE, 'id_parent = ?', $this->id()); } public function listChildren(): array { $name = DynamicFields::getNameFieldsSQL(); return DB::getInstance()->getGrouped(sprintf('SELECT id, %s AS name FROM %s WHERE id_parent = ?;', $name, self::TABLE), $this->id()); } public function listSiblings(): array { if (!$this->id_parent) { return []; } $name = DynamicFields::getNameFieldsSQL(); return DB::getInstance()->getGrouped(sprintf('SELECT id, %s AS name FROM %s WHERE id_parent = ? AND id != ?;', $name, self::TABLE), $this->id_parent, $this->id()); } public function sendMessage(string $subject, string $message, bool $send_copy, ?User $from = null) { $config = Config::getInstance(); $email_field = DynamicFields::getFirstEmailField(); $from = $from ? $from->getNameAndEmail() : null; Emails::queue(Emails::CONTEXT_PRIVATE, [['email' => $this->{$email_field}, 'pgp_key' => $this->pgp_key]], $from, $subject, $message); if ($send_copy) { Emails::queue(Emails::CONTEXT_PRIVATE, [['email' => $config->org_email, 'pgp_key' => $from->pgp_key]], null, $subject, $message); } } public function checkLoginFieldForUserEdit() { $session = Session::getInstance(); if (!$session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) { return; } $field = DynamicFields::getLoginField(); if (!$this->isModified($field)) { return; } if (trim($this->$field) !== '') { return; } throw new UserException("Le champ identifiant ne peut être laissé vide pour un administrateur, sinon vous ne pourriez plus vous connecter."); } public function canChangePassword(): bool { $password_field = current(DynamicFields::getInstance()->fieldsBySystemUse('password')); return $password_field->write_access == $password_field::ACCESS_USER; } public function checkDuplicate(): ?int { $id_field = DynamicFields::getNameFieldsSQL(); $db = DB::getInstance(); return $db->firstColumn(sprintf('SELECT id FROM %s WHERE %s = ?;', self::TABLE, $id_field), $this->name()) ?: null; } public function getPreference(string $key) { return $this->preferences->{$key} ?? null; } public function setPreference(string $key, $value): void { if (isset($this->$key)) { settype($value, gettype(self::PREFERENCES[$key])); } if (null === $this->preferences) { $this->preferences = new \stdClass; } $this->preferences->{$key} = $value; $this->_modified['preferences'] = null; } /** * Save preferences if they have been modified */ public function __destruct() { // We can't save preferences if user does not exist (eg. LDAP/Forced Login via LOCAL_LOGIN) if (!$this->exists()) { return; } // Nothing to save if (!$this->isModified('preferences')) { return; } DB::getInstance()->update(self::TABLE, ['preferences' => json_encode($this->preferences)], 'id = ' . $this->id()); $this->clearModifiedProperties(['preferences']); } } |
Modified src/include/lib/Garradin/Entities/Web/Page.php from [76f3315b12] to [e728797813].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | <?php namespace Garradin\Entities\Web; use Garradin\DB; use Garradin\Entity; use Garradin\UserException; use Garradin\Utils; use Garradin\Entities\Files\File; use Garradin\Files\Files; use Garradin\Web\Render\Render; use Garradin\Web\Web; use KD2\DB\EntityManager as EM; use const Garradin\WWW_URL; class Page extends Entity { const TABLE = 'web_pages'; | > > > > | | | | | | | | | | | | | < < < < < < < < < < < < < < < | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | <?php namespace Garradin\Entities\Web; use Garradin\DB; use Garradin\Entity; use Garradin\Form; use Garradin\UserException; use Garradin\Utils; use Garradin\Entities\Files\File; use Garradin\Files\Files; use Garradin\Web\Render\Render; use Garradin\Web\Web; use Garradin\Web\Cache; use KD2\DB\EntityManager as EM; use const Garradin\WWW_URL; class Page extends Entity { const NAME = 'Page du site web'; const TABLE = 'web_pages'; protected ?int $id; protected string $parent = ''; protected string $path; protected string $uri; protected string $_name = 'index.txt'; protected string $file_path; protected string $title; protected int $type; protected string $status; protected string $format; protected \DateTime $published; protected \DateTime $modified; protected string $content; const FORMATS_LIST = [ Render::FORMAT_MARKDOWN => 'MarkDown', Render::FORMAT_SKRIV => 'SkrivML', Render::FORMAT_ENCRYPTED => 'Chiffré', ]; |
︙ | ︙ | |||
94 95 96 97 98 99 100 101 102 103 104 105 106 107 | $db = DB::getInstance(); if ($db->test(self::TABLE, 'uri = ?', $page->uri)) { $page->importForm(['uri' => $page->uri . date('-Y-m-d-His')]); } $page->file_path = $page->filepath(false); return $page; } public function file(bool $force_reload = false) { if (null === $this->_file || $force_reload) { | > > | 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 | $db = DB::getInstance(); if ($db->test(self::TABLE, 'uri = ?', $page->uri)) { $page->importForm(['uri' => $page->uri . date('-Y-m-d-His')]); } $page->file_path = $page->filepath(false); Cache::clear(); return $page; } public function file(bool $force_reload = false) { if (null === $this->_file || $force_reload) { |
︙ | ︙ | |||
173 174 175 176 177 178 179 | $export = $this->export(); $exists = Files::callStorage('exists', $path); // Create file if required if (!$exists) { | | | | 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | $export = $this->export(); $exists = Files::callStorage('exists', $path); // Create file if required if (!$exists) { $file = $this->_file = Files::createFromString($path, $export); } else { $target = $this->filepath(false); // Move parent directory if needed if ($path !== $target) { $dir = Files::get(Utils::dirname($path)); $dir->rename(Utils::dirname($target)); $this->_file = null; } $file = $this->file(); // Or update file if ($file->fetch() !== $export) { $file->set('modified', $this->modified); $file->store(['content' => $export], false); } } $this->syncSearch(); } public function syncSearch(): void |
︙ | ︙ | |||
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 | parent = %1$s || substr(parent, %2$d), file_path = \'web/\' || %1$s || substr(file_path, %2$d + 4) WHERE path LIKE %3$s;', $db->quote($this->path), strlen($change_parent) + 1, $db->quote($change_parent . '/%')); $db->exec($sql); } return true; } public function delete(): bool { Files::get(Utils::dirname($this->file_path))->delete(); return parent::delete(); } public function selfCheck(): void { $db = DB::getInstance(); $this->assert($this->type === self::TYPE_CATEGORY || $this->type === self::TYPE_PAGE, 'Unknown page type'); | > > > | 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 | parent = %1$s || substr(parent, %2$d), file_path = \'web/\' || %1$s || substr(file_path, %2$d + 4) WHERE path LIKE %3$s;', $db->quote($this->path), strlen($change_parent) + 1, $db->quote($change_parent . '/%')); $db->exec($sql); } Cache::clear(); return true; } public function delete(): bool { Files::get(Utils::dirname($this->file_path))->delete(); Cache::clear(); return parent::delete(); } public function selfCheck(): void { $db = DB::getInstance(); $this->assert($this->type === self::TYPE_CATEGORY || $this->type === self::TYPE_PAGE, 'Unknown page type'); |
︙ | ︙ | |||
277 278 279 280 281 282 283 | if (isset($source['date']) && isset($source['date_time'])) { $source['published'] = $source['date'] . ' ' . $source['date_time']; } $parent = $this->parent; | | | < < < < < | < < < | | 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 | if (isset($source['date']) && isset($source['date_time'])) { $source['published'] = $source['date'] . ' ' . $source['date_time']; } $parent = $this->parent; if (isset($source['title']) && !$this->exists()) { $source['uri'] = $source['title']; } if (isset($source['uri'])) { $source['uri'] = Utils::transformTitleToURI($source['uri']); if (!$this->exists()) { $source['uri'] = strtolower($source['uri']); } $source['path'] = trim($parent . '/' . $source['uri'], '/'); } $uri = $source['uri'] ?? ($this->uri ?? null); if (array_key_exists('parent', $source)) { $source['parent'] = Form::getSelectorValue($source['parent']) ?: ''; $source['path'] = trim($source['parent'] . '/' . $uri, '/'); } if (!empty($source['encryption']) ) { $this->set('format', Render::FORMAT_ENCRYPTED); } elseif (empty($source['format'])) { $this->set('format', Render::FORMAT_MARKDOWN); |
︙ | ︙ | |||
555 556 557 558 559 560 561 | throw new \LogicException('Invalid page content: ' . $file->parent); } if (empty($this->modified)) { $this->set('modified', $file->modified); } | | > > > | 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 | throw new \LogicException('Invalid page content: ' . $file->parent); } if (empty($this->modified)) { $this->set('modified', $file->modified); } if (!isset($this->type) || $this->type != self::TYPE_CATEGORY) { $this->set('type', $this->checkRealType()); } else { $this->set('type', self::TYPE_CATEGORY); } } public function checkRealType(): int { // Make sure this is actually not a category foreach (Files::list(Utils::dirname($this->filepath())) as $subfile) { if ($subfile->type == File::TYPE_DIRECTORY) { |
︙ | ︙ |
Modified src/include/lib/Garradin/Entity.php from [4888bf94fb] to [f2feecdbe4].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <?php namespace Garradin; use Garradin\Form; use KD2\DB\AbstractEntity; use KD2\DB\Date; class Entity extends AbstractEntity { /** * Valider les champs avant enregistrement * @throws ValidationException Si une erreur de validation survient */ public function importForm(array $source = null) { if (null === $source) { | > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <?php namespace Garradin; use Garradin\Form; use KD2\DB\AbstractEntity; use KD2\DB\Date; class Entity extends AbstractEntity { /** * Entity name (eg. "Accounting transaction") * Entities with no name won't be stored in action logs */ const NAME = null; /** * Entity admin URL */ const PRIVATE_URL = null; /** * Valider les champs avant enregistrement * @throws ValidationException Si une erreur de validation survient */ public function importForm(array $source = null) { if (null === $source) { |
︙ | ︙ | |||
59 60 61 62 63 64 65 | } elseif ($value instanceof \DateTimeInterface) { return Date::createFromInterface($value); } return self::filterUserDateValue($value); } | | | 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 | } elseif ($value instanceof \DateTimeInterface) { return Date::createFromInterface($value); } return self::filterUserDateValue($value); } elseif ($type == 'DateTime' && is_string($value)) { if (preg_match('!^\d{2}/\d{2}/\d{4}\s\d{1,2}:\d{2}$!', $value)) { return \DateTime::createFromFormat('d/m/Y H:i', $value); } } return parent::filterUserValue($type, $value, $key); } |
︙ | ︙ | |||
93 94 95 96 97 98 99 100 | // Add plugin signals to save/delete public function save(bool $selfcheck = true): bool { $name = get_class($this); $name = str_replace('Garradin\Entities\\', '', $name); $name = 'entity.' . $name . '.save'; // Specific entity signal | > > > > > > > > | | | > > > > > > > | | | | | > > | | > > > > > | | | 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | // Add plugin signals to save/delete public function save(bool $selfcheck = true): bool { $name = get_class($this); $name = str_replace('Garradin\Entities\\', '', $name); $name = 'entity.' . $name . '.save'; // We are doing selfcheck here before sending the before event if ($selfcheck) { $this->selfCheck(); } $new = $this->exists() ? false : true; $modified = $this->isModified(); // Specific entity signal if (Plugins::fireSignal($name . '.before', ['entity' => $this, 'new' => $new])) { return true; } // Generic entity signal if (Plugins::fireSignal('entity.save.before', ['entity' => $this, 'new' => $new])) { return true; } $return = parent::save(false); // Log creation/edit, but don't record stuff that doesn't change anything if ($this::NAME && ($new || $modified)) { $type = str_replace('Garradin\Entities\\', '', get_class($this)); Log::add($new ? Log::CREATE : Log::EDIT, ['entity' => $type, 'id' => $this->id()]); } Plugins::fireSignal($name . '.after', ['entity' => $this, 'success' => $return, 'new' => $new]); Plugins::fireSignal('entity.save.after', ['entity' => $this, 'success' => $return, 'new' => $new]); return $return; } public function delete(): bool { $type = get_class($this); $type = str_replace('Garradin\Entities\\', '', $type); $name = 'entity.' . $type . '.delete'; $id = $this->id(); if (Plugins::fireSignal($name . '.before', ['entity' => $this, 'id' => $id])) { return true; } // Generic entity signal if (Plugins::fireSignal('entity.delete.before', ['entity' => $this, 'id' => $id])) { return true; } $return = parent::delete(); if ($this::NAME) { Log::add(Log::DELETE, ['entity' => $name, 'id' => $id]); } Plugins::fireSignal($name . '.after', ['entity' => $this, 'success' => $return, 'id' => $id]); Plugins::fireSignal('entity.delete.after', ['entity' => $this, 'success' => $return, 'id' => $id]); return $return; } } |
Modified src/include/lib/Garradin/Files/Files.php from [61c7409726] to [0438ab857a].
1 2 3 4 5 6 7 8 9 | <?php namespace Garradin\Files; use Garradin\Static_Cache; use Garradin\DB; use Garradin\Utils; use Garradin\UserException; use Garradin\ValidationException; | > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | <?php namespace Garradin\Files; use Garradin\Static_Cache; use Garradin\Config; use Garradin\DB; use Garradin\Plugins; use Garradin\Utils; use Garradin\UserException; use Garradin\ValidationException; use Garradin\Users\Session; use Garradin\Entities\Files\File; use Garradin\Entities\Web\Page; use KD2\DB\EntityManager as EM; use KD2\ZipWriter; use const Garradin\{FILE_STORAGE_BACKEND, FILE_STORAGE_QUOTA, FILE_STORAGE_CONFIG}; class Files { /** * To enable or disable quota check */ static protected $quota = true; static public function enableQuota(): void { self::$quota = true; } static public function disableQuota(): void { self::$quota = false; } static public function listContextsPermissions(Session $s): array { $perm = self::buildUserPermissions($s); $contexts = [ 'Fichiers de votre fiche de membre personnelle' => File::CONTEXT_USER . '/' . $s::getUserId() . '/', 'Documents de l\'association' => File::CONTEXT_DOCUMENTS, 'Fichiers des membres' => File::CONTEXT_USER . '//', 'Fichiers des écritures comptables' => File::CONTEXT_TRANSACTION . '//', 'Fichiers du site web (contenu des pages, images, etc.)' => File::CONTEXT_WEB . '//', 'Fichiers de la configuration (logo, etc.)' => File::CONTEXT_CONFIG, 'Code des modules' => File::CONTEXT_MODULES, ]; $out = []; foreach ($contexts as $name => $path) { $out[$name] = $perm[$path] ?? null; } return $out; } /** * Returns an array of all file permissions for a given user */ static public function buildUserPermissions(Session $s): array { $is_admin = $s->canAccess($s::SECTION_CONFIG, $s::ACCESS_ADMIN); $p = []; if ($s->isLogged() && $id = $s::getUserId()) { // The user can always access his own profile files $p[File::CONTEXT_USER . '/' . $s::getUserId() . '/'] = [ 'mkdir' => false, 'move' => false, 'create' => false, 'read' => true, 'write' => false, 'delete' => false, 'share' => false, ]; } // Subdirectories can be managed by member managemnt $p[File::CONTEXT_USER . '//'] = [ 'mkdir' => false, 'move' => false, 'create' => $s->canAccess($s::SECTION_USERS, $s::ACCESS_WRITE), 'read' => $s->canAccess($s::SECTION_USERS, $s::ACCESS_READ), 'write' => $s->canAccess($s::SECTION_USERS, $s::ACCESS_WRITE), 'delete' => $s->canAccess($s::SECTION_USERS, $s::ACCESS_WRITE), 'share' => false, ]; // Users can't do anything on the root though $p[File::CONTEXT_USER] = [ 'mkdir' => false, 'move' => false, 'create' => false, 'write' => false, 'delete' => false, 'read' => $s->canAccess($s::SECTION_USERS, $s::ACCESS_READ), 'share' => false, ]; $p[File::CONTEXT_CONFIG] = [ 'mkdir' => false, 'move' => false, 'create' => false, 'read' => $s->isLogged(), // All config files can be accessed by all logged-in users 'write' => $is_admin, 'delete' => false, 'share' => false, ]; // Modules source code $p[File::CONTEXT_MODULES] = [ 'mkdir' => $is_admin, 'move' => $is_admin, 'create' => $is_admin, 'read' => $s->isLogged(), 'write' => $is_admin, 'delete' => $is_admin, 'share' => false, ]; // Trash $p[File::CONTEXT_TRASH] = [ 'mkdir' => false, 'move' => $is_admin, 'create' => false, 'read' => $is_admin, 'write' => false, 'delete' => $is_admin, 'share' => false, ]; $p[File::CONTEXT_WEB . '//'] = [ 'mkdir' => false, 'move' => false, 'create' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE), 'read' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_READ), 'write' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE), 'delete' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE), 'share' => false, ]; // At root level of web you can only create new articles $p[File::CONTEXT_WEB] = [ 'mkdir' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_WRITE), 'move' => false, 'create' => false, 'read' => $s->canAccess($s::SECTION_WEB, $s::ACCESS_READ), 'write' => false, 'delete' => false, 'share' => false, ]; $p[File::CONTEXT_DOCUMENTS] = [ 'mkdir' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), 'move' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), 'create' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), 'read' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_READ), 'write' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), 'delete' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_ADMIN), 'share' => $s->canAccess($s::SECTION_DOCUMENTS, $s::ACCESS_WRITE), ]; // You can write in transaction subdirectories $p[File::CONTEXT_TRANSACTION . '//'] = [ 'mkdir' => false, 'move' => false, 'create' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE), 'read' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_READ), 'write' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE), 'delete' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_ADMIN), 'share' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_WRITE), ]; // But not in root $p[File::CONTEXT_TRANSACTION] = [ 'mkdir' => false, 'move' => false, 'write' => false, 'create' => false, 'delete' => false, 'read' => $s->canAccess($s::SECTION_ACCOUNTING, $s::ACCESS_READ), 'share' => false, ]; $p[''] = [ 'mkdir' => false, 'move' => false, 'write' => false, 'create' => false, 'delete' => false, 'read' => true, 'share' => false, ]; return $p; } static public function search(string $search, string $path = null): array { if (strlen($search) > 100) { throw new ValidationException('Recherche trop longue : maximum 100 caractères'); } |
︙ | ︙ | |||
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 | } $db->commit(); return $out; } static public function list(string $parent = ''): array { if ($parent !== '') { File::validatePath($parent); } $dir = self::get($parent); if ($dir && $dir->type != File::TYPE_DIRECTORY) { | > > > > > | > > > > | < > | < < | > > > > > > > > | | < > > > > | < | < < < < | < | < | < > > > > > > > > > > > > > > > > > > > > > > > | > > > | 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 | } $db->commit(); return $out; } /** * Returns a list of files and directories inside a parent path * This is not recursive and will only return files and directories * directly in the specified $parent path. */ static public function list(string $parent = ''): array { if ($parent !== '') { File::validatePath($parent); } $dir = self::get($parent); if ($dir && $dir->type != File::TYPE_DIRECTORY) { return [$dir]; } // Update this path return self::callStorage('list', $parent); } /** * Returns a list of files or directories matching a glob pattern * only * and ? characters are supported in pattern */ static public function glob(string $pattern): array { return self::callStorage('glob', $pattern); } /** * Creates a ZIP file archive from multiple paths * @param null|string $target Target file name, if left NULL, then will be sent to browser * @param array $paths List of paths to append to ZIP file * @param Session $session Logged-in user session, if set access rights to the path will be checked, * if left NULL, then no check will be made (!). */ static public function zip(?string $target, array $paths, ?Session $session, ?string $download_name = null): void { if (!$target) { $download_name ??= Config::getInstance()->org_name . ' - Documents'; header('Content-type: application/zip'); header(sprintf('Content-Disposition: attachment; filename="%s"', $download_name. '.zip')); $target = 'php://output'; } $zip = new ZipWriter($target); $zip->setCompression(0); foreach ($paths as $path) { foreach (Files::listRecursive($path, $session, false) as $file) { $zip->add($file->path, null, $file->fullpath()); } } $zip->close(); } static public function listRecursive(string $path, ?Session $session, bool $include_directories = true): \Generator { foreach (self::list($path) as $file) { if ($session && !$file->canRead($session)) { continue; } if ($file->isDir()) { yield from self::listRecursive($file->path, $session, $include_directories); if ($include_directories) { yield $file; } } else { yield $file; } } } /** * List files and directories inside a context (first-level directory) */ static public function listForContext(string $context, ?string $ref = null): array { $path = $context; if ($ref) { $path .= '/' . $ref; } return self::list($path); } /** * Delete a specified file/directory path */ static public function delete(string $path): void { $file = self::get($path); if (!$file) { return; } |
︙ | ︙ | |||
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | } call_user_func([$backend, 'truncate']); } static public function get(string $path, int $type = null): ?File { try { File::validatePath($path); } catch (ValidationException $e) { return null; } $file = self::callStorage('get', $path); if (!$file || ($type && $file->type != $type)) { return null; } return $file; } static public function getFromURI(string $uri): ?File { $uri = trim($uri, '/'); $uri = rawurldecode($uri); return self::get($uri, File::TYPE_FILE); } static public function getContext(string $path): ?string { | > > > > > > > > > > > > > > > > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > | 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 | } call_user_func([$backend, 'truncate']); } static public function get(string $path, int $type = null): ?File { // Root contexts always exist, same with root itself if ($path == '' || array_key_exists($path, File::CONTEXTS_NAMES)) { $file = new File; $file->parent = ''; $file->name = $path; $file->path = $path; $file->type = $file::TYPE_DIRECTORY; return $file; } try { File::validatePath($path); } catch (ValidationException $e) { return null; } $file = self::callStorage('get', $path); if (!$file || ($type && $file->type != $type)) { return null; } return $file; } static public function exists(string $path): bool { if (array_key_exists($path, File::CONTEXTS_NAMES)) { return true; } return self::callStorage('exists', $path); } static public function getFromURI(string $uri): ?File { $uri = trim($uri, '/'); $uri = rawurldecode($uri); return self::get($uri, File::TYPE_FILE); } static public function getContext(string $path): ?string { $pos = strpos($path, '/'); if (false === $pos) { return $path; } $context = substr($path, 0, $pos); if (!$context) { return null; } if (!array_key_exists($context, File::CONTEXTS_NAMES)) { return null; } return $context; } static public function isContextRoutable(string $path): bool { $context = self::getContext($path); if (!$context) { return false; } // Modules and trash files can never be served directly if ($context == File::CONTEXT_MODULES || $context == File::CONTEXT_TRASH) { return false; } return true; } static public function getContextRef(string $path): ?string { $context = strtok($path, '/'); return strtok('/') ?: null; } |
︙ | ︙ | |||
335 336 337 338 339 340 341 | $remaining = self::getRemainingQuota(true); if (($remaining - (float) $size) < 0) { throw new ValidationException('L\'espace disque est insuffisant pour réaliser cette opération'); } } | < < < < < < < < < < | 593 594 595 596 597 598 599 600 601 602 603 604 605 606 | $remaining = self::getRemainingQuota(true); if (($remaining - (float) $size) < 0) { throw new ValidationException('L\'espace disque est insuffisant pour réaliser cette opération'); } } static public function getVirtualTableName(): string { if (FILE_STORAGE_BACKEND == 'SQLite') { return 'files'; } return 'tmp_files'; |
︙ | ︙ | |||
381 382 383 384 385 386 387 | if ($recursive && $file->type === $file::TYPE_DIRECTORY) { self::syncVirtualTable($file->path, $recursive); } } $db->commit(); } | | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 | if ($recursive && $file->type === $file::TYPE_DIRECTORY) { self::syncVirtualTable($file->path, $recursive); } } $db->commit(); } static protected function create(string $parent, string $name, array $source): File { if (!isset($source['path']) && !isset($source['content']) && !isset($source['pointer'])) { throw new \InvalidArgumentException('Unknown source type'); } elseif (count($source) != 1) { throw new \InvalidArgumentException('Invalid source type'); } $pointer = $path = $content = null; extract($source); File::validateFileName($name); File::validatePath($parent); File::validateCanHTML($name, $parent); self::ensureDirectoryExists($parent); $name = File::filterName($name); $finfo = \finfo_open(\FILEINFO_MIME_TYPE); $target = $parent . '/' . $name; $file = Files::callStorage('get', $target) ?? new File; $file->path = $target; $file->parent = $parent; $file->name = $name; if ($pointer) { if (0 !== fseek($pointer, 0, SEEK_END)) { throw new \RuntimeException('Stream is not seekable'); } $file->set('size', ftell($pointer)); fseek($pointer, 0, SEEK_SET); $file->set('mime', mime_content_type($pointer)); } elseif ($path) { $file->set('mime', finfo_file($finfo, $path)); $file->set('size', filesize($path)); $file->set('modified', new \DateTime('@' . filemtime($path))); } else { $file->set('size', strlen($content)); $file->set('mime', finfo_buffer($finfo, $content)); } $file->set('image', in_array($file->mime, $file::IMAGE_TYPES)); // Force empty files as text/plain if ($file->mime == 'application/x-empty' && !$file->size) { $file->set('mime', 'text/plain'); } return $file; } static public function createDocument(string $parent, string $name, string $extension): File { // From https://github.com/nextcloud/richdocuments/tree/2338e2ff7078040d54fc0c70a96c8a1b860f43a0/emptyTemplates // We need to copy an empty template, or Collabora will create flat-XML file if ($extension == 'ods') { $tpl = 'UEsDBBQAAAAAAOw6wVCFbDmKLgAAAC4AAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnNwcmVhZHNoZWV0UEsDBBQAAAAIABxZFFFL43PrmgAAAEABAAAVAAAATUVUQS1JTkYvbWFuaWZlc3QueG1slVDRDoMgDHz3KwjvwvZK1H9poEYSKETqon8vLpluWfawPrXXy921XQTyIxY2r0asMVA5x14uM5kExRdDELEYtiZlJJfsEpHYfPLNXd2kGBpRqzvB0QdsK3nexIUtIbQZeOqllhcc0XloecvYS8g5eAvsE+kHOfWMod7dVckzgisTIkv9p61NxIdGveBHAMaV9bGu0p3++tXQ7FBLAwQUAAAACAAAWRRRA4GGVIkAAAD/AAAACwAAAGNvbnRlbnQueG1sXY/RCsIwDEWf9SvG3uv0Ncz9S01TLLTNWFJwf29xbljzEu49N1wysvcBCRxjSZTVIGetu3ulmAU2eu/LkoGtBIFsEwkoAs+U9yv4TcPtcu2nc1dn/DqCS5hVuqG1fe0y3iIZRxg/+LQzW5ST1YBGdI3Uwge7tcpDy7yQdfIk0i03NMFD/n85vQFQSwECFAMUAAAAAADsOsFQhWw5ii4AAAAuAAAACAAAAAAAAAAAAAAAtIEAAAAAbWltZXR5cGVQSwECFAMUAAAACAAcWRRRS+Nz65oAAABAAQAAFQAAAAAAAAAAAAAAtIFUAAAATUVUQS1JTkYvbWFuaWZlc3QueG1sUEsBAhQDFAAAAAgAAFkUUQOBhlSJAAAA/wAAAAsAAAAAAAAAAAAAALSBIQEAAGNvbnRlbnQueG1sUEsFBgAAAAADAAMAsgAAANMBAAAAAA=='; } elseif ($extension == 'odp') { $tpl = 'UEsDBBQAAAAAAC6dVEszJqyoLwAAAC8AAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnByZXNlbnRhdGlvblBLAwQUAAAACAAsYRRRP7fJFJoAAABBAQAAFQAAAE1FVEEtSU5GL21hbmlmZXN0LnhtbJVQwQqDMAy97ytK77bbNaj/EmpkhTYtNg79+1VhujF2WC5JXh7vJWkjsh+pCLwKtcTA5Wg7PU8MCYsvwBipgDhImXhIbo7EAp98uJmrVv1F1WgPcPSBmkqeVnVicwhNRrl32uoTjjR4bGTN1GnMOXiH4hPbBw9mX8O8u5s8Ual552j7p69LLJtIPeHHBkKL2G1cpVv79az+8gRQSwMEFAAAAAgAMl4UUXz4vRWJAAAA/gAAAAsAAABjb250ZW50LnhtbF2P0QqDMAxFn+dXiO+d22tw/ksXUyjYpJgI8+8tOGVdXsK994Qkg4QQkWASXBOxORS20ttPmlnhSF/dujCI16jAPpGCIUgmPqfgl4bn/dGNTVtq+DqKS8ymbT82t9MLZZELHslNhHOd+dUkeYvo1LaZ6vAt01bkpfNCWm4ouPAB9hV5yf8fx2YHUEsBAhQDFAAAAAAALp1USzMmrKgvAAAALwAAAAgAAAAAAAAAAAAAALSBAAAAAG1pbWV0eXBlUEsBAhQDFAAAAAgALGEUUT+3yRSaAAAAQQEAABUAAAAAAAAAAAAAALSBVQAAAE1FVEEtSU5GL21hbmlmZXN0LnhtbFBLAQIUAxQAAAAIADJeFFF8+L0ViQAAAP4AAAALAAAAAAAAAAAAAAC0gSIBAABjb250ZW50LnhtbFBLBQYAAAAAAwADALIAAADUAQAAAAA='; } else { $extension = 'odt'; $tpl = 'UEsDBBQAAAAAAPMbH0texjIMJwAAACcAAAAIAAAAbWltZXR5cGVhcHBsaWNhdGlvbi92bmQub2FzaXMub3BlbmRvY3VtZW50LnRleHRQSwMEFAAAAAgA3U0SUeqX5meSAAAAMQEAABUAAABNRVRBLUlORi9tYW5pZmVzdC54bWyVUEEOgzAMu+8VqHfa7Rq1/CUqQavUphUNE/wemDTYNO2wW2I7thWbkMNAVeA1NHOKXI/VqWlkyFhDBcZEFcRDLsR99lMiFvjUw01fVXdp7AEMIVK7CcelObEpxrag3J0y6oQT9QFbWQo5haXE4FFCZvPgXj8r6PdkLTSLMv+E+cyyX26df8TunmanN19rvr7TrVBLAwQUAAAACACQThJRWmJBaH8AAADjAAAACwAAAGNvbnRlbnQueG1sXY/RCsMgDEXf+xWj767ba+j8FxcjCGpKE6H9+wlbRfYUbs69uWTlECISeMaaqahBLtrm7cipCHzpa657AXYSBYrLJKAIvFG5UjC64Xl/zHZaf0pwj5vKYq9FaA0mOCTjCdMAXFXOTiMa0TNRI/3Im/3ZfUqHttQysqnL/0/sB1BLAQIUAxQAAAAAAPMbH0texjIMJwAAACcAAAAIAAAAAAAAAAAAAACkgQAAAABtaW1ldHlwZVBLAQIUAxQAAAAIAN1NElHql+ZnkgAAADEBAAAVAAAAAAAAAAAAAACkgU0AAABNRVRBLUlORi9tYW5pZmVzdC54bWxQSwECFAMUAAAACACQThJRWmJBaH8AAADjAAAACwAAAAAAAAAAAAAApIESAQAAY29udGVudC54bWxQSwUGAAAAAAMAAwCyAAAAugEAAAAA'; } return Files::createFromString($parent . '/' . $name . '.' . $extension, base64_decode($tpl)); } static protected function createFrom(string $target, array $source): File { $parent = Utils::dirname($target); $name = Utils::basename($target); $file = self::create($parent, $name, $source); $file->store($source); return $file; } /** * Create and store a file from a local path * @param string $target Target parent path + name * @param string $path Source file path * @return File */ static public function createFromPath(string $target, string $path): File { return self::createFrom($target, compact('path')); } /** * Create and store a file from a string * @param string $target Target parent path + name * @param string $content Source file contents * @return File */ static public function createFromString(string $target, string $content): File { return self::createFrom($target, compact('content')); } static public function createFromPointer(string $target, $pointer): File { return self::createFrom($target, compact('pointer')); } /** * Upload multiple files * @param string $parent Target parent directory (eg. 'documents/Logos') * @param string $key The name of the file input in the HTML form (this MUST have a '[]' at the end of the name) * @return array list of File objects created */ static public function uploadMultiple(string $parent, string $key): array { if (!isset($_FILES[$key]['name'][0])) { throw new UserException('Aucun fichier reçu'); } // Transpose array // see https://www.php.net/manual/en/features.file-upload.multiple.php#53240 $files = Utils::array_transpose($_FILES[$key]); $out = []; // First check all files foreach ($files as $file) { if (!empty($file['error'])) { throw new UserException(self::getUploadErrorMessage($file['error'])); } if (empty($file['size']) || empty($file['name'])) { throw new UserException('Fichier reçu invalide : vide ou sans nom de fichier.'); } if (!is_uploaded_file($file['tmp_name'])) { throw new \RuntimeException('Le fichier n\'a pas été envoyé de manière conventionnelle.'); } } // Then create files foreach ($files as $file) { $name = File::filterName($file['name']); $out[] = self::createFromPath($parent . '/' . $name, $file['tmp_name']); } return $out; } /** * Upload a file using POST from a HTML form * @param string $parent Target parent directory (eg. 'documents/Logos') * @param string $key The name of the file input in the HTML form * @return self Created file object */ static public function upload(string $parent, string $key, ?string $name = null): File { if (!isset($_FILES[$key]) || !is_array($_FILES[$key])) { throw new UserException('Aucun fichier reçu'); } $file = $_FILES[$key]; if (!empty($file['error'])) { throw new UserException(self::getUploadErrorMessage($file['error'])); } if (empty($file['size']) || empty($file['name'])) { throw new UserException('Fichier reçu invalide : vide ou sans nom de fichier.'); } if (!is_uploaded_file($file['tmp_name'])) { throw new \RuntimeException('Le fichier n\'a pas été envoyé de manière conventionnelle.'); } $name = File::filterName($name ?? $file['name']); return self::createFromPath($parent . '/' . $name, $file['tmp_name']); } /** * Récupération du message d'erreur * @param integer $error Code erreur du $_FILE * @return string Message d'erreur */ static public function getUploadErrorMessage($error) { switch ($error) { case UPLOAD_ERR_INI_SIZE: return 'Le fichier excède la taille permise par la configuration.'; case UPLOAD_ERR_FORM_SIZE: return 'Le fichier excède la taille permise par le formulaire.'; case UPLOAD_ERR_PARTIAL: return 'L\'envoi du fichier a été interrompu.'; case UPLOAD_ERR_NO_FILE: return 'Aucun fichier n\'a été reçu.'; case UPLOAD_ERR_NO_TMP_DIR: return 'Pas de répertoire temporaire pour stocker le fichier.'; case UPLOAD_ERR_CANT_WRITE: return 'Impossible d\'écrire le fichier sur le disque du serveur.'; case UPLOAD_ERR_EXTENSION: return 'Une extension du serveur a interrompu l\'envoi du fichier.'; default: return 'Erreur inconnue: ' . $error; } } /** * Create a new directory * @param string $parent Target parent path * @param string $name Target name * @param bool $create_parent Create parent directories if they don't exist * @return self */ static public function mkdir(string $path, bool $create_parent = true): File { $path = trim($path, '/'); $parent = Utils::dirname($path); $name = Utils::basename($path); $name = File::filterName($name); $path = $parent . '/' . $name; File::validatePath($path); Files::checkQuota(); if (self::exists($path)) { throw new ValidationException('Le nom de répertoire choisi existe déjà: ' . $path); } if ($parent !== '' && $create_parent) { self::ensureDirectoryExists($parent); } $file = new File; $type = $file::TYPE_DIRECTORY; $file->import(compact('path', 'name', 'parent') + [ 'type' => file::TYPE_DIRECTORY, 'image' => false, ]); $file->modified = new \DateTime; Files::callStorage('mkdir', $file); Plugins::fireSignal('files.mkdir', compact('file')); return $file; } static public function ensureDirectoryExists(string $path): void { $db = DB::getInstance(); $parts = explode('/', $path); $tree = ''; foreach ($parts as $part) { $tree = trim($tree . '/' . $part, '/'); $exists = $db->test(File::TABLE, 'type = ? AND path = ?', File::TYPE_DIRECTORY, $tree); if (!$exists) { try { self::mkdir($tree, false); } catch (ValidationException $e) { // Ignore when directory already exists } } } } /** * Return list of context that can be read by currently logged user */ static public function listReadAccessContexts(?Session $session): array { if (!$session->isLogged()) { return []; } $list = []; if ($session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN)) { $access[] = File::CONTEXT_CONFIG; $access[] = File::CONTEXT_MODULES; } if ($session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)) { $access[] = File::CONTEXT_TRANSACTION; } if ($session->canAccess($session::SECTION_USERS, $session::ACCESS_READ)) { $access[] = File::CONTEXT_USER; } if ($session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)) { $access[] = File::CONTEXT_DOCUMENTS; } if ($session->canAccess($session::SECTION_WEB, $session::ACCESS_READ)) { $access[] = File::CONTEXT_WEB; } return array_intersect_key(File::CONTEXTS_NAMES, array_flip($access)); } } |
Modified src/include/lib/Garradin/Files/Storage/FileSystem.php from [d336ee3df1] to [e490f9d582].
︙ | ︙ | |||
75 76 77 78 79 80 81 82 83 84 85 86 87 | if ($return) { touch($target, $file->modified->getTimestamp()); } return $return; } static public function mkdir(File $file): bool { return Utils::safe_mkdir(self::getFullPath($file)); } | > > > > > > > > > > > > > > > > > > | > > > > | < | < < < < | | | | < > > > > > | 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 | if ($return) { touch($target, $file->modified->getTimestamp()); } return $return; } static public function storePointer(File $file, $pointer): bool { $target = self::getFullPath($file); self::ensureDirectoryExists(dirname($target)); $fp = fopen($target, 'w'); while (!feof($pointer)) { fwrite($fp, fread($pointer, 8192)); } fclose($fp); touch($target, $file->modified->getTimestamp()); return true; } static public function mkdir(File $file): bool { return Utils::safe_mkdir(self::getFullPath($file)); } static public function touch(string $path, $date = null): bool { if ($date instanceof \DateTimeInterface) { $date = $date->getTimestamp(); } return touch(self::_getRealPath($path), $date ?: null); } static protected function _getRealPath(string $path): ?string { if (substr(trim($path, '/'), 0, 1) == '.') { return null; } return self::_getRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path); } static public function getFullPath(File $file): ?string { return self::_getRealPath($file->path); } static public function getReadOnlyPointer(File $file) { return fopen(self::getFullPath($file), 'rb'); } static public function display(File $file): void { readfile(self::getFullPath($file)); } static public function fetch(File $file): string |
︙ | ︙ | |||
213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | // directory_blabla // file_image.jpeg $files[$file->getType() . '_' .$file->getFilename()] = self::_SplToFile($file); } return Utils::knatcasesort($files); } static public function listDirectoriesRecursively(string $path): array { $fullpath = self::_getRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path); $fullpath = rtrim($fullpath, DIRECTORY_SEPARATOR); if (!file_exists($fullpath)) { return []; } return self::_recurseGlob($fullpath, '*', \GLOB_ONLYDIR); } static protected function _recurseGlob(string $path, string $pattern = '*', int $flags = 0): array { $target = $path . DIRECTORY_SEPARATOR . $pattern; $list = []; // glob is the fastest way to recursely list directories and files apparently | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | // directory_blabla // file_image.jpeg $files[$file->getType() . '_' .$file->getFilename()] = self::_SplToFile($file); } return Utils::knatcasesort($files); } static public function glob(string $path) { $fullpath = self::_getRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path); $fullpath = rtrim($fullpath, DIRECTORY_SEPARATOR); if (!file_exists($fullpath)) { return []; } $files = []; foreach (glob($fullpath) as $file) { $file = new \SplFileInfo($file); $files[$file->getType() . '_' .$file->getFilename()] = self::_SplToFile($file); } return Utils::knatcasesort($files); } static public function listDirectoriesRecursively(string $path): array { $fullpath = self::_getRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path); $fullpath = rtrim($fullpath, DIRECTORY_SEPARATOR); if (!file_exists($fullpath)) { return []; } return self::_recurseGlob($fullpath, '*', \GLOB_ONLYDIR); } static public function getDirectorySize(string $path): int { $fullpath = self::_getRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path); $fullpath = rtrim($fullpath, DIRECTORY_SEPARATOR); $total = 0; foreach (glob($fullpath . '/*', GLOB_NOSORT) as $f) { if (is_dir($f)) { $f = substr($f, strlen($path) + 1); $total += self::getDirectorySize($f); } else { $total += filesize($f); } } return $total; } static protected function _recurseGlob(string $path, string $pattern = '*', int $flags = 0): array { $target = $path . DIRECTORY_SEPARATOR . $pattern; $list = []; // glob is the fastest way to recursely list directories and files apparently |
︙ | ︙ |
Modified src/include/lib/Garradin/Files/Storage/SQLite.php from [99e5f6c9f5] to [8bb888c492].
︙ | ︙ | |||
15 16 17 18 19 20 21 | class SQLite implements StorageInterface { static public function configure(?string $config): void { } | < < < < | < < < < | | | | | | | | | | | < | | > > | | | | | | | | > > | | > > < > | | | | > | > > > > > > | > < < < > > > > > > > > > > > > > > > > > | > | > > > > > > > | > > > > > > > > > > > > > > > > | 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | class SQLite implements StorageInterface { static public function configure(?string $config): void { } static protected function getPointer(File $file) { $db = DB::getInstance(); try { $blob = $db->openBlob('files_contents', 'content', $file->id()); } catch (\Exception $e) { if (!strstr($e->getMessage(), 'no such rowid')) { throw $e; } throw new \RuntimeException('File does not exist in DB: ' . $file->path, 0, $e); } return $blob; } static public function storePath(File $file, string $path): bool { return self::store($file, compact('path')); } static public function storeContent(File $file, string $content): bool { return self::store($file, compact('content')); } static public function storePointer(File $file, $pointer): bool { return self::store($file, compact('pointer')); } static protected function store(File $file, array $source): bool { if (!isset($source['path']) && !isset($source['content']) && !isset($source['pointer'])) { throw new \InvalidArgumentException('Unknown source type'); } elseif (count($source) != 1) { throw new \InvalidArgumentException('Invalid source type'); } $content = $path = $pointer = null; extract($source); $db = DB::getInstance(); $file->save(); $id = $file->id(); $db->preparedQuery('INSERT OR REPLACE INTO files_contents (id, content) VALUES (?, zeroblob(?));', $id, $file->size); $blob = $db->openBlob('files_contents', 'content', $id, 'main', \SQLITE3_OPEN_READWRITE); if (null !== $content) { fwrite($blob, $content); } elseif ($path) { $pointer = fopen($path, 'rb'); } if ($pointer) { while (!feof($pointer)) { fwrite($blob, fread($pointer, 8192)); } if ($path) { fclose($pointer); } } fclose($blob); if ($file->parent) { self::touch($file->parent); } $cache_id = 'files.' . $file->pathHash(); Static_Cache::remove($cache_id); return true; } static public function getFullPath(File $file): ?string { $cache_id = 'files.' . $file->pathHash(); if (!Static_Cache::exists($cache_id)) { $blob = self::getPointer($file); Static_Cache::storeFromPointer($cache_id, $blob); fclose($blob); } return Static_Cache::getPath($cache_id); } static public function getReadOnlyPointer(File $file) { return self::getPointer($file); } static public function display(File $file): void { $blob = self::getPointer($file); while (!feof($blob)) { echo fread($blob, 8192); } fclose($blob); } static public function fetch(File $file): string { $blob = self::getPointer($file); $out = ''; while (!feof($blob)) { $out .= fread($blob, 8192); } fclose($blob); return $out; } static public function get(string $path): ?File { $sql = 'SELECT * FROM @TABLE WHERE path = ? LIMIT 1;'; return EM::findOne(File::class, $sql, $path); } static public function glob(string $pattern): array { return EM::getInstance(File::class)->all('SELECT * FROM @TABLE WHERE path GLOB ? AND path NOT GLOB ? ORDER BY type DESC, name COLLATE U_NOCASE ASC;', $pattern, $pattern . '/*'); } static public function list(string $path): array { return EM::getInstance(File::class)->all('SELECT * FROM @TABLE WHERE parent = ? ORDER BY type DESC, name COLLATE U_NOCASE ASC;', $path); } static public function listDirectoriesRecursively(string $path): array { $files = []; $it = DB::getInstance()->iterate('SELECT path FROM files WHERE (parent = ? OR parent LIKE ?) AND type = ? ORDER BY path;', $path, $path . '/%', File::TYPE_DIRECTORY); foreach ($it as $file) { $files[] = substr($file->path, strlen($path) + 1); } return $files; } static public function getDirectorySize(string $path): int { return DB::getInstance()->firstColumn('SELECT SUM(size) FROM files WHERE (parent = ? OR parent LIKE ?) AND type = ?;', $path, $path . '/%', File::TYPE_FILE) ?: 0; } static public function exists(string $path): bool { return DB::getInstance()->test('files', 'path = ?', $path); } static public function delete(File $file): bool |
︙ | ︙ | |||
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | } return true; } static public function move(File $file, string $new_path): bool { $current_path = $file->path; $file->set('path', $new_path); $file->set('parent', Utils::dirname($new_path)); $file->set('name', Utils::basename($new_path)); $file->save(); if ($file->type == File::TYPE_DIRECTORY) { // Move sub-directories and sub-files DB::getInstance()->preparedQuery('UPDATE files SET parent = ?, path = TRIM(? || \'/\' || name, \'/\') WHERE parent = ?;', $new_path, $new_path, $current_path); } if ($file->parent) { self::touch($file->parent); } return true; } | > > > | > > > > > > > > > > | | 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 | } return true; } static public function move(File $file, string $new_path): bool { $cache_id = 'files.' . $file->pathHash(); Static_Cache::remove($cache_id); $current_path = $file->path; $file->set('path', $new_path); $file->set('parent', Utils::dirname($new_path)); $file->set('name', Utils::basename($new_path)); $file->save(); if ($file->type == File::TYPE_DIRECTORY) { // Move sub-directories and sub-files DB::getInstance()->preparedQuery('UPDATE files SET parent = ?, path = TRIM(? || \'/\' || name, \'/\') WHERE parent = ?;', $new_path, $new_path, $current_path); } if ($file->parent) { self::touch($file->parent); } return true; } static public function touch(string $path, $date = null): bool { if (null === $date) { $date = new \DateTime; } elseif (!($date instanceof \DateTimeInterface) && ctype_digit($date)) { $date = new \DateTime('@' . $date); } elseif (!($date instanceof \DateTimeInterface)) { throw new \InvalidArgumentException('Invalid date string: ' . $date); } return DB::getInstance()->preparedQuery('UPDATE files SET modified = ? WHERE path = ?;', $date, $path); } static public function mkdir(File $file): bool { $file->save(); if ($file->parent) { |
︙ | ︙ |
Modified src/include/lib/Garradin/Files/Storage/StorageInterface.php from [67b35b24ca] to [2482356147].
︙ | ︙ | |||
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | /** * Should return full local file access path. * If storage backend cannot store the file locally, return NULL. * In that case a subsequent call to fetch() will be done. */ static public function getFullPath(File $file): ?string; /** * Returns the binary of a content to php://output */ static public function display(File $file): void; /** * Returns the binary content of a file */ static public function fetch(File $file): string; /** * Delete a file */ static public function delete(File $file): bool; /** * Change file mtime */ | > > > > > | | 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | /** * Should return full local file access path. * If storage backend cannot store the file locally, return NULL. * In that case a subsequent call to fetch() will be done. */ static public function getFullPath(File $file): ?string; /** * Returns a read-only file pointer (resource) to the file contents */ static public function getReadOnlyPointer(File $file); /** * Returns the binary of a content to php://output */ static public function display(File $file): void; /** * Returns the binary content of a file */ static public function fetch(File $file): string; /** * Delete a file */ static public function delete(File $file): bool; /** * Change file mtime */ static public function touch(string $path, $date = null): bool; /** * Return TRUE if file exists */ static public function exists(string $path): bool; /** |
︙ | ︙ | |||
70 71 72 73 74 75 76 77 78 79 80 81 82 83 | /** * Return an array of (string) paths of all subdirectories inside a path * @param string $path Parent path */ static public function listDirectoriesRecursively(string $path): array; /** * Moves a file to a new path, when its name or path has changed */ static public function move(File $file, string $new_path): bool; /** * Return total size of used space by files stored in this backed | > > > > > | 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | /** * Return an array of (string) paths of all subdirectories inside a path * @param string $path Parent path */ static public function listDirectoriesRecursively(string $path): array; /** * Return recursive directory size */ static public function getDirectorySize(string $path): int; /** * Moves a file to a new path, when its name or path has changed */ static public function move(File $file, string $new_path): bool; /** * Return total size of used space by files stored in this backed |
︙ | ︙ |
Modified src/include/lib/Garradin/Files/Transactions.php from [181762b574] to [7fc2e9d2f8].
︙ | ︙ | |||
40 41 42 43 44 45 46 | $columns = self::LIST_COLUMNS; $tables = sprintf('%s f INNER JOIN acc_transactions t ON t.id = f.name INNER JOIN acc_years y ON t.id_year = y.id', Files::getVirtualTableName()); | < < | 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | $columns = self::LIST_COLUMNS; $tables = sprintf('%s f INNER JOIN acc_transactions t ON t.id = f.name INNER JOIN acc_years y ON t.id_year = y.id', Files::getVirtualTableName()); // Only fetch directories with an ID as the name $conditions = sprintf('f.parent = \'%s\' AND f.type = %d AND printf("%%d", f.name) = name', File::CONTEXT_TRANSACTION, File::TYPE_DIRECTORY); $list = new DynamicList($columns, $tables, $conditions); $list->orderBy('year', true); $list->setCount('COUNT(DISTINCT t.id)'); $list->setModifier(function (&$row) { $row->date = \DateTime::createFromFormat('!Y-m-d', $row->date); }); return $list; } } |
Added src/include/lib/Garradin/Files/Trash.php version [c134c35f90].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | <?php namespace Garradin\Files; use Garradin\Entities\Files\File; use Garradin\DynamicList; class Trash { const LIST_COLUMNS = [ 'name' => [ 'label' => 'Fichier', ], 'parent' => [ 'label' => 'Chemin d\'origine', 'select' => 'SUBSTR(parent, LENGTH(\'trash/\') + 1)', ], 'path' => [ ], 'modified' => [ 'label' => 'Supprimé le', ], ]; static public function list(): DynamicList { Files::syncVirtualTable(File::CONTEXT_TRASH, true); $columns = self::LIST_COLUMNS; $tables = Files::getVirtualTableName(); $conditions = sprintf('type = %d', File::TYPE_FILE); $list = new DynamicList($columns, $tables, $conditions); $list->orderBy('modified', true); return $list; } static public function pruneEmptyDirectories(): void { $paths = []; foreach (Files::listRecursive(File::CONTEXT_TRASH, null, true) as $file) { if ($file->isDir()) { $paths[$file->path] = 0; } else { if (!isset($paths[$file->parent])) { $paths[$file->parent] = 0; } $paths[$file->parent]++; } } foreach ($paths as $path => $count) { if (!$count) { Files::get($path)->delete(); } } } static public function clean(string $expiry = '-30 days'): void { $past = new \DateTime($expiry); $deleted = false; foreach (Files::listRecursive(File::CONTEXT_TRASH, null, true) as $file) { if ($file->modified < $past) { $file->delete(); $deleted = true; } } if ($deleted) { self::pruneEmptyDirectories(); } } } |
Modified src/include/lib/Garradin/Files/Users.php from [55c1feeac2] to [c993625eb5].
1 2 3 4 5 6 | <?php namespace Garradin\Files; use Garradin\Entities\Files\File; use Garradin\DynamicList; | | < | | < < < < | | > | < < | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | <?php namespace Garradin\Files; use Garradin\Entities\Files\File; use Garradin\DynamicList; use Garradin\Users\DynamicFields as DF; class Users { const LIST_COLUMNS = [ 'number' => [ 'label' => 'Numéro', ], 'identity' => [ 'select' => '', 'label' => '', ], 'path' => [ ], 'id' => [ 'label' => null, 'select' => 'u.id', ], ]; static public function list(): DynamicList { Files::syncVirtualTable(File::CONTEXT_USER); $columns = self::LIST_COLUMNS; $columns['identity']['select'] = DF::getNameFieldsSQL('u'); $columns['identity']['label'] = DF::getNameLabel(); $columns['number']['select'] = DF::getNumberField(); $tables = sprintf('%s f INNER JOIN users u ON u.id = f.name', Files::getVirtualTableName()); // Only fetch directories with an ID as the name $conditions = sprintf('f.parent = \'%s\' AND f.type = %d AND printf("%%d", f.name) = name', File::CONTEXT_USER, File::TYPE_DIRECTORY); $list = new DynamicList($columns, $tables, $conditions); $list->orderBy('number', false); $list->setCount('COUNT(DISTINCT u.id)'); return $list; } } |
Added src/include/lib/Garradin/Files/WebDAV/NextCloud.php version [8231ba23c4].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | <?php namespace Garradin\Files\WebDAV; use KD2\WebDAV\NextCloud as WebDAV_NextCloud; use KD2\WebDAV\Exception as WebDAV_Exception; use Garradin\Config; use Garradin\Utils; use Garradin\UserException; use Garradin\Files\Files; use Garradin\Entities\Files\File; use const Garradin\{SECRET_KEY, ADMIN_URL, CACHE_ROOT, WWW_URL, ROOT}; class NextCloud extends WebDAV_NextCloud { protected string $temporary_chunks_path; protected string $prefix = File::CONTEXT_DOCUMENTS . '/'; public function __construct() { $this->temporary_chunks_path = CACHE_ROOT . '/webdav.chunks'; $this->setRootURL(WWW_URL); } public function route(?string $uri = null): bool { $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; // Currently, iOS apps are broken if (stristr($ua, 'nextcloud-ios') || stristr($ua, 'owncloudapp')) { throw new WebDAV_Exception('Your client is not compatible with this server. Consider using a different WebDAV client.', 403); } return parent::route($uri); } public function auth(?string $login, ?string $password): bool { $session = Session::getInstance(); if ($session->isLogged()) { return true; } if (!$login || !$password) { return false; } if ($session->checkAppCredentials($login, $password)) { return true; } if ($session->login($login, $password)) { return true; } return false; } public function getUserName(): ?string { $s = Session::getInstance(); return $s->isLogged() ? $s->user()->name() : null; } public function setUserName(string $login): bool { return true; } public function getUserQuota(): array { return [ 'free' => Files::getRemainingQuota(), 'used' => Files::getUsedQuota(), 'total' => Files::getQuota(), ]; } public function generateToken(): string { return Session::getInstance()->generateAppToken(); } public function validateToken(string $token): ?array { return Session::getInstance()->verifyAppToken($_POST['token']); } public function getLoginURL(?string $token): string { if ($token) { return sprintf('%slogin.php?app=%s', ADMIN_URL, $token); } else { return sprintf('%slogin.php?app=redirect', ADMIN_URL); } } public function getDirectDownloadSecret(string $uri, string $login): string { return hash_hmac('sha1', $uri, SECRET_KEY); } protected function cleanChunks(): void { // 36 hours $expire = time() - 36*3600; foreach (glob($this->temporary_chunks_path . '/*') as $dir) { $first_file = current(glob($dir . '/*')); if (filemtime($first_file) < $expire) { Utils::deleteRecursive($dir, true); } } } public function storeChunk(string $login, string $name, string $part, $pointer): void { $this->cleanChunks(); $path = $this->temporary_chunks_path . '/' . $name; @mkdir($path, 0777, true); $file_path = $path . '/' . $part; $out = fopen($file_path, 'wb'); $quota = $this->getUserQuota(); $used = array_sum(array_map(fn($a) => filesize($a), glob($path . '/*'))); $used += $quota['used']; while (!feof($pointer)) { $data = fread($pointer, 8192); $used += strlen($used); if ($used > $quota['free']) { $this->deleteChunks($login, $name); throw new WebDAV_Exception('Your quota does not allow for the upload of this file', 403); } fwrite($out, $data); } fclose($out); fclose($pointer); } public function deleteChunks(string $login, string $name): void { $path = $this->temporary_chunks_path . '/' . $name; Utils::deleteRecursive($path, true); } public function listChunks(string $login, string $name): array { $path = $this->temporary_chunks_path . '/' . $name; $list = glob($path . '/*'); $list = array_map(fn($a) => str_replace($path . '/', '', $a), $list); return $list; } public function assembleChunks(string $login, string $name, string $target, ?int $mtime): array { $parent = Utils::dirname($target); $parent = Files::get($parent); if (!$parent || $parent->type != $parent::TYPE_DIRECTORY) { throw new WebDAV_Exception('Target parent directory does not exist', 409); } $path = $this->temporary_chunks_path . '/' . $name; $tmp_file = $path . '/__complete'; $target = $this->prefix . $target; $exists = Files::exists($target); try { $out = fopen($tmp_file, 'wb'); $processed = 0; foreach (glob($path . '/*') as $file) { if ($file == $tmp_file) { continue; } $in = fopen($file, 'rb'); while (!feof($in)) { $data = fread($in, 8192); fwrite($out, $data); $processed += strlen($data); } fclose($in); } fclose($out); $file = Files::createFromPath($target, $tmp_file); if ($mtime) { $file->touch($mtime); } } finally { $this->deleteChunks($login, $name); Utils::safe_unlink($tmp_file); } return ['created' => !$exists, 'etag' => $file->etag()]; } public function serveThumbnail(string $uri, int $width, int $height, bool $crop = false, bool $preview = false): void { if (!preg_match('/\.(?:jpe?g|gif|png|webp)$/', $uri)) { http_response_code(404); return; } $this->requireAuth(); $uri = preg_replace(self::WEBDAV_BASE_REGEXP, '', $uri); $file = Files::get(File::CONTEXT_DOCUMENTS . '/' . $uri); if (!$file) { throw new WebDAV_Exception('Not found', 404); } if (!$file->image) { throw new WebDAV_Exception('Not an image', 404); } if ($crop) { $size = 'crop-256px'; } elseif ($width >= 500 || $height >= 500) { $size = '500px'; } else { $size = '150px'; } $session = Session::getInstance(); $this->server->log('Serving thumbnail for: %s - size: %s', $uri, $size); try { $file->serveThumbnail($session, $size); } catch (UserException $e) { throw new WebDAV_Exception($e->getMessage(), $e->getCode(), $e); } } protected function nc_avatar(): void { header('X-NC-IsCustomAvatar: 1'); $file = Config::getInstance()->file('icon'); if (!$file) { $path = ROOT . '/www/admin/static/icon.png'; header('Content-Type: image/png'); header('Last-Modified: ' . gmdate(DATE_ISO8601)); header('Content-Length: ' . filesize($path)); readfile($path); } else { $file->serveThumbnail(Session::getInstance(), 'crop-256px'); } } } |
Added src/include/lib/Garradin/Files/WebDAV/Server.php version [4ecb204367].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | <?php namespace Garradin\Files\WebDAV; use Garradin\Users\Session as UserSession; use KD2\WebDAV\WOPI; use const Garradin\WOPI_DISCOVERY_URL; class Server { /** * WOPI routes are only available to users logged-in in /admin/ * Not people logged-in using webdav */ static public function wopiRoute(?string $uri = null): bool { if (!WOPI_DISCOVERY_URL) { return false; } if (0 !== strpos($uri, '/wopi/')) { return false; } $wopi = new WOPI; $dav = new WebDAV; $storage = new Storage(UserSession::getInstance()); $dav->setStorage($storage); $wopi->setServer($dav); return $wopi->route($uri); } static public function route(?string $uri = null): bool { $uri = '/' . ltrim($uri, '/'); if (self::wopiRoute($uri)) { return true; } $dav = new WebDAV; $nc = new NextCloud($dav); $storage = new Storage(Session::getInstance(), $nc); $dav->setStorage($storage); $method = $_SERVER['REQUEST_METHOD'] ?? null; // Always say YES to OPTIONS if ($method == 'OPTIONS') { $dav->http_options(); return true; } $nc->setServer($dav); if ($r = $nc->route($uri)) { // NextCloud route already replied something, stop here return true; } // If NextCloud layer didn't return anything // it means we fall back to the default WebDAV server // available on the root path. We need to handle a // classic login/password auth here. if (0 !== strpos($uri, '/dav/')) { return false; } if (!self::auth()) { http_response_code(401); header('WWW-Authenticate: Basic realm="Please login"'); return true; } $dav->setBaseURI('/dav/'); return $dav->route($uri); } static public function auth(): bool { $session = Session::getInstance(); if ($session->isLogged()) { return true; } if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) { return false; } if ($session->login($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) { return true; } return false; } } |
Added src/include/lib/Garradin/Files/WebDAV/Session.php version [674705e7d9].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | <?php namespace Garradin\Files\WebDAV; use Garradin\DB; use Garradin\Users\Users; use Garradin\Entities\Users\User; use Garradin\Users\Session as UserSession; use const Garradin\{WWW_URL}; class Session extends UserSession { static protected $_instance = null; // Use a different session name so that someone cannot access the admin // with a cookie from WebDAV/app protected $cookie_name = 'pkow'; /** * Create a temporary app token for an external service session (eg. NextCloud) */ public function generateAppToken(): string { $token = hash('sha256', random_bytes(10)); $expiry = time() + 30*60; // 30 minutes $this->storeRememberMeSelector('tok_' . $token, 'waiting', $expiry, null); return $token; } /** * Validate the temporary token once the user has logged-in */ public function validateAppToken(string $token): bool { if (!ctype_alnum($token) || strlen($token) > 64) { return false; } $token = $this->getRememberMeSelector('tok_' . $token); if (!$token || $token->hash != 'waiting') { return false; } $user = $this->getUser(); if (!$user) { throw new \LogicException('Cannot create a token if the user is not logged-in'); } DB::getInstance()->preparedQuery('UPDATE users_sessions SET hash = \'ok\', id_user = ?, expiry = expiry + 30*60 WHERE selector = ?;', $user->id, $this->cookie_name . '_' . $token->selector); return true; } /** * Verify temporary app token and create a session, * this is similar to "remember me" sessions but without cookies */ public function verifyAppToken(string $token): ?array { if (!ctype_alnum($token) || strlen($token) > 64) { return null; } $token = $this->getRememberMeSelector('tok_' . $token); if (!$token || $token->hash != 'ok') { return null; } // Delete temporary token $this->deleteRememberMeSelector($token->selector); if ($token->expiry < time()) { return null; } $new_token = base_convert(sha1(random_bytes(10)), 16, 36); $selector = 'app_' . substr($new_token, 0, 16); $selector = $this->createSelectorValues($token->user_id, $token->user_password, null, $selector); $this->storeRememberMeSelector($selector->selector, $selector->hash, $selector->expiry, $token->user_id); $login = $selector->selector; $password = $selector->verifier; return compact('login', 'password'); } public function createAppCredentials(): \stdClass { if (!$this->isLogged()) { throw new \LogicException('User is not logged'); } $user = $this->getUser(); $token = base_convert(sha1(random_bytes(10)), 16, 36); $selector = 'app_' . substr($token, 0, 16); $selector = $this->createSelectorValues($user->id, $user->password, null, $selector); $this->storeRememberMeSelector($selector->selector, $selector->hash, $selector->expiry, $user->id); $login = $selector->selector; $password = $selector->verifier; $redirect = sprintf(NextCloud::AUTH_REDIRECT_URL, WWW_URL, $login, $password); return (object) compact('login', 'password', 'redirect'); } public function checkAppCredentials(string $login, string $password): ?User { $selector = $this->getRememberMeSelector($login); if (!$selector) { return null; } if (!$this->checkRememberMeSelector($selector, $password)) { $this->deleteRememberMeSelector($selector->selector); return null; } $this->_user = Users::get($selector->user_id); if (!$this->_user) { return null; } $this->user = $selector->user_id; return $this->_user; } } |
Added src/include/lib/Garradin/Files/WebDAV/Storage.php version [5103f6c417].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 | <?php namespace Garradin\Files\WebDAV; use KD2\WebDAV\AbstractStorage; use KD2\WebDAV\WOPI; use KD2\WebDAV\Exception as WebDAV_Exception; use Garradin\DB; use Garradin\Utils; use Garradin\ValidationException; use Garradin\Users\Session as UserSession; use Garradin\Files\Files; use Garradin\Entities\Files\File; use Garradin\Web\Router; use const Garradin\{FILE_STORAGE_BACKEND, SECRET_KEY, WWW_URL}; class Storage extends AbstractStorage { /** * These file names will be ignored when doing a PUT * as they are garbage, coming from some OS */ const PUT_IGNORE_PATTERN = '!^~(?:lock\.|^\._)|^(?:\.DS_Store|Thumbs\.db|desktop\.ini)$!'; protected ?array $cache = null; protected array $root = []; protected ?NextCloud $nextcloud; protected UserSession $session; public function __construct(UserSession $session, ?NextCloud $nextcloud = null) { $this->session = $session; $this->nextcloud = $nextcloud; } protected function populateRootCache(): void { if (isset($this->cache)) { return; } $access = Files::listReadAccessContexts($this->session); $this->cache = ['' => Files::get('')]; foreach ($access as $context => $name) { $this->cache[$context] = Files::get($context); $this->root[] = $context; } } protected function load(string $uri) { $this->populateRootCache(); if (!isset($this->cache[$uri])) { $this->cache[$uri] = Files::get($uri); if (!$this->cache[$uri]) { return null; } } return $this->cache[$uri]; } /** * @extends */ public function list(string $uri, ?array $properties): iterable { $this->populateRootCache(); if (!$uri) { foreach ($this->root as $name) { yield $name => null; } return; } $file = $this->load($uri); if (!$file) { return null; } if ($file->type != $file::TYPE_DIRECTORY) { return; } foreach (Files::list($uri) as $file) { $path = $uri . '/' . $file->name; $this->cache[$path] = $file; yield $file->name => null; } } /** * @extends */ public function get(string $uri): ?array { $file = $this->load($uri); if (!$file) { throw new WebDAV_Exception('File Not Found', 404); } if (!$file->canRead($this->session)) { throw new WebDAV_Exception('Vous n\'avez pas accès à ce chemin', 403); } $type = $file->type; // Serve files if ($type == File::TYPE_DIRECTORY) { return null; } if (FILE_STORAGE_BACKEND == 'FileSystem' && Router::xSendFile($file->fullpath())) { return ['stop' => true]; } // We trust the WebDAV server to be more efficient that File::serve // with serving a file for WebDAV clients return ['resource' => $file->getReadOnlyPointer()]; } /** * @extends */ public function exists(string $uri): bool { $this->populateRootCache(); if (isset($this->cache[$uri])) { return true; } return Files::exists($uri); } protected function get_file_property(string $uri, string $name, int $depth) { $file = $this->load($uri); $is_dir = $file->type == File::TYPE_DIRECTORY; if (!$file) { throw new \LogicException('File does not exist'); } switch ($name) { case 'DAV::getcontentlength': return $is_dir ? null : $file->size; case 'DAV::getcontenttype': return $is_dir ? null : $file->mime; case 'DAV::resourcetype': return $is_dir ? 'collection' : ''; case 'DAV::getlastmodified': return $file->modified ?? null; case 'DAV::displayname': return $file->name; case 'DAV::ishidden': return false; case 'DAV::getetag': return !$is_dir ? $file->etag() : md5($file->getRecursiveSize() . $file->path); case 'DAV::lastaccessed': return null; case 'DAV::creationdate': return $file->modified ?? null; case WebDAV::PROP_DIGEST_MD5: if ($file->type != File::TYPE_FILE) { return null; } return md5_file($file->fullpath()); // NextCloud stuff case NextCloud::PROP_NC_HAS_PREVIEW: return $file->image ? 'true' : 'false'; case NextCloud::PROP_NC_IS_ENCRYPTED: return 'false'; case NextCloud::PROP_OC_SHARETYPES: return WebDAV::EMPTY_PROP_VALUE; case NextCloud::PROP_OC_DOWNLOADURL: return $this->nextcloud->getDirectURL($uri, $this->session::getUserId()); case Nextcloud::PROP_NC_RICH_WORKSPACE: return ''; case NextCloud::PROP_OC_ID: return NextCloud::getDirectID('', $uri); case NextCloud::PROP_OC_PERMISSIONS: $permissions = [ NextCloud::PERM_READ => $file->canRead($this->session), NextCloud::PERM_WRITE => $file->canWrite($this->session), NextCloud::PERM_DELETE => $file->canDelete($this->session), NextCloud::PERM_RENAME => $file->canDelete($this->session), NextCloud::PERM_MOVE => $file->canDelete($this->session), NextCloud::PERM_CREATE => $file->canCreateHere($this->session), NextCloud::PERM_MKDIR => $file->canCreateDirHere($this->session), ]; $permissions = array_filter($permissions, fn($a) => $a); return implode('', array_keys($permissions)); case 'DAV::quota-available-bytes': return Files::getRemainingQuota(); case 'DAV::quota-used-bytes': return Files::getUsedQuota(); case Nextcloud::PROP_OC_SIZE: return $file->getRecursiveSize(); case WOPI::PROP_USER_NAME: return $this->session->getUser()->name(); case WOPI::PROP_USER_ID: return $this->session->getUser()->id; case WOPI::PROP_READ_ONLY: return $file->canWrite($this->session) ? false : true; case WOPI::PROP_FILE_URL: $id = gzcompress($uri); $id = WOPI::base64_encode_url_safe($id); return WWW_URL . 'wopi/files/' . $id; default: break; } return null; } /** * @extends */ public function properties(string $uri, ?array $properties, int $depth): ?array { $this->populateRootCache(); $file = $this->load($uri); if (!$file) { return null; } if (null === $properties) { $properties = array_merge(WebDAV::BASIC_PROPERTIES, ['DAV::getetag', Nextcloud::PROP_OC_ID]); } $out = []; // Generate a new token for WOPI, and provide also TTL if (in_array(WOPI::PROP_TOKEN, $properties)) { $out = $this->createWopiToken($uri); unset($properties[WOPI::PROP_TOKEN], $properties[WOPI::PROP_TOKEN_TTL]); } foreach ($properties as $name) { $v = $this->get_file_property($uri, $name, $depth); if (null !== $v) { $out[$name] = $v; } } return $out; } public function put(string $uri, $pointer, ?string $hash_algo, ?string $hash, ?int $mtime): bool { if (!strpos($uri, '/')) { throw new WebDAV_Exception('Impossible de créer un fichier ici', 403); } if (preg_match(self::PUT_IGNORE_PATTERN, basename($uri))) { return false; } $target = Files::get($uri); if ($target && $target->type === $target::TYPE_DIRECTORY) { throw new WebDAV_Exception('Target is a directory', 409); } $new = !$target ? true : false; if ($new && !File::canCreate($uri, $this->session)) { throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de créer ce fichier', 403); } elseif (!$new && !$target->canWrite($this->session)) { throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de modifier ce fichier', 403); } $h = $hash ? hash_init($hash_algo == 'MD5' ? 'md5' : 'sha1') : null; while (!feof($pointer)) { if ($h) { hash_update($h, fread($pointer, 8192)); } else { fread($pointer, 8192); } } if ($h) { if (hash_final($h) != $hash) { throw new WebDAV_Exception('The data sent does not match the supplied hash', 400); } } // Check size $size = ftell($pointer); try { Files::checkQuota($size); } catch (ValidationException $e) { throw new WebDAV_Exception($e->getMessage(), 403); } rewind($pointer); if ($new) { $target = Files::createFromPointer($uri, $pointer); } else { $target->store(compact('pointer')); } if ($mtime) { $target->touch(new \DateTime('@' . $mtime)); } return $new; } /** * @extends */ public function delete(string $uri): void { if (!strpos($uri, '/')) { throw new WebDAV_Exception('Ce répertoire ne peut être supprimé', 403); } $target = Files::get($uri); if (!$target) { throw new WebDAV_Exception('This file does not exist', 404); } if (!$target->canDelete($this->session)) { throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de supprimer ce fichier', 403); } if ($file->context() == $file::CONTEXT_TRASH) { $target->delete(); } else { $target->moveToTrash(); } } protected function copymove(bool $move, string $uri, string $destination): bool { if (!strpos($uri, '/')) { throw new WebDAV_Exception('Ce répertoire ne peut être modifié', 403); } $source = Files::get($uri); if (!$source) { throw new WebDAV_Exception('File not found', 404); } if (!$source->canMoveTo($destination, $this->session)) { throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de déplacer ce fichier', 403); } if (!$move) { if ($source->size > Files::getRemainingQuota(true)) { throw new WebDAV_Exception('Your quota is exhausted', 403); } } $overwritten = Files::exists($destination); if ($overwritten) { $this->delete($destination); } $method = $move ? 'rename' : 'copy'; $source->$method($destination); return $overwritten; } /** * @extends */ public function copy(string $uri, string $destination): bool { return $this->copymove(false, $uri, $destination); } /** * @extends */ public function move(string $uri, string $destination): bool { return $this->copymove(true, $uri, $destination); } /** * @extends */ public function mkcol(string $uri): void { if (!strpos($uri, '/')) { throw new WebDAV_Exception('Impossible de créer un répertoire ici', 403); } if (!File::canCreateDir($uri)) { throw new WebDAV_Exception('Vous n\'avez pas l\'autorisation de créer un répertoire ici', 403); } if (Files::exists($uri)) { throw new WebDAV_Exception('There is already a file with that name', 405); } if (!Files::exists(Utils::dirname($uri))) { throw new WebDAV_Exception('The parent directory does not exist', 409); } Files::mkdir($uri); } protected function createWopiToken(string $uri) { $ttl = time()+(3600*10); $session_id = $this->session->id(); $hash = WebDAV::hmac(compact('uri', 'ttl', 'session_id'), SECRET_KEY); $data = sprintf('%s_%s_%s', $hash, $session_id, $ttl); return [ WOPI::PROP_TOKEN => WOPI::base64_encode_url_safe($data), WOPI::PROP_TOKEN_TTL => $ttl * 1000, ]; } public function getWopiURI(string $id, string $token): ?string { $id = WOPI::base64_decode_url_safe($id); $uri = gzuncompress($id); $token_decode = WOPI::base64_decode_url_safe($token); $hash = strtok($token_decode, '_'); $session_id = strtok('_'); $ttl = (int) strtok(false); $check = WebDAV::hmac(compact('uri', 'ttl', 'session_id'), SECRET_KEY); if (!hash_equals($hash, $check)) { return null; } if ($ttl < time()) { return null; } $this->session->setId($session_id); $this->session->start(true); if (!$this->session->isLogged()) { return null; } return $uri; } } |
Added src/include/lib/Garradin/Files/WebDAV/WebDAV.php version [10c1284926].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | <?php namespace Garradin\Files\WebDAV; use Garradin\Utils; use Garradin\Web\Router; use KD2\WebDAV\Server as KD2_WebDAV; use KD2\WebDAV\Exception; use const Garradin\{WOPI_DISCOVERY_URL, WWW_URL, ADMIN_URL}; class WebDAV extends KD2_WebDAV { /* protected function html_directory(string $uri, iterable $list): ?string { Utils::redirect('!docs/?path=' . rawurlencode($uri)); return null; } */ protected function html_directory(string $uri, iterable $list): ?string { $out = parent::html_directory($uri, $list); if (null !== $out) { if (WOPI_DISCOVERY_URL) { $out = str_replace('<html', sprintf('<html data-wopi-discovery-url="%s" data-wopi-host-url="%s"', WOPI_DISCOVERY_URL, WWW_URL . 'wopi/'), $out); } $body = sprintf('<body style="opacity: 0"> <script type="text/javascript" src="%1$sstatic/scripts/lib/webdav.fr.js"></script> <script type="text/javascript" src="%1$sstatic/scripts/lib/webdav.js"></script>', ADMIN_URL); $out = str_replace('<body>', $body, $out); } return $out; } public function log(string $message, ...$params) { Router::log('DAV: ' . $message, ...$params); } } |
Modified src/include/lib/Garradin/Form.php from [e413809f22] to [50a5e33df9].
︙ | ︙ | |||
63 64 65 66 67 68 69 | throw $e; } elseif (REPORT_USER_EXCEPTIONS === 1) { \KD2\ErrorManager::reportExceptionSilent($e); } } | | | | 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 | throw $e; } elseif (REPORT_USER_EXCEPTIONS === 1) { \KD2\ErrorManager::reportExceptionSilent($e); } } public function runIf($condition, callable $fn, ?string $csrf_key = null, ?string $redirect = null, bool $follow_redirect = false): ?bool { if (is_string($condition) && empty($_POST[$condition])) { return null; } elseif (is_bool($condition) && !$condition) { return null; } return $this->run($fn, $csrf_key, $redirect, $follow_redirect); } /** * @deprecated */ public function check($token_action = '', Array $rules = null) { |
︙ | ︙ | |||
117 118 119 120 121 122 123 | } public function addError($msg) { $this->errors[] = $msg; } | | < < < < < < < < < < < < < < < < < < < < < < < | < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < | > > > > > > > > > > > > > > > > > > > > > > | 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | } public function addError($msg) { $this->errors[] = $msg; } public function getErrorMessages() { return $this->errors; } public function __invoke($key) { return \KD2\Form::get($key); } /** * Returns a value from a custom list selector * see CommonFunctions::input */ static public function getSelectorValue($value) { if (!is_array($value)) { return $value; } $values = array_filter(array_keys($value)); if (count($values) == 1) { return current($values); } elseif (!count($values)) { return ''; // Empty } else { return $values; } } } |
Modified src/include/lib/Garradin/Install.php from [0ad378a28f] to [83fa8d0d78].
1 2 3 4 5 6 7 8 9 10 | <?php namespace Garradin; use Garradin\Accounting\Charts; use Garradin\Entities\Accounting\Account; use Garradin\Entities\Accounting\Year; use Garradin\Entities\Users\Category; use Garradin\Entities\Files\File; use Garradin\Files\Files; | > > > > > | > > > > > > > > > > > > > > > > > | | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | <?php namespace Garradin; use Garradin\Accounting\Charts; use Garradin\Entities\Accounting\Account; use Garradin\Entities\Accounting\Year; use Garradin\Entities\Users\Category; use Garradin\Entities\Users\User; use Garradin\Entities\Files\File; use Garradin\Entities\Search; use Garradin\Users\DynamicFields; use Garradin\Users\Session; use Garradin\Files\Files; use Garradin\UserTemplate\Modules; use Garradin\Plugins; use KD2\HTTP; /** * Pour procéder à l'installation de l'instance Garradin * Utile pour automatiser l'installation sans passer par la page d'installation */ class Install { /** * List of plugins that should be displayed during installation (if present) */ const DEFAULT_PLUGINS = [ 'caisse', 'taima', ]; const DEFAULT_MODULES = [ 'recus_fiscaux', 'carte_membre', 'recu_don', 'recu_paiement', //'bilan_pc', //'invoice', ]; /** * This sends the current installed version, as well as the PHP and SQLite versions * for statistics purposes. * * You can disable this by setting DISABLE_INSTALL_PING to TRUE in CONFIG_FILE */ static public function ping(): void { if (DISABLE_INSTALL_PING) { return; } |
︙ | ︙ | |||
50 51 52 53 54 55 56 | 'sqlite_options' => trim($options, ', '), ]); } /** * Reset the database to empty and create a new user with the same password */ | | | | 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 | 'sqlite_options' => trim($options, ', '), ]); } /** * Reset the database to empty and create a new user with the same password */ static public function reset(Users\Session $session, string $password, array $options = []) { $config = (object) Config::getInstance()->asArray(); $user = $session->getUser(); if (!$session->checkPassword($password, $user->passe)) { throw new UserException('Le mot de passe ne correspond pas.'); } if (!trim($config->org_name)) { throw new UserException('Le nom de l\'association est vide, merci de le renseigner dans la configuration.'); } if (!trim($user->identite)) { throw new UserException('L\'utilisateur connecté ne dispose pas de nom, merci de le renseigner.'); } |
︙ | ︙ | |||
92 93 94 95 96 97 98 | Config::deleteInstance(); DB::getInstance()->close(); DB::deleteInstance(); file_put_contents(CACHE_ROOT . '/reset', json_encode([ 'password' => $session::hashPassword($password), | | | | | 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | Config::deleteInstance(); DB::getInstance()->close(); DB::deleteInstance(); file_put_contents(CACHE_ROOT . '/reset', json_encode([ 'password' => $session::hashPassword($password), 'name' => $user->name(), 'email' => $user->email, 'organization' => $config->org_name, 'country' => $config->country, ])); rename(DB_FILE, sprintf(DATA_ROOT . '/association.%s.sqlite', date('Y-m-d-His-') . 'avant-remise-a-zero')); self::showProgressSpinner('!install.php', 'Remise à zéro en cours…'); exit; } |
︙ | ︙ | |||
159 160 161 162 163 164 165 | if (null === $source) { $source = $_POST; } self::assert(isset($source['name']) && trim($source['name']) !== '', 'Le nom de l\'association n\'est pas renseigné'); self::assert(isset($source['user_name']) && trim($source['user_name']) !== '', 'Le nom du membre n\'est pas renseigné'); self::assert(isset($source['user_email']) && trim($source['user_email']) !== '', 'L\'adresse email du membre n\'est pas renseignée'); | | | | > > > | | > > < | | > > | | | > | > | < < | < < | | < | > > < > > > > > > > | | | > | > > > > > > | | > > | > > > > > > > > | | > > > > > > > > > | > > > > > | > | | | 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 | if (null === $source) { $source = $_POST; } self::assert(isset($source['name']) && trim($source['name']) !== '', 'Le nom de l\'association n\'est pas renseigné'); self::assert(isset($source['user_name']) && trim($source['user_name']) !== '', 'Le nom du membre n\'est pas renseigné'); self::assert(isset($source['user_email']) && trim($source['user_email']) !== '', 'L\'adresse email du membre n\'est pas renseignée'); self::assert(isset($source['password']) && isset($source['password_confirmed']) && trim($source['password']) !== '', 'Le mot de passe n\'est pas renseigné'); self::assert((bool)filter_var($source['user_email'], FILTER_VALIDATE_EMAIL), 'Adresse email invalide'); self::assert(strlen($source['password']) >= User::MINIMUM_PASSWORD_LENGTH, 'Le mot de passe est trop court'); self::assert($source['password'] === $source['password_confirmed'], 'La vérification du mot de passe ne correspond pas'); $plugins = isset($source['plugins']) ? array_keys($source['plugins']) : []; $modules = isset($source['modules']) ? array_keys($source['modules']) : []; try { self::install($source['country'], $source['name'], $source['user_name'], $source['user_email'], $source['password'], $plugins, $modules); self::ping(); } catch (\Exception $e) { @unlink(DB_FILE); throw $e; } } static public function install(string $country_code, string $name, string $user_name, string $user_email, string $user_password, array $plugins = [], array $modules = []): void { if (file_exists(DB_FILE)) { throw new UserException('La base de données existe déjà.'); } self::checkAndCreateDirectories(); Files::disableQuota(); $db = DB::getInstance(); $db->requireFeatures('cte', 'json_patch', 'fts4', 'date_functions_in_constraints', 'index_expressions', 'rename_column', 'upsert'); // Création de la base de données $db->begin(); $db->exec('PRAGMA application_id = ' . DB::APPID . ';'); $db->setVersion(garradin_version()); $db->exec(file_get_contents(DB_SCHEMA)); $db->commit(); file_put_contents(SHARED_CACHE_ROOT . '/version', garradin_version()); $currency = $country_code == 'CH' ? 'CHF' : '€'; // Configuration de base $config = Config::getInstance(); $config->setCreateFlag(); $config->import([ 'org_name' => $name, 'org_email' => $user_email, 'currency' => $currency, 'country' => $country_code, 'site_disabled' => true, 'log_retention' => 365, 'analytical_set_all' => true, ]); $fields = DynamicFields::getInstance(); $fields->install(); // Create default category for common users $cat = new Category; $cat->setAllPermissions(Session::ACCESS_NONE); $cat->importForm([ 'name' => 'Membres actifs', 'perm_connect' => Session::ACCESS_READ, ]); $cat->save(); $config->set('default_category', $cat->id()); // Create default category for ancient users $cat = new Category; $cat->importForm([ 'name' => 'Anciens membres', 'hidden' => 1, ]); $cat->setAllPermissions(Session::ACCESS_NONE); $cat->save(); // Create default category for admins $cat = new Category; $cat->importForm([ 'name' => 'Administrateurs', ]); $cat->setAllPermissions(Session::ACCESS_ADMIN); $cat->save(); // Create first user $user = new User; $user->set('id_category', $cat->id()); $user->importForm([ 'numero' => 1, 'nom' => $user_name, 'email' => $user_email, 'pays' => 'FR', ]); $user->importSecurityForm(false, [ 'password' => $user_password, 'password_confirmed' => $user_password, ]); $user->save(); $config->set('files', array_map(fn () => null, $config::FILES)); $welcome_text = sprintf("Bienvenue dans l'administration de %s !\n\nUtilisez le menu à gauche pour accéder aux différentes sections.\n\nSi vous êtes perdu, n'hésitez pas à consulter l'aide :-)", $name); $config->setFile('admin_homepage', $welcome_text); // Import accounting chart $chart = Charts::installCountryDefault($country_code); // Create an example saved search (users) $query = (object) [ 'groups' => [[ 'operator' => 'AND', 'conditions' => [ [ 'column' => 'lettre_infos', 'operator' => '= 1', 'values' => [], ], ], ]], 'order' => 'numero', 'desc' => true, 'limit' => '10000', ]; $search = new Search; $search->import([ 'label' => 'Inscrits à la lettre d\'information', 'target' => $search::TARGET_USERS, 'type' => $search::TYPE_JSON, 'content' => json_encode($query), ]); $search->created = new \DateTime; $search->save(); // Create an example saved search (accounting) $query = (object) [ 'groups' => [[ 'operator' => 'AND', 'conditions' => [ [ 'column' => 'p.code', 'operator' => 'IS NULL', 'values' => [], ], ], ]], 'order' => 't.id', 'desc' => false, 'limit' => '100', ]; $search = new Search; $search->import([ 'label' => 'Écritures sans projet', 'target' => $search::TARGET_ACCOUNTING, 'type' => $search::TYPE_JSON, 'content' => json_encode($query), ]); $search->created = new \DateTime; $search->save(); $config->save(); // Install welcome plugin if available $has_welcome_plugin = Plugins::exists('welcome'); if ($has_welcome_plugin) { Plugins::install('welcome'); } foreach ($plugins as $plugin) { Plugins::install($plugin); } Modules::refresh(); foreach ($modules as $module) { $m = Modules::get($module); $m->set('enabled', true); $m->save(); } Files::enableQuota(); } static public function checkAndCreateDirectories() { // Vérifier que les répertoires vides existent, sinon les créer $paths = [ DATA_ROOT, PLUGINS_ROOT, CACHE_ROOT, SHARED_CACHE_ROOT, USER_TEMPLATES_CACHE_ROOT, STATIC_CACHE_ROOT, SMARTYER_CACHE_ROOT, SHARED_USER_TEMPLATES_CACHE_ROOT, ]; foreach ($paths as $path) { $index_file = $path . '/index.html'; Utils::safe_mkdir($path, 0777, true); if (!is_dir($path)) { throw new \RuntimeException('Le répertoire '.$path.' n\'existe pas ou n\'est pas un répertoire.'); } // On en profite pour vérifier qu'on peut y lire et écrire if (!is_writable($path) || !is_readable($path)) { throw new \RuntimeException('Le répertoire '.$path.' n\'est pas accessible en lecture/écriture.'); } if (file_exists($index_file) AND (!is_writable($index_file) || !is_readable($index_file))) { throw new \RuntimeException('Le fichier ' . $index_file . ' n\'est pas accessible en lecture/écriture.'); } // Some basic safety against misconfigured hosts file_put_contents($index_file, '<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"><html><head><title>404 Not Found</title></head><body><h1>Not Found</h1><p>The requested URL was not found on this server.</p></body></html>'); } return true; } static public function setLocalConfig(string $key, $value, bool $overwrite = true): void { $path = ROOT . DIRECTORY_SEPARATOR . CONFIG_FILE; $new_line = sprintf('const %s = %s;', $key, var_export($value, true)); if (@filesize($path)) { $config = file_get_contents($path); $pattern = sprintf('/^.*(?:const\s+%s|define\s*\(.*%1$s).*$/m', $key); |
︙ | ︙ | |||
389 390 391 392 393 394 395 396 397 398 399 | $next = $next ? sprintf('<meta http-equiv="refresh" content="0;url=%s" />', Utils::getLocalURL($next)) : ''; printf('<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <style type="text/css"> body { font-family: sans-serif; } h2, p { | > > > > > > > > > < < > | | | | > | > | 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 | $next = $next ? sprintf('<meta http-equiv="refresh" content="0;url=%s" />', Utils::getLocalURL($next)) : ''; printf('<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <style type="text/css"> * { padding: 0; margin: 0; } html { height: 100%%; } body { font-family: sans-serif; text-align: center; display: flex; align-items: center; justify-content: center; height: 100%%; } h2, p { margin-bottom: 1rem; } div { position: relative; max-width: 500px; padding: 1em; border-radius: .5em; background: #ccc; } .spinner h2::after { display: block; content: " "; margin: 1rem auto; width: 50px; height: 50px; border: 5px solid #999; border-radius: 50%%; border-top-color: #000; animation: spin 1s ease-in-out infinite; } @keyframes spin { to { transform: rotate(360deg); } } </style> %s </head> <body> <div class="spinner"> <h2>%s</h2> </div>', $next, nl2br(htmlspecialchars($message))); flush(); } } |
Added src/include/lib/Garradin/Log.php version [012a4138b7].
> > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | <?php namespace Garradin; use Garradin\Config; use Garradin\DB; use Garradin\Users\DynamicFields; use Garradin\Users\Session; class Log { /** * How many seconds in the past should we look for failed attempts? * @var int */ const LOCKOUT_DELAY = 20*60; /** * Number of maximum login attempts in that delay * @var int */ const LOCKOUT_ATTEMPTS = 10; const SOFT_LOCKOUT_ATTEMPTS = 3; const LOGIN_FAIL = 1; const LOGIN_SUCCESS = 2; const LOGIN_RECOVER = 3; const LOGIN_PASSWORD_CHANGE = 4; const LOGIN_CHANGE = 5; const LOGIN_AS = 6; const CREATE = 10; const DELETE = 11; const EDIT = 12; const ACTIONS = [ self::LOGIN_FAIL => 'Connexion refusée', self::LOGIN_SUCCESS => 'Connexion réussie', self::LOGIN_RECOVER => 'Mot de passe perdu', self::LOGIN_PASSWORD_CHANGE => 'Modification de mot de passe', self::LOGIN_CHANGE => 'Modification d\'identifiant', self::LOGIN_AS => 'Connexion par un administrateur', self::CREATE => 'Création', self::DELETE => 'Suppression', self::EDIT => 'Modification', ]; static public function add(int $type, ?array $details = null, int $id_user = null): void { if (defined('Garradin\INSTALL_PROCESS')) { return; } if ($type != self::LOGIN_FAIL) { $keep = Config::getInstance()->log_retention; // Don't log anything if ($keep == 0) { return; } } $ip = Utils::getIP(); $session = Session::getInstance(); $id_user ??= Session::getUserId(); DB::getInstance()->insert('logs', [ 'id_user' => $id_user, 'type' => $type, 'details' => $details ? json_encode($details) : null, 'ip_address' => $ip, ]); } static public function clean(): void { $config = Config::getInstance(); $db = DB::getInstance(); $days_delete = $config->log_retention; // Delete old logs according to configuration $db->exec(sprintf('DELETE FROM logs WHERE type != %d AND type != %d AND created < datetime(\'now\', \'-%d days\');', self::LOGIN_FAIL, self::LOGIN_RECOVER, $days_delete)); // Delete failed login attempts and reminders after 30 days $db->exec(sprintf('DELETE FROM logs WHERE type = %d OR type = %d AND created < datetime(\'now\', \'-%d days\');', self::LOGIN_FAIL, self::LOGIN_RECOVER, 30)); } /** * Returns TRUE if the current IP address has done too many failed login attempts * @return int 1 if banned from logging in, -1 if a captcha should be presented, 0 if no restriction is in place */ static public function isLocked(): int { $ip = Utils::getIP(); // is IP locked out? $sql = sprintf('SELECT COUNT(*) FROM logs WHERE type = ? AND ip_address = ? AND created > datetime(\'now\', \'-%d seconds\');', self::LOCKOUT_DELAY); $count = DB::getInstance()->firstColumn($sql, self::LOGIN_FAIL, $ip); if ($count >= self::LOCKOUT_ATTEMPTS) { return 1; } if ($count >= self::SOFT_LOCKOUT_ATTEMPTS) { return -1; } return 0; } static public function list(array $params = []): DynamicList { $id_field = DynamicFields::getNameFieldsSQL('u'); $columns = [ 'id_user' => [ ], 'identity' => [ 'label' => isset($params['id_self']) ? null : (isset($params['history']) ? 'Membre à l\'origine de la modification' : 'Membre'), 'select' => $id_field, ], 'created' => [ 'label' => 'Date' ], 'type_icon' => [ 'select' => null, 'order' => null, 'label' => '', ], 'type' => [ 'label' => 'Action', ], 'details' => [ 'label' => 'Détails', ], 'ip_address' => [ 'label' => 'Adresse IP', ], ]; $tables = 'logs LEFT JOIN users u ON u.id = logs.id_user'; if (isset($params['id_user'])) { $conditions = 'logs.id_user = ' . (int)$params['id_user']; } elseif (isset($params['id_self'])) { $conditions = sprintf('logs.id_user = %d AND type < 10', (int)$params['id_self']); } elseif (isset($params['history'])) { $conditions = sprintf('logs.type IN (%d, %d, %d) AND json_extract(logs.details, \'$.entity\') = \'Users\\User\' AND json_extract(logs.details, \'$.id\') = %d', self::CREATE, self::EDIT, self::DELETE, (int)$params['history']); } else { $conditions = '1'; } $list = new DynamicList($columns, $tables, $conditions); $list->orderBy('created', true); $list->setCount('COUNT(logs.id)'); $list->setModifier(function (&$row) { $row->created = \DateTime::createFromFormat('!Y-m-d H:i:s', $row->created); $row->details = $row->details ? json_decode($row->details) : null; $row->type_label = self::ACTIONS[$row->type]; if (isset($row->details->entity) && defined('Garradin\Entities\\' . $row->details->entity . '::NAME')) { $row->entity_name = constant('Garradin\Entities\\' . $row->details->entity . '::NAME'); } if (isset($row->details->id, $row->details->entity) && constant('Garradin\Entities\\' . $row->details->entity . '::PRIVATE_URL')) { $row->entity_url = sprintf(constant('Garradin\Entities\\' . $row->details->entity . '::PRIVATE_URL'), $row->details->id); } }); return $list; } } |
Deleted src/include/lib/Garradin/Membres.php version [cb2a25721d].
|
|