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




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
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>>





La syntaxe utilisée dans Paheko pour les squelettes du site web 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.










# 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}}`.


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, mais `escape` est utilisé pour changer l'ordre d'échappement. Exemple :

```
{{:assign text = "Coucou
ça va ?" }}
{{$text|escape|nl2br}}
```

Donnera bien `Coucou<br />ça va ?`. Sans indiquer le filtre `escape` le résultat serait `Coucou&lt;br /&gt;ça va ?`.










### 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}}
      <a href="{{$url}}"><img src="{{$thumb_url}}" alt="{{$title}}" /></a>
    {{/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.








































































































## 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}}
```















## Fonctions



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 `/`).












>
>
>
>
|



>
>
>
>
>
>
>
>
>












|
>



|


|
<



|

>
>
>
>
>
>
>
>
>
|


















|







|









>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>



|










>
>
>
>
>
>
>
>
>
>
>
>
>
>



>
>
|
>
>







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&lt;br /&gt;ç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
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


















































Un exemple de sous-section

```
{{#categories uri=$_GET.uri}}
    <h1>{{$title}}</h1>

    {{#articles parent=$path order="published DESC" limit="10"}}
        <h2><a href="{{$url}}">{{$title}}</a></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.

## Sections litérales

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}}
*}}
```


## Référence des variables définies par défaut

Ces variables sont définies tout le temps :

| Nom | Contenu |
| - | - |
| `$_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é). |



| `$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éfinit dans la personnalisation |

























































|










|













>
|













>
|



|
|







>
>
>






|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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


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
















































































































































































































































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>>



# assign

Permet d'assigner une valeur dans une variable :












```
{{:assign blabla="Coucou"}}

{{$blabla}}
```

Attention : certains noms de variables ne sont pas assignables : `value` et `var` (voir ci-dessous).

Il est possible d'assigner toutes les variables d'une section dans une variable en utilisant la syntaxe `.` et en inversant (`.="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}}
```















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}}
```
















# 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.


















# 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 | Fonction |
| - | - |
| `code` | Modifie le code HTTP renvoyé. [Liste des codes HTTP](https://fr.wikipedia.org/wiki/Liste_des_codes_HTTP) |
| `redirect` | 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` | Modifie le type MIME renvoyé |
| `download` | 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ètre | Fonction |
| - | - |
| `file` | obligatoire | Nom du squelette à inclure |
| `keep` | optionnel | Liste de noms de variables à conserver |



```

{{:include file="./navigation.html"}}
=> Affiche le contenu du squelette "navigation.html" dans le même répertoire que le squelette d'origine
```

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 incluse dans le 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}}
```


# mail







Permet d'envoyer un e-mail à une adresse indiquée. Le message est toujours envoyé en format texte et l'expéditeur est l'adresse de l'association.





Attention à l'utilisation de cette fonction il n'existe pas de limite d'envoi.




Paramètres requis :






| Paramètre | Fonction |
| - | - |


















































| `to` | Adresse email destinataire (seule l'adresse e-mail elle-même est acceptée, pas de nom) |
| `subject` | Sujet du message |
| `body` | Corps du message |










Exemple de formulaire de contact :

```
{{if !$_POST.email|check_email}}
<p class="alert">L'adresse e-mail indiquée est invalide.</p>
{{elseif $_POST.antispam != 42}}
<p class="alert">La réponse permettant de savoir si vous êtes un robot a échoué. Vous êtes donc un robot ?</p>
{{elseif $_POST.message|trim == ''}}
<p class="alert">Le message est vide</p>
{{elseif $_POST.send}}

{{:mail to=$config.org_email subject="Formulaire de contact" body="%s a écrit : %s"|args:$_POST.email:$_POST.message}}
<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><label>Merci d'écrire "quarante-deux" en chiffres pour confirmer que vous n'êtes pas un robot : <input type="text" name="antispam" required /></label></dt>
</dl>
<p><input type="submit" name="send" value="Envoyer !" /></p>
</form>
```



























































































































































































































































>
>
|

|
>
>
>
>
>
>
>
>
>
>
>







<
<
|








>
>
>
>
>
>
>
>
>
>
>
>
>
>















|


|
|






>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|

















>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|



|
|
|
|
|
|












|



>
>
|
|
|
|
>
>


>

<
















|














>
|
>
>
>
>
>

>
|
>
>
>

>
|
>

>
>
|
>
>
>

>
>

|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
|
|
>
>
>
>
>
>
>
>
>





|
<
<

|

>
|
|






|




>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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

![](shapes.png)

Modified doc/admin/brindille_modifiers.md from [dc87155bc4] to [f777fa9608].

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
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 :






































* strtolower
* strtoupper
* ucfirst
* ucwords
* htmlentities

* htmlspecialchars
* trim, ltrim, rtrim
* lcfirst
* md5
* sha1

* metaphone

* soundex
* str_split

* str_word_count

* strrev

* strlen


* strip_tags


* nl2br
* wordwrap
* strlen

* abs



# Filtres de texte

## truncate


Arguments :






* nombre : longueur en nombre de caractères (défaut = 80)

* texte : texte à placer à la fin (si tronqué) (défaut = …)




* booléen : coupure stricte, si `true` alors un mot pourra être coupé en deux, sinon le texte sera coupé au dernier mot complet



Tronque un texte à une longueur définie.




## 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>
```

## 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 javascriptsactivé).

## 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).




## xml_escape


Échappe le contenu pour un usage dans un document XML.




## 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"}} = &lt;b&gt;Test
{{"<b>Test"|raw}} = <b>Test
```


## 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'.






```






















































## count


Compte le nombre d'entrées dans un tableau.



























































```


{{$products|count}}

= 5








```








































## cat
















Concaténer un texte avec un autre.
















































```


{{"Tangerine"|cat:" Dream"}}

= Tangerine Dream




```









## 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













|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

<
<
<
|
|
>
|
|
|
|
|
>
|
>
|
|
>
|
>
|
>
|
>
>
|
>
>
|
|
|
>
|
>
>



|

>
|
>
>
>
>

>
|
>
|
>
>
>
>
|
>

>
|
>
>
>













|

|

|

|
>
>
>

<
>

<
>
>
>










>
|

>
|
>
>
>
>

>
>
>
>

>
>
>
|
|
>
>
>
>
>
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

>
>
>
>
>
>
>
>
|

>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>


>
>
|
>
|
>
>
>
>
>
>
>
>

>
>
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>

>
|
>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

>
>
|
>
|
>
>
>
>

>
>
>
>
>
>
>
>







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 aubut 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"}} = &lt;b&gt;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&nbsp;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&nbsp;€</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
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
{{"1+%d"|math:$age}}
= 43
{{:assign prix=39.99 tva=19.1}}
{{"round(%f*%f, 2)"|math:$prix:$tva}}
= 47.63
```

Il est aussi possible d'utiliser 

## 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


```

## 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
```

## typo

Pour le français.

Ajoute des espaces insécables (`&nbsp;`) devant ou derrière les ponctuations françaises (`« » ? ! :`).

## money

Formatte une valeur de monnaie (exprimée avec les cents inclus : `1502` = 15,02) pour l'affichage :

```
{{$amount|money}}
15,02
```

## money_currency

Formatte une valeur de monnaie en ajoutant la devise :

```
{{$amount|money_currency}}
15,02 €
```

## 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
```

## extract_leading_number

Extrait le numéro du titre.

## 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}}
```

## Filtres de date

* `date` : formatte une date selon un format spécifié (identique au [format utilisé par PHP](https://www.php.net/manual/fr/datetime.format.php)). Si aucun format n'est utilisé, le défaut sera `d/m/Y à H:i`. (en français)
* `strftime` : formatte une date selon un format spécifié, 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 second paramètre, l'heure sera ajoutée au format `14h34`.
* `date_short` : date au format court : `d/m/Y`, en spécifiant `true` en second paramètre l'heure sera ajoutée : `à H\hi`.
* `date_long` : date au format long : `lundi 2 janvier 2021`. En spécifiant `true` en second paramètre l'heure sera ajoutée : `à 20h42`.
* `date_hour` : renvoie l'heure uniquement à partir d'une date : `20h00`. En passant `true` en second 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`

## Filtres de condition

Ces filtres renvoient `1` si la condition est remplie, ou `0` sinon. Ils peuvent être utilisés dans les conditions :

```
{{if $page.path|match:"/aide"}}Bienvenue dans l'aide !{{/if}}
```

* `match` renvoie `1` si le texte indiqué est trouvé (insensible à la casse)
* `regexp_match`, idem mais avec une expression régulière (de type `/Bonjour|revoir/i`)







<
<
|

<
>


<
<
>
>


<
|
<


<
<
>
>












<
|
<

<
|
<

<
|
<
<
<
<

<
<
<
<

<
<
<
|
<
<
<
<
<
<

<
<
<

<
|
<

<
<
<
<
<
<

<
<
<
<
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<

<
<
<
<
<
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
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

































































































































































































































<<toc aside level=2>>

# Sections généralistes

## foreach

Permet d'itérer sur un tableau par exemple :









```






{{#foreach from=$variable key="key" item="value"}}
{{$key}} = {{$value}}
{{/foreach}}
```




























## 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 | Fonction |
| - | - |
| `level` | Niveau d'accès : read, write, admin |
| `section` | Section où le niveau d'accès doit s'appliquer : users, accounting, web, documents, config |




```
{{#restrict}}
	Un membre est connecté, mais on ne sait pas avec quels droits.
{{else}}
	Aucun membre n'est connecté.
{{/restrict}}
```



```
{{#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}}
```



```
{{#restrict block=true section="accounting" level="write"}}
{{/restrict}}

Si le membre n'est pas connecté ou n'a pas le droit de modifier la compta, il aura une page d'erreur.












```









# Sections SQL









Dans toutes les sections héritées de `sql` suivantes 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. |

| `order` | Ordre de tri des résultats. Si vide le tri sera fait par ordre d'ajout dans la base de données. |












| `debug` | Si ce paramètre existe, 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é"}}`
















## sql


Effectue une requête SQL de type `SELECT` dans la base de données.

```
{{#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ètres possibles

| Paramètre | Fonction |
| - | - |
| `tables` | Liste des tables à utiliser dans la requête. Ce paramètre est obligatoire. |
| `select` | Liste des colonnes à sélectionner, si non spécifié, toutes les colonnes (`*`) seront sélectionnées |
| `group` | Contenu de la clause `GROUP BY` |

## pages, articles, categories (héritent de sql)

* `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 les trois types de section se comportent de manière identique

### Paramètres possibles

| 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` | Indiquer ici le chemin d'article ou de catégorie parente. Utile pour renvoyer par exemple la liste des articles d'une catégorie. |

## files, documents, images (héritent de sql)

* `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 les 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ètres possibles




| Paramètre | Fonction |
| - | - |
| `parent` (obligatoire) | Chemin (adresse unique) de l'article ou catégorie parente dont ont veut lister les fichiers |
| `except_in_text` | passer `true` à ce paramètre , et seuls les fichiers qui ne sont pas liés dans le texte de la page seront renvoyés |

## 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

```

{{#breadcrumbs path=$page.path}}
&rarr; <a href="{{ $url }}">{{ $title }}</a><br />
{{/breadcrumbs}}

```







































































































































































































































|

>
>
>
>
>
>
>
>

>
>
>
>
>
>
|



>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







|
|
|
|
>
>
>








>
>









>
>



>
|
>
>
>
>
>
>
>
>
>
>
>
>

>
>
>
>
>
>
>

>
|
>
>
>
>
>
>
>

>
|
>

>
>
>
>
>
>
|
>
|
>
|
>
>
>
>
|
>
|
>
|
>
>
>
>
>
>
>
>
>
>
>
>

>
>

>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>



>
|







<
<
|
|
|
|
<

<
|
<
<
<

<
|
<


|
>
>
>
>
|
<
<
|
<
|
<
<
<
|
<

<
>

<
>
>
>

|
<
<
<








|





|







>

|

>

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
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


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 (code *Brindille*, CSS, etc.) 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**.

## 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/)
* [Jardin du bon pasteur](https://jardindubonpasteur.fr)

## 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.

Dans la gestion des squelettes, seuls les fichiers ayant une des extensions `tpl, btpl, html, htm, b, skel, 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 non-texte.

Ainsi, nous appelons ici *squelette* tout fichier situé dans l'onglet **Configuration**, mais seuls les fichiers traités par Brindille sont de "vrais" squelettes au sens code exécutable par *Brindille*. Les autres ne sont pas traités ni exécutés : ils ne peuvent pas contenir de code Brindille.

### Adresses des pages du site

Les squelettes sont appelés en fonction des règles suivantes (dans l'ordre) :

| Adresse | Squelette appelé |
| ---- | ---- |

| `/` (racine du site) | `index.html` |
| Toute autre adresse se terminant par un slash `/` | `category.html` |
| Toute autre adresse, si un article existe avec cette URI | `article.html` |
| Toute autre adresse, si un squelette du même nom existe | Squelettes du même nom |


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. Sinon si un squelette `Actualite` (sans extension) existe, c'est lui qui sera appelé.

Autre exemple : `https://monsite.paheko.cloud/atom.xml` appellera le squelette `atom.xml` vu qu'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).

### Squelette 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.













|

|

|






<

|







<
<
<
<
<
<
|



|
|
>
|
|
|
<
>

|

|





|

|
>
>
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
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
	## /garradin/ par le nom du sous-répertoire où est installé Garradin
 	RewriteBase /garradin/
	FallbackResource /garradin/www/_route.php

	## Ne pas modifier les lignes suivantes, les décommenter simplement !
	RewriteCond %{REQUEST_URI} !www/
	RewriteRule ^(.*)$ www/$1 [QSA,L]
</IfModule>







|
|
|





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
48






49
50
51
52
53
54
55
	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






	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







|
>
>
>
>
>
>







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.2.7
|
1
1.3.0-alpha1

Modified src/config.dist.php from [fee27d0d97] to [65125f1971].

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
<?php

/**
 * Ce fichier représente un exemple des constantes de configuration
 * disponibles pour Garradin.
 *
 * NE PAS MODIFIER CE FICHIER!
 *
 * Pour configurer Garradin, 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 Garradin
 *
 * 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, Garradin ajoutera automatiquement
 * une valeur aléatoire dans le fichier config.local.php.
 */

//const SECRET_KEY = '3xUhIgGwuovRKOjVsVPQ5yUMfXUSIOX2GKzcebsz5OINrYC50r';

/**




 * Se connecter automatiquement avec l'ID de membre indiqué


 * Exemple: LOCAL_LOGIN = 42 connectera automatiquement le membre 42
 * Attention à ne pas utiliser en production !
 *
 * Il est aussi possible de mettre "LOCAL_LOGIN = -1" pour se connecter
 * avec le premier membre trouvé qui peut gérer la configuration (et donc
 * modifier les droits des membres).

 *










 * Défault : false (connexion automatique désactivée)
 */

//const LOCAL_LOGIN = false;

/**
 * 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;

/**
 * Doit-on suggérer à l'utilisateur d'utiliser la version chiffrée du site ?
 *
 * 1 ou true = affiche un message de suggestion sur l'écran de connexion invitant à utiliser le site chiffré
 * (conseillé si vous avez un certificat auto-signé ou peu connu type CACert)
 * 2 = rediriger automatiquement sur la version chiffrée pour l'administration (mais pas le site public)
 * 3 = rediriger automatiquement sur la version chiffrée pour administration et site public
 * false ou 0 = aucune version chiffrée disponible, donc ne rien proposer ni rediriger
 *
 * Défaut : false
 */

//const PREFER_HTTPS = false;

/**
 * Répertoire où se situe le code source de Garradin
 *
 * Défaut : répertoire racine de Garradin (__DIR__)
 */

//const ROOT = __DIR__;

/**
 * Répertoire où sont situées les données de Garradin
 * (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
 * Garradin 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';

/**





















 * Emplacement du fichier de base de données de Garradin
 *
 * Défaut : DATA_ROOT . '/association.sqlite'
 */

//const DB_FILE = DATA_ROOT . '/association.sqlite';

/**




|



|







|







|






>
>
>
>
|
>
>
|


<
|
|
>

>
>
>
>
>
>
>
>
>
>
|


|


















<
<
<
<
<
<
<
<
<
<
<
<
<
<
|

|





|


















|








>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|







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
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
 * 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 Garradin
 * (doit se terminer par un slash)
 *
 * Défaut : découverte automatique à partir de SCRIPT_NAME
 */

//const WWW_URI = '/asso/';

/**
 * Adresse URL HTTP(S) de Garradin
 *
 * Défaut : découverte à partir de HTTP_HOST ou SERVER_NAME + WWW_URI
 */

//const WWW_URL = 'http://garradin.chezmoi.tld' . WWW_URI;

/**
 * Adresse URL HTTP(S) de l'admin Garradin
 *
 * Défaut : WWW_URL + 'admin/'
 */

//const ADMIN_URL = 'https://admin.garradin.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







|








|




|


|




|







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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
 *
 * Défaut : false
 */

//const MAIL_ERRORS = false;

/**
 * Envoi des erreurs à une API compatible AirBrake/Errbit/Garradin
 *
 * 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é.
 *
 * Garradin accepte aussi les rapports d'erreur venant d'autres instances.
 *
 * Pour cela utiliser l'URL https://login:password@garradin.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
 */







|






|

|







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
315
316
317
318
319
320
321
322
 * @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
 * la mise à jour, Garradin proposera de se rendre sur le site officiel pour
 * télécharger la mise à jour.
 *
 * Défaut : true
 *
 * @var bool
 */








>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>












|







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
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
 * - 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 Garradin:
 * XSendFile On
 * XSendFilePath /var/www/garradin
 *
 * (remplacer le chemin par le répertoire racine de Garradin)
 *
 * 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';


















/**
 * Hôte du serveur SMTP, mettre à false (défaut) pour utiliser la fonction
 * mail() de PHP
 *
 * Défaut : false
 */







|

|

|


















>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
409
410
411
412
413
414
415
416
 * Login utilisateur pour le server SMTP
 *
 * mettre à null pour utiliser un serveur local ou anonyme
 *
 * Défaut : null
 */

//const SMTP_USER = 'garradin@monserveur.com';

/**
 * Mot de passe pour le serveur 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
534
535
536
537
538
539
540
541
 */
//const DISABLE_INSTALL_FORM = false;

/**
 * Stockage des fichiers
 *
 * Indiquer ici le nom d'une classe de stockage de fichiers
 * (parmis celles disponibles dans lib/Garradin/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







|







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








583

584

585


586
587
588
589
590
591
592
593
594
595
 *
 * 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

/**








 * PDF_COMMAND

 * Commande de création de PDF

 *


 * Commande qui sera exécutée pour créer un fichier PDF à partir d'un HTML.
 *
 * Si laissé sur 'auto', Garradin 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'.







>
>
>
>
>
>
>
>
|
>
|
>
|
>
>


|







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
631
632
633
634
635
636
637
638
639
640
641
 * 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
 *
 * Garradin gère nativement les exports en ODS (OpenDocument : LibreOffice)
 * et CSV, et imports en CSV.
 *
 * En indiquant ici le nom d'un outil, Garradin 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.
 *







>
>
>
>




















|


|







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
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
//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@garradin.association.tld/api/...
 * Voir https://fossil.kd2.org/garradin/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 Garradin,
 * 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.







|
|















|







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].

1
2
3
4
5
6
7
8
9
10
11
12
13
ALTER TABLE fichiers_membres RENAME TO fichiers_membres_old;
ALTER TABLE fichiers_wiki_pages RENAME TO fichiers_wiki_pages_old;
ALTER TABLE fichiers_acc_transactions RENAME TO fichiers_acc_transactions_old;

.read 1.0.0_schema.sql

INSERT INTO fichiers_membres SELECT * FROM fichiers_membres_old;
INSERT INTO fichiers_wiki_pages SELECT * FROM fichiers_wiki_pages_old;
INSERT INTO fichiers_acc_transactions SELECT * FROM fichiers_acc_transactions_old;

DROP TABLE fichiers_membres_old;
DROP TABLE fichiers_wiki_pages_old;
DROP TABLE fichiers_acc_transactions_old;
<
<
<
<
<
<
<
<
<
<
<
<
<


























Deleted src/include/data/1.0.0-beta8_migration.sql version [ff1b70a076].

1
2
UPDATE acc_accounts SET type = 11 WHERE code = '120';
UPDATE acc_accounts SET type = 12 WHERE code = '129';
<
<




Deleted src/include/data/1.0.0-rc10_migration.sql version [ab6262425c].

1
2
3
4
UPDATE acc_accounts SET type = 8, position = 4 WHERE id_chart = (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '86_%');
UPDATE acc_accounts SET type = 8, position = 5 WHERE id_chart = (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '87_%');

UPDATE acc_accounts SET position = 3 WHERE code IN ('5112', '5115', '530') AND code IS NOT NULL;
<
<
<
<








Deleted src/include/data/1.0.0-rc14_migration.sql version [2be7aec0e4].

1
2
3
4
-- Put 890, 891 accounts in balance sheet, though it's not really correct...
UPDATE acc_accounts SET position = 3 WHERE
	code IN (890, 891)
	AND id_chart IN (SELECT id FROM acc_charts WHERE code IS NOT NULL);
<
<
<
<








Deleted src/include/data/1.0.0-rc16_migration.sql version [2408d33901].

1
2
UPDATE acc_accounts SET position = 1 WHERE code = '486' AND id_chart IN (SELECT id FROM acc_charts WHERE code = 'PCA2018');
UPDATE acc_accounts SET position = 2 WHERE code = '487' AND id_chart IN (SELECT id FROM acc_charts WHERE code = 'PCA2018');
<
<




Deleted src/include/data/1.0.0-rc3_migration.sql version [46b4d521e5].

1
UPDATE acc_transactions SET type = 0 WHERE type = 6;
<


Deleted src/include/data/1.0.0_migration.sql version [5b1e5033eb].

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
ALTER TABLE membres_operations RENAME TO membres_operations_old;
ALTER TABLE membres_categories RENAME TO membres_categories_old;

DROP TABLE fichiers_compta_journal; -- Inutilisé à ce jour

-- Fix: comptes de clôture et fermeture
UPDATE compta_comptes SET libelle = 'Bilan d''ouverture' WHERE id = '890' AND libelle = 'Bilan de clôture';
INSERT OR REPLACE INTO compta_comptes (id, parent, libelle, position) VALUES ('891', '89', 'Bilan de clôture', 0);

-- N'est pas utilisé
DELETE FROM config WHERE cle = 'categorie_dons' OR cle = 'categorie_cotisations';

.read 1.0.0_schema.sql

-------- MIGRATION COMPTA ---------
INSERT INTO acc_charts (id, country, code, label) VALUES (1, 'FR', 'PCGA1999', 'Plan comptable associatif 1999');

-- Migration comptes de code comme identifiant à ID unique
-- Inversement valeurs actif/passif et produit/charge
INSERT INTO acc_accounts (id, id_chart, code, label, position, user)
	SELECT NULL, 1, id, libelle,
	CASE
		WHEN position = 1 THEN 2
		WHEN position = 2 THEN 1
		WHEN position = 3 THEN 3
		WHEN position = 4 THEN 5
		WHEN position = 8 THEN 4
		-- Suppression de la position "charge ou produit" qui n'a aucun sens
		WHEN position = 12 AND id LIKE '6%' THEN 4
		WHEN position = 12 AND id LIKE '7%' THEN 5
		WHEN position = 12 THEN 0
		ELSE 0
	END,
	CASE WHEN plan_comptable = 1 THEN 0 ELSE 1 END
	FROM compta_comptes;

-- Migrations projets vers comptes analytiques
INSERT INTO acc_accounts (id_chart, code, label, position, user, type)
	VALUES (1, '99', 'Projets', 0, 1, 0);

INSERT INTO acc_accounts (id_chart, code, label, position, user, type)
	SELECT 1, '99' || substr('0000' || id, -4), libelle, 0, 1, 7 FROM compta_projets;

-- Mise à jour de la position pour les comptes de tiers qui peuvent varier actif ou passif
UPDATE acc_accounts SET position = 3 WHERE code IN (4010, 4110, 4210, 428, 438);

-- Mise à jour position comptes bancaires, qui peuvent être à découvert et donc changer de côté au bilan
UPDATE acc_accounts SET position = 3 WHERE code LIKE '512%';

-- Migration comptes bancaires
UPDATE acc_accounts SET type = 1 WHERE code IN (SELECT id FROM compta_comptes_bancaires);

-- Caisse
UPDATE acc_accounts SET type = 2 WHERE code = '530';

-- Chèques et carte à encaisser
UPDATE acc_accounts SET type = 3 WHERE code = '5112' OR code = '5113';

-- Comptes d'ouverture et de clôture
UPDATE acc_accounts SET type = 9, position = 0 WHERE code = '890';
UPDATE acc_accounts SET type = 10, position = 0 WHERE code = '891';

-- Comptes de tiers
UPDATE acc_accounts SET type = 4 WHERE code IN (SELECT id FROM compta_comptes WHERE id LIKE '4%' AND plan_comptable = 0 AND desactive = 0);

-- Recopie des mouvements
INSERT INTO acc_transactions (id, label, notes, reference, date, id_year, id_creator)
	SELECT id, libelle, remarques, numero_piece, date, id_exercice, id_auteur
	FROM compta_journal;

-- Recettes
UPDATE acc_transactions SET type = 1 WHERE id IN (SELECT id FROM compta_journal WHERE id_categorie IN (SELECT id FROM compta_categories WHERE type = 1));

-- Dépenses
UPDATE acc_transactions SET type = 2 WHERE id IN (SELECT id FROM compta_journal WHERE id_categorie IN (SELECT id FROM compta_categories WHERE type = -1));

-- Virements
UPDATE acc_transactions SET type = 3 WHERE id IN (SELECT id FROM compta_journal
	WHERE (compte_credit IN ('530', '5112', '5115') OR compte_credit LIKE '512%')
	AND (compte_debit IN ('530', '5112', '5115') OR compte_debit LIKE '512%'));

-- Dettes
UPDATE acc_transactions SET type = 4 WHERE id IN (SELECT id FROM compta_journal WHERE compte_debit LIKE '6%' AND compte_credit LIKE '4%');

-- Créances
UPDATE acc_transactions SET type = 5 WHERE id IN (SELECT id FROM compta_journal WHERE compte_credit LIKE '7%' AND compte_debit LIKE '4%');

-- Création des lignes associées aux mouvements
INSERT INTO acc_transactions_lines (id_transaction, id_account, debit, credit, reference, id_analytical)
	SELECT id, (SELECT id FROM acc_accounts WHERE code = compte_credit), 0, CAST(REPLACE(montant * 100, '.0', '') AS INT), numero_cheque,
	CASE WHEN id_projet IS NOT NULL THEN (SELECT id FROM acc_accounts WHERE code = '99' || substr('0000' || id_projet, -4)) ELSE NULL END
	FROM compta_journal;

INSERT INTO acc_transactions_lines (id_transaction, id_account, debit, credit, reference, id_analytical)
	SELECT id, (SELECT id FROM acc_accounts WHERE code = compte_debit), CAST(REPLACE(montant * 100, '.0', '') AS INT), 0, numero_cheque,
	CASE WHEN id_projet IS NOT NULL THEN (SELECT id FROM acc_accounts WHERE code = '99' || substr('0000' || id_projet, -4)) ELSE NULL END
	FROM compta_journal;

-- Recopie des descriptions de catégories dans la table des comptes, et mise des comptes en signets
-- +Fix éventuels types qui ne correspondent pas à leur type… (@Fred C.) (... a.position = X)
-- Revenus
UPDATE acc_accounts SET type = 6, description = (SELECT description FROM compta_categories WHERE compte = acc_accounts.code)
	WHERE id IN (SELECT a.id FROM acc_accounts a INNER JOIN compta_categories c ON c.compte = a.code AND c.type = 1 AND a.position = 5);

-- Dépenses
UPDATE acc_accounts SET type = 5, description = (SELECT description FROM compta_categories WHERE compte = acc_accounts.code)
	WHERE id IN (SELECT a.id FROM acc_accounts a INNER JOIN compta_categories c ON c.compte = a.code AND c.type = -1 AND c.compte NOT LIKE '4%' AND a.position = 4);

-- Tiers
UPDATE acc_accounts SET type = 4, description = (SELECT description FROM compta_categories WHERE compte = acc_accounts.code)
	WHERE id IN (SELECT a.id FROM acc_accounts a INNER JOIN compta_categories c ON c.compte = a.code AND c.type = -1 AND c.compte LIKE '4%');

-- Recopie des exercices, mais la date de fin ne peut être nulle
INSERT INTO acc_years (id, label, start_date, end_date, closed, id_chart)
	SELECT id, libelle, debut, CASE WHEN fin IS NULL THEN date(debut, '+1 year') ELSE fin END, cloture, 1 FROM compta_exercices;

-- Recopie des catégories, on supprime la colonne id_cotisation_obligatoire
INSERT INTO membres_categories
	SELECT id, nom, droit_wiki, droit_membres, droit_compta, droit_inscription, droit_connexion, droit_config, cacher FROM membres_categories_old;

DROP TABLE membres_categories_old;

-- Transfert des rapprochements
UPDATE acc_transactions_lines SET reconciled = 1 WHERE id_transaction IN (SELECT id_operation FROM compta_rapprochement);

--------- MIGRATION COTISATIONS ----------

-- A edge-case where the end date is after the start date, let's fix it…
UPDATE cotisations SET fin = debut WHERE fin < debut;
UPDATE cotisations SET duree = NULL WHERE duree = 0;

INSERT INTO services SELECT id, intitule, description, duree, debut, fin FROM cotisations;

INSERT INTO services_fees (id, label, amount, id_service, id_account, id_year)
	SELECT id, intitule, CASE WHEN montant IS NOT NULL THEN CAST(montant*100 AS integer) ELSE NULL END, id,
		(SELECT id FROM acc_accounts WHERE code = (SELECT compte FROM compta_categories WHERE id = id_categorie_compta)),
		(SELECT MAX(id) FROM acc_years GROUP BY closed ORDER BY closed LIMIT 1)
	FROM cotisations;

INSERT INTO services_users SELECT cm.id, cm.id_membre, cm.id_cotisation,
	cm.id_cotisation,
	1,
	NULL,
	cm.date,
	CASE
		WHEN c.duree IS NOT NULL THEN date(cm.date, '+'||c.duree||' days')
		WHEN c.fin IS NOT NULL THEN c.fin
		ELSE NULL
	END
	FROM cotisations_membres cm
	INNER JOIN cotisations c ON c.id = cm.id_cotisation;

INSERT INTO services_reminders SELECT * FROM rappels;
INSERT INTO services_reminders_sent SELECT id, id_membre, id_cotisation,
	CASE WHEN id_rappel IS NULL THEN (SELECT MAX(id) FROM rappels) ELSE id_rappel END, date
	FROM rappels_envoyes
	WHERE id_rappel IS NOT NULL
	GROUP BY id_membre, id_cotisation, id_rappel;

-- Recopie des opérations par membre, mais le nom a changé pour acc_transactions_users, et il faut valider l'existence du membre ET du service
INSERT INTO acc_transactions_users
	SELECT a.* FROM membres_operations_old a
	INNER JOIN membres b ON b.id = a.id_membre
	INNER JOIN services_users c ON c.id = a.id_cotisation;

DROP TABLE cotisations;
DROP TABLE cotisations_membres;
DROP TABLE rappels;
DROP TABLE rappels_envoyes;

-- Suppression inutilisées
DROP TABLE compta_rapprochement;
DROP TABLE compta_journal;
DROP TABLE compta_categories;
DROP TABLE compta_comptes;
DROP TABLE compta_exercices;
DROP TABLE membres_operations_old;

DROP TABLE compta_projets;
DROP TABLE compta_comptes_bancaires;
DROP TABLE compta_moyens_paiement;

INSERT INTO acc_charts (country, code, label) VALUES ('FR', 'PCA2018', 'Plan comptable associatif 2018');

CREATE TEMP TABLE tmp_accounts (code,label,description,position,type);

.import charts/fr_pca_2018.csv tmp_accounts

INSERT INTO acc_accounts (id_chart, code, label, description, position, type) SELECT
	(SELECT id FROM acc_charts WHERE code = 'PCA2018'),
	code, label, description,
	CASE position
		WHEN 'Actif' THEN 1
		WHEN 'Passif' THEN 2
		WHEN 'Actif ou passif' THEN 3
		WHEN 'Charge' THEN 4
		WHEN 'Produit' THEN 5
		ELSE 0
	END,
	CASE type
		WHEN 'Banque' THEN 1
		WHEN 'Caisse' THEN 2
		WHEN 'Attente d''encaissement' THEN 3
		WHEN 'Tiers' THEN 4
		WHEN 'Dépenses' THEN 5
		WHEN 'Recettes' THEN 6
		WHEN 'Analytique' THEN 7
		WHEN 'Bénévolat' THEN 8
		WHEN 'Ouverture' THEN 9
		WHEN 'Clôture' THEN 10
		WHEN 'Résultat excédentaire' THEN 11
		WHEN 'Résultat déficitaire' THEN 12
		ELSE 0
	END
	FROM tmp_accounts;

DROP TABLE tmp_accounts;
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


















































































































































































































































































































































































































































Deleted src/include/data/1.0.0_schema.sql version [292ae06778].

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
CREATE TABLE IF NOT EXISTS config (
-- Configuration de Garradin
    cle TEXT PRIMARY KEY NOT NULL,
    valeur TEXT
);

CREATE TABLE IF NOT EXISTS membres_categories
-- Catégories de membres
(
    id INTEGER PRIMARY KEY NOT NULL,
    nom TEXT NOT NULL,

    droit_wiki INTEGER NOT NULL DEFAULT 1,
    droit_membres INTEGER NOT NULL DEFAULT 1,
    droit_compta INTEGER NOT NULL DEFAULT 1,
    droit_inscription INTEGER NOT NULL DEFAULT 0,
    droit_connexion INTEGER NOT NULL DEFAULT 1,
    droit_config INTEGER NOT NULL DEFAULT 0,
    cacher INTEGER NOT NULL DEFAULT 0
);

-- Membres de l'asso
-- Table dynamique générée par l'application
-- voir Garradin\Membres\Champs.php

CREATE TABLE IF NOT EXISTS membres_sessions
-- Sessions
(
    selecteur TEXT NOT NULL,
    hash TEXT NOT NULL,
    id_membre INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    expire INT NOT NULL,

    PRIMARY KEY (selecteur, id_membre)
);

CREATE TABLE IF NOT EXISTS services
-- Types de services (cotisations)
(
    id INTEGER PRIMARY KEY NOT NULL,

    label TEXT NOT NULL,
    description TEXT NULL,

    duration INTEGER NULL CHECK (duration IS NULL OR duration > 0), -- En jours
    start_date TEXT NULL CHECK (start_date IS NULL OR date(start_date) = start_date),
    end_date TEXT NULL CHECK (end_date IS NULL OR (date(end_date) = end_date AND date(end_date) >= date(start_date)))
);

CREATE TABLE IF NOT EXISTS services_fees
(
    id INTEGER PRIMARY KEY NOT NULL,

    label TEXT NOT NULL,
    description TEXT NULL,

    amount INTEGER NULL,
    formula TEXT NULL, -- Formule de calcul du montant de la cotisation, si cotisation dynamique (exemple : membres.revenu_imposable * 0.01)

    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_account INTEGER NULL REFERENCES acc_accounts (id) ON DELETE SET NULL CHECK (id_account IS NULL OR id_year IS NOT NULL), -- NULL si le type n'est pas associé automatiquement à la compta
    id_year INTEGER NULL REFERENCES acc_years (id) ON DELETE SET NULL -- NULL si le type n'est pas associé automatiquement à la compta
);

CREATE TABLE IF NOT EXISTS services_users
-- Enregistrement des cotisations et activités
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_fee INTEGER NULL REFERENCES services_fees (id) ON DELETE CASCADE,

    paid INTEGER NOT NULL DEFAULT 0,
    expected_amount INTEGER NULL,

    date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),
    expiry_date TEXT NULL CHECK (date(expiry_date) IS NULL OR date(expiry_date) = expiry_date)
);

CREATE UNIQUE INDEX IF NOT EXISTS su_unique ON services_users (id_user, id_service, date);

CREATE INDEX IF NOT EXISTS su_service ON services_users (id_service);
CREATE INDEX IF NOT EXISTS su_fee ON services_users (id_fee);
CREATE INDEX IF NOT EXISTS su_paid ON services_users (paid);
CREATE INDEX IF NOT EXISTS su_expiry ON services_users (expiry_date);

CREATE TABLE IF NOT EXISTS services_reminders
-- Rappels de devoir renouveller une cotisation
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,

    delay INTEGER NOT NULL, -- Délai en jours pour envoyer le rappel

    subject TEXT NOT NULL,
    body TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS services_reminders_sent
-- Enregistrement des rappels envoyés à qui et quand
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,

    date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date)
);

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, date);

CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);

--
-- WIKI
--

CREATE TABLE IF NOT EXISTS wiki_pages
-- Pages du wiki
(
    id INTEGER PRIMARY KEY NOT NULL,
    uri TEXT NOT NULL, -- URI unique (équivalent NomPageWiki)
    titre TEXT NOT NULL,
    date_creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_creation) IS NOT NULL AND datetime(date_creation) = date_creation),
    date_modification TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date_modification) IS NOT NULL AND datetime(date_modification) = date_modification),
    parent INTEGER NOT NULL DEFAULT 0, -- ID de la page parent
    revision INTEGER NOT NULL DEFAULT 0, -- Numéro de révision (commence à 0 si pas de texte, +1 à chaque changement du texte)
    droit_lecture INTEGER NOT NULL DEFAULT 0, -- Accès en lecture (-1 = public [site web], 0 = tous ceux qui ont accès en lecture au wiki, 1+ = ID de groupe)
    droit_ecriture INTEGER NOT NULL DEFAULT 0 -- Accès en écriture (0 = tous ceux qui ont droit d'écriture sur le wiki, 1+ = ID de groupe)
);

CREATE UNIQUE INDEX IF NOT EXISTS wiki_uri ON wiki_pages (uri);

CREATE VIRTUAL TABLE IF NOT EXISTS wiki_recherche USING fts4
-- Table dupliquée pour chercher une page
(
    id INT PRIMARY KEY NOT NULL, -- Clé externe obligatoire
    titre TEXT NOT NULL,
    contenu TEXT NULL, -- Contenu de la dernière révision
    FOREIGN KEY (id) REFERENCES wiki_pages(id)
);

CREATE TABLE IF NOT EXISTS wiki_revisions
-- Révisions du contenu des pages
(
    id_page INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
    revision INTEGER NULL,

    id_auteur INTEGER NULL REFERENCES membres (id) ON DELETE SET NULL,

    contenu TEXT NOT NULL,
    modification TEXT NULL, -- Description des modifications effectuées
    chiffrement INTEGER NOT NULL DEFAULT 0, -- 1 si le contenu est chiffré, 0 sinon
    date TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(date) IS NOT NULL AND datetime(date) = date),

    PRIMARY KEY(id_page, revision)
);

CREATE INDEX IF NOT EXISTS wiki_revisions_id_page ON wiki_revisions (id_page);
CREATE INDEX IF NOT EXISTS wiki_revisions_id_auteur ON wiki_revisions (id_auteur);

-- Triggers pour synchro avec table wiki_pages
CREATE TRIGGER IF NOT EXISTS wiki_recherche_delete AFTER DELETE ON wiki_pages
    BEGIN
        DELETE FROM wiki_recherche WHERE id = old.id;
    END;

CREATE TRIGGER IF NOT EXISTS wiki_recherche_update AFTER UPDATE OF id, titre ON wiki_pages
    BEGIN
        UPDATE wiki_recherche SET id = new.id, titre = new.titre WHERE id = old.id;
    END;

-- Trigger pour mettre à jour le contenu de la table de recherche lors d'une nouvelle révision
CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_insert AFTER INSERT ON wiki_revisions WHEN new.chiffrement != 1
    BEGIN
        UPDATE wiki_recherche SET contenu = new.contenu WHERE id = new.id_page;
    END;

-- Si le contenu est chiffré, la recherche n'affiche pas de contenu
CREATE TRIGGER IF NOT EXISTS wiki_recherche_contenu_chiffre AFTER INSERT ON wiki_revisions WHEN new.chiffrement = 1
    BEGIN
        UPDATE wiki_recherche SET contenu = '' WHERE id = new.id_page;
    END;

--
-- COMPTA
--

CREATE TABLE IF NOT EXISTS acc_charts
-- Plans comptables : il peut y en avoir plusieurs
(
    id INTEGER NOT NULL PRIMARY KEY,
    country TEXT NOT NULL,
    code TEXT NULL, -- NULL = plan comptable créé par l'utilisateur
    label TEXT NOT NULL,
    archived INTEGER NOT NULL DEFAULT 0 -- 1 = archivé, non-modifiable
);

CREATE TABLE IF NOT EXISTS acc_accounts
-- Comptes des plans comptables
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_chart INTEGER NOT NULL REFERENCES acc_charts ON DELETE CASCADE,

    code TEXT NOT NULL, -- peut contenir des lettres, eg. 53A, 53B, etc.

    label TEXT NOT NULL,
    description TEXT NULL,

    position INTEGER NOT NULL, -- position actif/passif/charge/produit
    type INTEGER NOT NULL DEFAULT 0, -- Type de compte spécial : banque, caisse, en attente d'encaissement, etc.
    user INTEGER NOT NULL DEFAULT 1 -- 1 = fait partie du plan comptable original, 0 = a été ajouté par l'utilisateur
);

CREATE UNIQUE INDEX IF NOT EXISTS acc_accounts_codes ON acc_accounts (code, id_chart);
CREATE INDEX IF NOT EXISTS acc_accounts_type ON acc_accounts (type);
CREATE INDEX IF NOT EXISTS acc_accounts_position ON acc_accounts (position);

CREATE TABLE IF NOT EXISTS acc_years
-- Exercices
(
    id INTEGER NOT NULL PRIMARY KEY,

    label TEXT NOT NULL,

    start_date TEXT NOT NULL CHECK (date(start_date) IS NOT NULL AND date(start_date) = start_date),
    end_date TEXT NOT NULL CHECK (date(end_date) IS NOT NULL AND date(end_date) = end_date),

    closed INTEGER NOT NULL DEFAULT 0,

    id_chart INTEGER NOT NULL REFERENCES acc_charts (id)
);

CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
    UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
END;

CREATE INDEX IF NOT EXISTS acc_years_closed ON acc_years (closed);

CREATE TABLE IF NOT EXISTS acc_transactions
-- Opérations comptables
(
    id INTEGER PRIMARY KEY NOT NULL,

    type INTEGER NOT NULL DEFAULT 0, -- Type d'écriture, 0 = avancée (normale)
    status INTEGER NOT NULL DEFAULT 0, -- Statut (bitmask)

    label TEXT NOT NULL,
    notes TEXT NULL,
    reference TEXT NULL, -- N° de pièce comptable

    date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(date) IS NOT NULL AND date(date) = date),

    validated INTEGER NOT NULL DEFAULT 0, -- 1 = écriture validée, non modifiable

    hash TEXT NULL,
    prev_hash TEXT NULL,

    id_year INTEGER NOT NULL REFERENCES acc_years(id),
    id_creator INTEGER NULL REFERENCES membres(id) ON DELETE SET NULL,
    id_related INTEGER NULL REFERENCES acc_transactions(id) ON DELETE SET NULL -- écriture liée (par ex. remboursement d'une dette)
);

CREATE INDEX IF NOT EXISTS acc_transactions_year ON acc_transactions (id_year);
CREATE INDEX IF NOT EXISTS acc_transactions_date ON acc_transactions (date);
CREATE INDEX IF NOT EXISTS acc_transactions_related ON acc_transactions (id_related);
CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);
CREATE INDEX IF NOT EXISTS acc_transactions_status ON acc_transactions (status);

CREATE TABLE IF NOT EXISTS acc_transactions_lines
-- Lignes d'écritures d'une opération
(
    id INTEGER PRIMARY KEY NOT NULL,

    id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
    id_account INTEGER NOT NULL REFERENCES acc_accounts (id), -- N° du compte dans le plan comptable

    credit INTEGER NOT NULL,
    debit INTEGER NOT NULL,

    reference TEXT NULL, -- Référence de paiement, eg. numéro de chèque
    label TEXT NULL,

    reconciled INTEGER NOT NULL DEFAULT 0,

    id_analytical INTEGER NULL REFERENCES acc_accounts(id) ON DELETE SET NULL,

    CONSTRAINT line_check1 CHECK ((credit * debit) = 0),
    CONSTRAINT line_check2 CHECK ((credit + debit) > 0)
);

CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_account ON acc_transactions_lines (id_account);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_analytical ON acc_transactions_lines (id_analytical);
CREATE INDEX IF NOT EXISTS acc_transactions_lines_reconciled ON acc_transactions_lines (reconciled);

CREATE TABLE IF NOT EXISTS acc_transactions_users
-- Liaison des écritures et des membres
(
    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_transaction INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
    id_service_user INTEGER NULL REFERENCES services_users (id) ON DELETE SET NULL,

    PRIMARY KEY (id_user, id_transaction)
);

CREATE INDEX IF NOT EXISTS acc_transactions_users_service ON acc_transactions_users (id_service_user);

CREATE TABLE IF NOT EXISTS plugins
(
    id TEXT NOT NULL PRIMARY KEY,
    officiel INTEGER NOT NULL DEFAULT 0,
    nom TEXT NOT NULL,
    description TEXT NULL,
    auteur TEXT NULL,
    url TEXT NULL,
    version TEXT NOT NULL,
    menu INTEGER NOT NULL DEFAULT 0,
    menu_condition TEXT NULL,
    config TEXT NULL
);

CREATE TABLE IF NOT EXISTS plugins_signaux
-- Association entre plugins et signaux (hooks)
(
    signal TEXT NOT NULL,
    plugin TEXT NOT NULL REFERENCES plugins (id),
    callback TEXT NOT NULL,
    PRIMARY KEY (signal, plugin)
);

CREATE TABLE IF NOT EXISTS fichiers
-- Données sur les fichiers
(
    id INTEGER NOT NULL PRIMARY KEY,
    nom TEXT NOT NULL, -- nom de fichier (par exemple image1234.jpeg)
    type TEXT NULL, -- Type MIME
    image INTEGER NOT NULL DEFAULT 0, -- 1 = image reconnue
    datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(datetime) IS NOT NULL AND datetime(datetime) = datetime), -- Date d'ajout ou mise à jour du fichier
    id_contenu INTEGER NOT NULL REFERENCES fichiers_contenu (id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS fichiers_date ON fichiers (datetime);

CREATE TABLE IF NOT EXISTS fichiers_contenu
-- Contenu des fichiers
(
    id INTEGER NOT NULL PRIMARY KEY,
    hash TEXT NOT NULL, -- Hash SHA1 du contenu du fichier
    taille INTEGER NOT NULL, -- Taille en octets
    contenu BLOB NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS fichiers_hash ON fichiers_contenu (hash);

CREATE TABLE IF NOT EXISTS fichiers_membres
-- Associations entre fichiers et membres (photo de profil par exemple)
(
    fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
    id INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    PRIMARY KEY(fichier, id)
);

CREATE TABLE IF NOT EXISTS fichiers_wiki_pages
-- Associations entre fichiers et pages du wiki
(
    fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
    id INTEGER NOT NULL REFERENCES wiki_pages (id) ON DELETE CASCADE,
    PRIMARY KEY(fichier, id)
);

CREATE TABLE IF NOT EXISTS fichiers_acc_transactions
-- Associations entre fichiers et journal de compta (pièce comptable par exemple)
(
    fichier INTEGER NOT NULL REFERENCES fichiers (id) ON DELETE CASCADE,
    id INTEGER NOT NULL REFERENCES acc_transactions (id) ON DELETE CASCADE,
    PRIMARY KEY(fichier, id)
);

CREATE TABLE IF NOT EXISTS recherches
-- Recherches enregistrées
(
    id INTEGER NOT NULL PRIMARY KEY,
    id_membre INTEGER NULL REFERENCES membres (id) ON DELETE CASCADE, -- Si non NULL, alors la recherche ne sera visible que par le membre associé
    intitule TEXT NOT NULL,
    creation TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP CHECK (datetime(creation) IS NOT NULL AND datetime(creation) = creation),
    cible TEXT NOT NULL, -- "membres" ou "compta"
    type TEXT NOT NULL, -- "json" ou "sql"
    contenu TEXT NOT NULL
);


CREATE TABLE IF NOT EXISTS compromised_passwords_cache
-- Cache des hash de mots de passe compromis
(
    hash TEXT NOT NULL PRIMARY KEY
);

CREATE TABLE IF NOT EXISTS compromised_passwords_cache_ranges
-- Cache des préfixes de mots de passe compromis
(
    prefix TEXT NOT NULL PRIMARY KEY,
    date INTEGER NOT NULL
);
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































































































































































































































































































































































































































































































































































































































































































































































































































Deleted src/include/data/1.0.1_migration.sql version [d72d553917].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
UPDATE acc_accounts SET position = 3 WHERE code = '445' OR code = '444' AND id_chart IN (SELECT id FROM acc_charts WHERE code = 'PCGA1999');

UPDATE acc_transactions SET label = '[ERREUR ! À corriger !] ' || label, status = 8 WHERE id IN (
	SELECT DISTINCT t.id
		FROM acc_transactions t
		INNER JOIN acc_transactions_lines l ON l.id_transaction = t.id
		INNER JOIN acc_accounts a ON l.id_account = a.id
		INNER JOIN acc_years y ON y.id = t.id_year AND y.closed = 0 AND y.id_chart != a.id_chart
);

UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_account IN (
	SELECT sf.id_account FROM services_fees sf
		INNER JOIN acc_accounts a ON a.id = sf.id_account
		INNER JOIN acc_years y ON y.id = sf.id_year
	WHERE a.id_chart != y.id_chart);
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






























Deleted src/include/data/1.0.3_migration.sql version [9ded2b030d].

1
2
-- Fix services connected to old closed year
UPDATE services_fees SET id_year = NULL, id_account = NULL WHERE id_year IN (SELECT id FROM acc_years WHERE closed = 1);
<
<




Deleted src/include/data/1.0.6_migration.sql version [6e98c5399a].

1
2
-- Fix credit/debt payment types
UPDATE acc_transactions SET type = 0 WHERE id_related IS NOT NULL AND type IN (4,5);
<
<




Deleted src/include/data/1.0.7_migration.sql version [f53124e2db].

1
2
3
4
5
-- Add indexes
DROP INDEX IF EXISTS acc_transactions_type;
CREATE INDEX IF NOT EXISTS acc_transactions_type ON acc_transactions (type, id_year);

CREATE INDEX IF NOT EXISTS acc_transactions_lines_transaction ON acc_transactions_lines (id_transaction);
<
<
<
<
<










Deleted src/include/data/1.1.0_migration.sql version [9f5de55bec].

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
-- Remove triggers in case they interact with the migration
DROP TRIGGER IF EXISTS wiki_recherche_delete;
DROP TRIGGER IF EXISTS wiki_recherche_update;
DROP TRIGGER IF EXISTS wiki_recherche_contenu_insert;
DROP TRIGGER IF EXISTS wiki_recherche_contenu_chiffre;

-- Fix some rare edge cases where date_inscription is incorrect
UPDATE membres SET date_inscription = date() WHERE date(date_inscription) IS NULL;

-- Uh, force another login id if email is not correct
UPDATE config SET valeur = 'numero' WHERE cle = 'champ_identifiant' AND valeur = 'email'
	AND (SELECT COUNT(*) FROM membres WHERE email IS NOT NULL GROUP BY LOWER(email) HAVING COUNT(*) > 1 LIMIT 1);

-- Other weird things to fix before migration
UPDATE wiki_pages SET uri = 'page_' || id WHERE uri = '' OR uri IS NULL;
DELETE FROM config WHERE cle = 'connexion' OR cle = 'wiki';

ALTER TABLE membres_categories RENAME TO membres_categories_old;

INSERT OR IGNORE INTO config (cle, valeur) VALUES ('desactiver_site', '0');
ALTER TABLE config RENAME TO config_old;

.read 1.1.0_schema.sql

INSERT INTO config SELECT * FROM config_old;
DROP TABLE config_old;

-- This is not used anymore
DELETE FROM config WHERE key = 'version';
DELETE FROM config WHERE key = 'accueil_wiki';

-- New config key
INSERT INTO config (key, value) VALUES ('telephone_asso', NULL);

-- Create directories
INSERT INTO files (parent, name, path, type) VALUES ('', 'documents', 'documents', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'config', 'config', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'transaction', 'transaction', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'skel', 'skel', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'user', 'user', 2);
INSERT INTO files (parent, name, path, type) VALUES ('', 'web', 'web', 2);

-- Copy droit_wiki value to droit_web and droit_documents
INSERT INTO users_categories
	SELECT id, nom,
		droit_wiki, -- perm_web
		droit_wiki, -- perm_documents
		droit_membres,
		droit_compta,
		droit_inscription,
		droit_connexion,
		droit_config,
		cacher
	FROM membres_categories_old;

DROP TABLE membres_categories_old;

UPDATE recherches SET contenu = REPLACE(contenu, 'id_categorie', 'id_category') WHERE cible = 'membres' AND contenu LIKE '%id_categorie%';

CREATE TEMP TABLE files_transactions (old_id, old_transaction, old_name, new_path, new_id, same_name);

-- Adding an extra step as some file names can have the same name
INSERT INTO files_transactions
	SELECT f.id, t.id, f.nom, NULL, NULL, NULL
	FROM fichiers f
		INNER JOIN fichiers_acc_transactions t ON t.fichier = f.id;

UPDATE files_transactions SET same_name = old_id || '_'
	WHERE old_id IN (SELECT old_id FROM files_transactions GROUP BY old_transaction, old_name HAVING COUNT(*) > 1);

-- Make file name is unique!
UPDATE files_transactions SET new_path = 'transaction/' || old_transaction || '/' || COALESCE(old_id || '_', '') || old_name;

-- Copy existing files for transactions
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT
		ft.new_path,
		dirname(ft.new_path),
		basename(ft.new_path),
		1, f.type, f.datetime, c.taille, f.image
	FROM files_transactions ft
		INNER JOIN fichiers f ON f.id = ft.old_id
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu;

UPDATE files_transactions SET new_id = (SELECT id FROM files WHERE path = new_path);

INSERT INTO files_contents (id, compressed, content)
	SELECT ft.new_id, 0, c.contenu
	FROM fichiers f
		INNER JOIN files_transactions ft ON ft.old_id = f.id
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu;

-- Copy wiki pages content
CREATE TEMP TABLE wiki_as_files (old_id, new_id, path, content, title, uri,
	old_parent, new_parent, created, modified, author_id, encrypted, type, public);

INSERT INTO wiki_as_files
	SELECT
		id, NULL, '', CASE WHEN contenu IS NULL THEN '' ELSE contenu END, titre, uri,
		parent, parent, date_creation, date_modification, id_auteur, chiffrement,
		CASE WHEN (SELECT 1 FROM wiki_pages pp WHERE pp.parent = p.id LIMIT 1) THEN 1 ELSE 2 END, -- Type, 1 = category, 2 = page
		CASE WHEN droit_lecture = -1 THEN 1 ELSE 0 END -- public
	FROM wiki_pages p
	LEFT JOIN wiki_revisions r ON r.id_page = p.id AND r.revision = p.revision;

-- Build path
WITH RECURSIVE path(level, uri, parent, id) AS (
	SELECT 0, uri, old_parent, old_id
	FROM wiki_as_files
	UNION ALL
	SELECT path.level + 1,
	wiki_as_files.uri,
	wiki_as_files.old_parent,
	path.id
	FROM wiki_as_files
	JOIN path ON wiki_as_files.old_id = path.parent
	WHERE level <= 8 -- max level = 8 to avoid recursion
),
path_from_root AS (
	SELECT group_concat(uri, '/') AS path, id
	FROM (SELECT id, uri FROM path ORDER BY level DESC)
	GROUP BY id
)
UPDATE wiki_as_files SET path = (SELECT path FROM path_from_root WHERE id = wiki_as_files.old_id);

-- remove recursion
UPDATE wiki_as_files SET path = uri WHERE path IS NULL OR LENGTH(path) - LENGTH(REPLACE(path, '/', '')) >= 8;

-- Copy into files
INSERT INTO files (path, parent, name, type, mime, modified, size)
	SELECT
		'web/' || path || '/index.txt',
		'web/' || path,
		'index.txt',
		1,
		'text/plain',
		modified,
		0 -- size will be set after
	FROM wiki_as_files;

UPDATE wiki_as_files SET new_id = (SELECT id FROM files WHERE files.path = 'web/' || (CASE WHEN wiki_as_files.path IS NOT NULL THEN wiki_as_files.path || '/' ELSE '' END) || wiki_as_files.uri || '/index.txt');

-- x'0a' == \n
INSERT INTO files_contents (id, compressed, content)
	SELECT f.id, 0,
		'Title: ' || title || x'0a' || 'Published: ' || created || x'0a' || 'Status: '
		|| (CASE WHEN public THEN 'Online' ELSE 'Draft' END)
		|| x'0a' || 'Format: ' || (CASE WHEN encrypted THEN 'Skriv/Encrypted' ELSE 'Skriv' END)
		|| x'0a' || x'0a' || '----' || x'0a' || x'0a' || content
	FROM wiki_as_files waf
	INNER JOIN files f ON f.path = 'web/' || waf.path || '/index.txt';

-- Copy to search
INSERT INTO files_search (path, title, content)
	SELECT
		'web/' || path || '/index.txt',
		title,
		CASE WHEN encrypted THEN NULL ELSE content END
	FROM wiki_as_files WHERE encrypted = 0;

-- Copy to web_pages
INSERT INTO web_pages (id, parent, path, uri, file_path, type, status, title, published, modified, format, content)
	SELECT new_id,
	CASE WHEN dirname(path) = '.' THEN '' ELSE dirname(path) END,
	path,
	uri,
	'web/' || path || '/index.txt',
	type,
	CASE WHEN public THEN 'online' ELSE 'draft' END,
	title, created, modified,
	CASE WHEN encrypted THEN 'skriv/encrypted' ELSE 'skriv' END,
	content
	FROM wiki_as_files;

CREATE TEMP TABLE files_wiki (old_id, wiki_id, web_path, old_name, new_path, new_id);

-- Adding an extra step as some file names can have the same name
INSERT INTO files_wiki
	SELECT f.id, w.id, waf.path, f.nom, 'web/' || waf.path || '/' || f.id || '_' || f.nom, NULL
	FROM fichiers f
		INNER JOIN fichiers_wiki_pages w ON w.fichier = f.id
		INNER JOIN wiki_as_files waf ON w.id = waf.old_id;

-- Copy files linked to wiki pages
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT
		fw.new_path,
		dirname(fw.new_path),
		basename(fw.new_path),
		1,
		f.type,
		f.datetime,
		c.taille,
		f.image
	FROM fichiers f
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu
		INNER JOIN files_wiki fw ON fw.old_id = f.id;

UPDATE files_wiki SET new_id = (SELECT id FROM files WHERE path = new_path);

INSERT INTO files_contents (id, compressed, content)
	SELECT
		fw.new_id, 0, c.contenu
	FROM files_wiki fw
		INNER JOIN fichiers f ON f.id = fw.old_id
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu;

-- Create parent directories
INSERT INTO files (type, path, parent, name, modified)
	SELECT 2,
		'web/' || waf.path,
		dirname('web/' || waf.path),
		waf.uri,
		modified
	FROM wiki_as_files waf;

INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;
INSERT OR IGNORE INTO files (type, path, parent, name) SELECT 2, parent, dirname(parent), basename(parent) FROM files WHERE type = 2 AND dirname(parent) != '.' AND dirname(parent) != '' AND (SELECT 1 FROM files f2 WHERE f2.path = dirname(files.parent) LIMIT 1) IS NULL;

-- Copy existing config files
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT 'config/admin_bg.png', 'config', 'admin_bg.png', 1, type, datetime, c.taille, image
	FROM fichiers f
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu
	WHERE f.id = (SELECT c.value FROM config c WHERE key = 'image_fond') LIMIT 1;

INSERT INTO files_contents (id, compressed, content)
	SELECT f2.id, 0, c.contenu
	FROM files AS f2
		INNER JOIN fichiers f ON f2.path = 'config/admin_bg.png'
		INNER JOIN fichiers_contenu c ON c.id = f.id_contenu
		WHERE f.id = (SELECT c.value FROM config c WHERE key = 'image_fond') LIMIT 1;

-- Rename
UPDATE config SET key = 'admin_background', value = 'config/admin_bg.png' WHERE key = 'image_fond';

-- Copy connection page as a single file
INSERT INTO files (path, parent, name, type, mime, modified, size, image)
	SELECT 'config/admin_homepage.skriv', 'config', 'admin_homepage.skriv', 1, 'text/plain', datetime(), LENGTH(content), 0
	FROM wiki_as_files
	WHERE uri = (SELECT value FROM config WHERE key = 'accueil_connexion');

INSERT INTO files_contents (id, compressed, content)
	SELECT f.id, 0, waf.content
	FROM files f
		INNER JOIN wiki_as_files waf ON waf.uri = (SELECT value FROM config WHERE key = 'accueil_connexion')
	WHERE f.path = 'config/admin_homepage.skriv';

-- Rename
UPDATE config SET key = 'admin_homepage', value = 'config/admin_homepage.skriv' WHERE key = 'accueil_connexion';
UPDATE config SET key = 'site_disabled' WHERE key = 'desactiver_site';

-- Create transaction directories
INSERT INTO files (path, parent, name, type) SELECT 'transaction/' || id, 'transaction', id, 2 FROM fichiers_acc_transactions GROUP BY id;

-- Set file size
UPDATE files SET size = (SELECT LENGTH(content) FROM files_contents WHERE id = files.id) WHERE type = 1;

DELETE FROM plugins_signaux WHERE signal LIKE 'boucle.%';

DROP TABLE wiki_recherche;

DROP TABLE wiki_pages;
DROP TABLE wiki_revisions;

DROP TABLE fichiers_wiki_pages;
DROP TABLE fichiers_acc_transactions;
DROP TABLE fichiers_membres;

DROP TABLE fichiers;
DROP TABLE fichiers_contenu;
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<














































































































































































































































































































































































































































































































































































Deleted src/include/data/1.1.15_migration.sql version [f806b6152c].

1
2
3
4
5
UPDATE acc_accounts SET type = 8, position = 4 WHERE id_chart IN (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '86_%') AND user = 0;
UPDATE acc_accounts SET type = 8, position = 5 WHERE id_chart IN (SELECT id FROM acc_charts WHERE code IS NOT NULL) AND (code LIKE '87_%') AND user = 0;

-- Cohérence avec plan 2018
UPDATE acc_charts SET code = 'PCA1999' WHERE code = 'PCGA1999';
<
<
<
<
<










Deleted src/include/data/1.1.19_migration.sql version [a29354b289].

1
2
3
UPDATE acc_accounts SET type = 13 WHERE type = 0 AND code = '1068';
UPDATE acc_accounts SET type = 14 WHERE type = 0 AND code = '110';
UPDATE acc_accounts SET type = 15 WHERE type = 0 AND code = '119';
<
<
<






Deleted src/include/data/1.1.3_migration.sql version [18eac8e4eb].

1
2
3
4
-- Make sure id_account is reset when a year is deleted
CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
    UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
END;
<
<
<
<








Deleted src/include/data/1.1.7_migration.sql version [1dab4145d3].

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
ALTER TABLE services_reminders_sent RENAME TO srs_old;

-- Add new column in services_reminders_sent
CREATE TABLE IF NOT EXISTS services_reminders_sent
-- Enregistrement des rappels envoyés à qui et quand
(
    id INTEGER NOT NULL PRIMARY KEY,

    id_user INTEGER NOT NULL REFERENCES membres (id) ON DELETE CASCADE,
    id_service INTEGER NOT NULL REFERENCES services (id) ON DELETE CASCADE,
    id_reminder INTEGER NOT NULL REFERENCES services_reminders (id) ON DELETE CASCADE,

    sent_date TEXT NOT NULL DEFAULT CURRENT_DATE CHECK (date(sent_date) IS NOT NULL AND date(sent_date) = sent_date),
    due_date TEXT NOT NULL CHECK (date(due_date) IS NOT NULL AND date(due_date) = due_date)
);

CREATE UNIQUE INDEX IF NOT EXISTS srs_index ON services_reminders_sent (id_user, id_service, id_reminder, due_date);

CREATE INDEX IF NOT EXISTS srs_reminder ON services_reminders_sent (id_reminder);
CREATE INDEX IF NOT EXISTS srs_user ON services_reminders_sent (id_user);

INSERT INTO services_reminders_sent SELECT id, id_user, id_service, id_reminder, date, date FROM srs_old;
DROP TABLE srs_old;

-- Missing acc_years_delete trigger, again, because of missing symlink in previous release
-- Make sure id_account is reset when a year is deleted
CREATE TRIGGER IF NOT EXISTS acc_years_delete BEFORE DELETE ON acc_years BEGIN
    UPDATE services_fees SET id_account = NULL, id_year = NULL WHERE id_year = OLD.id;
END;

<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




























































Deleted src/include/data/1.1.8_migration.sql version [cc434f178d].

1
2
3
4
5
-- Remove any leftover duplicates
DELETE FROM web_pages WHERE id IN (SELECT id FROM web_pages GROUP BY uri HAVING COUNT(*) > 1);

-- Add unique index
CREATE UNIQUE INDEX IF NOT EXISTS web_pages_uri ON web_pages (uri);
<
<
<
<
<










Deleted src/include/data/champs_membres.ini version [1af4967b02].

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
;	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
;	title = "Super champ trop cool"
;	mandatory = true
;	editable = false
;
;	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
;	title: 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
;	editable:
;		true = modifiable par le membre
;		false = modifiable uniquement par un admin (défaut)
;	mandatory:
;		true = obligatoire, la fiche membre ne pourra être enregistrée si ce champ est vide
;		false = facultatif (défaut)
;	private:
;		true = non visible par le membre lui-même
;		false = visible par le membre (défaut)
;	list_row:
;		Si absent ou zéro ('0') ou false, ce champ n'apparaîtra pas dans la liste des membres
;		Si présent et un chiffre supérieur à 0, alors le champ apparaîtra dans la liste des membres
;		dans l'ordre défini par le chiffre (si nom est à 2 et email à 1, alors email sera
;		la première colonne et nom la seconde)
;	install:
;		true = sera ajouté aux fiches membres à l'installation
;		false = sera seulement présent dans les champs supplémentaires possibles (défaut)

[numero]
type = number
title = "Numéro de membre"
help = "Doit être unique, laisser vide pour que le numéro soit attribué automatiquement"
mandatory = false
install = true
editable = false
list_row = 1

[nom]
type = text
title = "Nom & prénom"
mandatory = true
install = true
editable = true
list_row = 2

[email]
; ce champ est facultatif et de type 'email'
type = email
title = "Adresse E-Mail"
mandatory = false
install = true
editable = true

[passe]
; ce champ est obligatoirement présent et de type 'password'
; le titre ne peut être modifié
type = password
mandatory = false
install = true
editable = true

[adresse]
type = textarea
title = "Adresse postale"
help = "Indiquer ici le numéro, le type de voie, etc."
install = true
editable = true

[code_postal]
type = text
title = "Code postal"
install = true
editable = true
list_row = 3

[ville]
type = text
title = "Ville"
install = true
editable = true
list_row = 4

[pays]
type = country
title = "Pays"
install = true
editable = true

[telephone]
type = tel
title = "Numéro de téléphone"
install = true
editable = true

[lettre_infos]
type = checkbox
title = "Inscription à la lettre d'information"
install = true
editable = true

[groupe_travail]
type = multiple
title = "Groupes de travail"
editable = false
options[] = "Télécoms"
options[] = "Trésorerie"
options[] = "Relations publiques"
options[] = "Communication presse"
options[] = "Organisation d'événements"

[date_naissance]
type = date
title = "Date de naissance"
editable = true

[notes]
type = textarea
title = "Notes"
editable = false
private = true

[photo]
type = file
title = "Photo"
editable = false
private = false
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
































































































































































































































































































Modified src/include/data/schema.sql from [bb4040c594] to [57116110a2].

1
../migrations/1.2/schema.sql
|
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
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
}

/*
 * Configuration globale
 */

// Configuration externalisée
if (file_exists(__DIR__ . '/../config.local.php'))
{
	require __DIR__ . '/../config.local.php';
}

// Configuration par défaut, si les constantes ne sont pas définies dans config.local.php
// (fallback)
if (!defined('Garradin\ROOT'))
{
	define('Garradin\ROOT', dirname(__DIR__));
}

\spl_autoload_register(function (string $classname) {
	$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 = Plugin::getPath(strtolower($plugin_name)) . '/lib/' . $filename . '.php';







	}
	else
	{
		// PSR-0 autoload
		$filename = str_replace('\\', '/', $classname);
		$path = ROOT . '/include/lib/' . $filename . '.php';
	}







|

|


|






|









|
>
>
>
>
>
>
>







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
190
191
192
193
194
195
196

197
198
199

200
201
202
203
204
205
206
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',
	'PREFER_HTTPS'          => false,
	'ALLOW_MODIFIED_IMPORT' => true,
	'SHOW_ERRORS'           => true,
	'MAIL_ERRORS'           => false,
	'ERRORS_REPORT_URL'     => null,
	'REPORT_USER_EXCEPTIONS' => 0,
	'ENABLE_TECH_DETAILS'   => true,

	'ENABLE_UPGRADES'       => true,
	'USE_CRON'              => false,
	'ENABLE_XSENDFILE'      => false,

	'SMTP_HOST'             => false,
	'SMTP_USER'             => null,
	'SMTP_PASSWORD'         => null,
	'SMTP_PORT'             => 587,
	'SMTP_SECURITY'         => 'STARTTLS',
	'MAIL_RETURN_PATH'      => null,
	'MAIL_BOUNCE_PASSWORD'  => null,







>



<






>



>







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
266
267
268
269
270
271
272
273
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.local.php
if (!ini_get('date.timezone'))
{
	if (($tz = @date_default_timezone_get()) && $tz != 'UTC')
	{
		ini_set('date.timezone', $tz);
	}
	else







|







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
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
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; }
	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 . '">&larr; Retour à la page d\'accueil</a></p>
	</body></html>');

ErrorManager::setHtmlHeader('<!DOCTYPE html><meta charset="utf-8" /><style type="text/css">
	body { font-family: sans-serif; } * { 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>
<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 &rarr;" /></p></form></if>
	</article>
</section>
');

function user_error(UserException $e)
{
	if (PHP_SAPI == 'cli')
	{
		echo $e->getMessage();
	}
	else
	{

		$tpl = Template::getInstance();


		$tpl->assign('error', $e->getMessage());
		$tpl->assign('html_error', $e->getHTMLMessage());
		$tpl->assign('admin_url', ADMIN_URL);
		$tpl->display('error.tpl');
	}

	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'))
{



	$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') && !defined('Garradin\UPGRADE_PROCESS'))
{
	if (!file_exists(DB_FILE)) {


		if (in_array('install.php', get_included_files())) {
			die('Erreur de redirection en boucle : problème de configuration ?');
		}

		Utils::redirect(ADMIN_URL . 'install.php');
	}








|














|
|











|

















>
|
>




|













>
>
>
















|

|
>
>







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 . '">&larr; 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 &rarr;" /></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
5
6
7
8
9
10
11
12
<?php

namespace Garradin;

use Garradin\Membres\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;




|







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
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\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use KD2\DB\EntityManager;









|







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
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
	public function getClosingAccountId(): ?int
	{
		return $this->getIdForType(Account::TYPE_CLOSING);
	}

	public function listUserAccounts(int $year_id): DynamicList
	{
		$id_field = Config::getInstance()->champ_identite;

		$columns = [
			'id' => [
				'select' => 'u.id',
			],
			'user_number' => [
				'select' => 'u.numero',
				'label' => 'N° membre',
			],
			'user_identity' => [
				'select' => 'u.' . $id_field,
				'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 membres 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;
	}














/* FIXME: implement closing of accounts

	public function closeRevenueExpenseAccounts(Year $year, int $user_id)
	{
		$closing_id = $this->getClosingAccountId();








<
<





|



|














|

















>
>
>
>
>
>
>
>
>
>
>
>
>







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
8
9
10
11
12
13
14
15
<?php

namespace Garradin\Accounting;

use Garradin\CSV_Custom;
use Garradin\UserException;
use Garradin\Utils;
use Garradin\Membres\Session;
use Garradin\Entities\Accounting\Account;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entity;

/**
 * Provides assisted reconciliation
 */







|







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
127
128
129
130
131
132
133
134

		if (!array_key_exists($type, self::COLUMNS)) {
			throw new \InvalidArgumentException('Unknown type: ' . $type);
		}

		CSV::export(
			$format,
			sprintf('%s - Export comptable - %s - %s', Config::getInstance()->get('nom_asso'), self::NAMES[$type], $year->label),
			self::iterateExport($year->id(), $type),
			array_keys(self::COLUMNS[$type])
		);
	}

	static public function getExamples(Year $year)
	{







|







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
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
		}

		return $out;
	}

	static protected function iterateExport(int $year_id, string $type): \Generator
	{
		$id_field = Config::getInstance()->get('champ_identite');

		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(u.%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 membres 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);
		}







|








|







|







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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
				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(u.%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 membres 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 {







|





|







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
235
236
237
238
239
240
241
242
243

		return $out;
	}

	static protected function getColors()
	{
		$config = Config::getInstance();
		$c1 = $config->get('couleur1') ?: ADMIN_COLOR1;
		$c2 = $config->get('couleur2') ?: 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) {







|
|







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
183
184
185
186
187
188
189
190
191
		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);

		//var_dump('<pre>', $sql, $balances[Account::REVENUE]); exit;

		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 (







<
<







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
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603

		return $out;
	}

	/**
	 * Grand livre
	 */
	static public function getGeneralLedger(array $criterias): \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
			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;







|















|







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






627
628
629
630
631
632
633
634
					'credit'=> 0,
					'lines' => [],
				];
			}

			$row->date = \DateTime::createFromFormat('Y-m-d', $row->date);







			$account->sum += ($row->credit - $row->debit);
			$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);







>
>
>
>
>
>
|







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
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
<?php

namespace Garradin;


use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Membres\Champs;

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',

	];

	const FILES_TYPES = [
		'admin_background' => 'image',
		'admin_css'        => 'code',
		'admin_homepage'   => 'web',
		'logo'             => 'image',
		'icon'             => 'image',
		'favicon'          => 'image',

	];

	protected $nom_asso;
	protected $adresse_asso;
	protected $email_asso;
	protected $telephone_asso;
	protected $site_asso;

	protected $monnaie;
	protected $pays;

	protected $champs_membres;
	protected $categorie_membres;

	protected $frequence_sauvegardes;
	protected $nombre_sauvegardes;

	protected $champ_identifiant;
	protected $champ_identite;

	/**
	 * This setting means that when creating a new transaction, if analytical_set_all
	 * is TRUE then all lines will be affected
	 */
	protected $analytical_set_all;

	protected $last_chart_change;
	protected $last_version_check;

	protected $couleur1;
	protected $couleur2;

	protected $files = [];

	protected $site_disabled;

	protected $_types = [
		'nom_asso'              => 'string',
		'adresse_asso'          => '?string',
		'email_asso'            => 'string',
		'telephone_asso'        => '?string',
		'site_asso'             => '?string',

		'monnaie'               => 'string',
		'pays'                  => 'string',

		'champs_membres'        => Champs::class,

		'categorie_membres'     => 'int',

		'frequence_sauvegardes' => '?int',
		'nombre_sauvegardes'    => '?int',

		'champ_identifiant'     => 'string',
		'champ_identite'        => 'string',

		'analytical_set_all'    => '?bool',

		'last_chart_change'     => '?int',
		'last_version_check'    => '?string',

		'couleur1'              => '?string',
		'couleur2'              => '?string',

		'files'                 => 'array',

		'site_disabled'         => 'bool',
	];



	static protected $_instance = null;

	static public function getInstance()
	{
		return self::$_instance ?: self::$_instance = new self;
	}




>


<













>









>


<
<
<
<
<
|
<
<
|
<
<
|
<
<

|
|
<
<
<
<
<
<
<
|
|
<
|
|

|
<
|

|
<
<
<
<
<

<
<
|
<
|
<

<
<
<
<
<
<
<
<
|
|

|
|

|

|
|
>
>







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
131
132
133
134
135
136
137
138
139
140
141
142
143







144
145
146
147
148
149
150
		if (empty($config)) {
			return;
		}

		$default = array_fill_keys(array_keys($this->_types), null);
		$config = array_merge($default, $config);

		$config['champs_membres'] = new Champs($config['champs_membres']);

		foreach ($this->_types as $key => $type) {
			$value = $config[$key];

			if ($type[0] == '?' && $value === null) {
				continue;
			}
		}

		$this->load($config);

		$this->champs_membres = new Membres\Champs((string)$this->champs_membres);







	}

	public function save(bool $selfcheck = true): bool
	{
		if (!count($this->_modified)) {
			return true;
		}







<
<









|
|
>
>
>
>
>
>
>







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
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
		$db->begin();

		foreach ($values as $key => $value)
		{
			$db->preparedQuery('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?);', $key, $value);
		}

		if (!empty($values['champ_identifiant'])) {
			// Regenerate login index
			$db->exec('DROP INDEX IF EXISTS users_id_field;');
			$config = Config::getInstance();
			$champs = $config->get('champs_membres');
			$champs->createIndexes();
		}

		$db->commit();

		if (isset($values['couleur1']) || isset($values['couleur2'])) {
			// Reset graph cache
			Static_Cache::clean(0);
		}

		$this->_modified = [];

		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['couleur1'], $source['couleur2'])
			&& ($source['couleur1'] == ADMIN_COLOR1 && $source['couleur2'] == ADMIN_COLOR2))
		{
			$source['couleur1'] = null;
			$source['couleur2'] = 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;
			case Champs::class:
				if (!is_object($value) || !($value instanceof $this->_types[$key])) {
					throw new \InvalidArgumentException(sprintf('"%s" is not of type "%s"', $key, $this->_types[$key]));
				}
				return $value;
			default:
				throw new \InvalidArgumentException(sprintf('"%s" has unknown type "%s"', $key, $this->_types[$key]));
		}
	}

	public function selfCheck(): void
	{
		$this->assert(trim($this->nom_asso) != '', 'Le nom de l\'association ne peut rester vide.');
		$this->assert(trim($this->monnaie) != '', 'La monnaie ne peut rester vide.');
		$this->assert(trim($this->pays) != '' && Utils::getCountryName($this->pays), 'Le pays ne peut rester vide.');
		$this->assert(null === $this->site_asso || filter_var($this->site_asso, FILTER_VALIDATE_URL), 'L\'adresse URL du site web est invalide.');
		$this->assert(trim($this->email_asso) != '' && SMTP::checkEmailIsValid($this->email_asso, false), 'L\'adresse e-mail de l\'association est  invalide.');
		$this->assert($this->champs_membres instanceof Champs, 'Objet champs membres invalide');


		// 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));
		}

		$champs = $this->champs_membres;

		$this->assert(!empty($champs->get($this->champ_identite)), sprintf('Le champ spécifié pour identité, "%s" n\'existe pas', $this->champ_identite));
		$this->assert(!empty($champs->get($this->champ_identifiant)), sprintf('Le champ spécifié pour identifiant, "%s" n\'existe pas', $this->champ_identifiant));

		$db = DB::getInstance();

		// Check that this field is actually unique
		if (isset($this->_modified['champ_identifiant'])) {
			$sql = sprintf('SELECT (COUNT(DISTINCT %s COLLATE U_NOCASE) = COUNT(*)) FROM membres WHERE %1$s IS NOT NULL AND %1$s != \'\';', $this->champ_identifiant);
			$is_unique = (bool) $db->firstColumn($sql);

			$this->assert($is_unique, sprintf('Le champ "%s" comporte des doublons et ne peut donc pas servir comme identifiant unique de connexion.', $this->champ_identifiant));
		}

		$this->assert($db->test('users_categories', 'id = ?', $this->categorie_membres), 'Catégorie de membres inconnue');
	}

	public function file(string $key): ?File
	{
		if (!isset(self::FILES[$key])) {
			throw new \InvalidArgumentException('Invalid file key: ' . $key);
		}







<
<
|
<
<
<
|
|
<

|
<
|

<
<
















|
|

|
|














<
<
<
<
<







|
|
|
|
|
|
>









<
<
<
<
<

<
<
<
<
<
<
<
<
<
|







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
291
292
293
294
295
296
297
298
			}

			return null;
		}

		$params = $params ? $params . '&' : '';

		return BASE_URL . self::FILES[$key] . '?' . $params . substr(md5($this->files[$key]), 0, 10);
	}


	public function hasFile(string $key): bool
	{
		return $this->files[$key] ? true : false;
	}







|







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
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
			if ($f) {
				$f->delete();
			}

			$f = null;
		}
		elseif ($upload) {
			$f = File::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 == 'favicon') {
				$format = 'png';
				$i = new Image($f->fullpath());
				$i->cropResize(512, 512);
				$f->setContent($i->output($format, true));
			}







		}
		elseif ($f) {
			$f->setContent($value);
		}
		else {
			$f = File::createAndStore(Utils::dirname($path), Utils::basename($path), null, $value);
		}

		$files[$key] = $f ? $f->modified->getTimestamp() : null;
		$this->set('files', $files);

		return $f;
	}
}







|














|





>
>
>
>
>
>
>





|








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
212
213
214
215
216
217
218
219

        // Activer les contraintes des foreign keys
        $this->db->exec('PRAGMA foreign_keys = ON;');

        // 10 secondes
        $this->db->busyTimeout(10 * 1000);

        // Default to DELETE
        $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







<







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
235

236
237
238












239
240
241
242
243
244
245
        self::registerCustomFunctions($this->db);
    }

    static public function registerCustomFunctions($db)
    {
        $db->createFunction('dirname', [Utils::class, 'dirname']);
        $db->createFunction('basename', [Utils::class, 'basename']);
        $db->createFunction('like', [self::class, 'unicodeLike']);

        $db->createFunction('email_hash', [Entities\Users\Email::class, 'getHash']);
        $db->createCollation('U_NOCASE', [Utils::class, 'unicodeCaseComparison']);
    }













    public function version(): ?string
    {
        if (-1 === $this->_version) {
            $this->connect();
            $this->_version = self::getVersion($this->db);
        }







|
>
|


>
>
>
>
>
>
>
>
>
>
>
>







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


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
        }

        $this->db->exec(sprintf('PRAGMA user_version = %d;', $version));
    }

    public function beginSchemaUpdate()
    {


        $this->toggleForeignKeys(false);
        $this->begin();

    }

    public function commitSchemaUpdate()
    {


        $this->commit();
        $this->toggleForeignKeys(true);

    }

    public function lastErrorMsg()
    {
        return $this->db->lastErrorMsg();
    }

    /**
     * @see https://www.sqlite.org/lang_altertable.html
     */
    public function toggleForeignKeys($enable)
    {
        assert(is_bool($enable));

        $this->connect();

        if (!$enable) {
            $this->db->exec('PRAGMA legacy_alter_table = ON;');
            $this->db->exec('PRAGMA foreign_keys = OFF;');




        }
        else {
            $this->db->exec('PRAGMA legacy_alter_table = OFF;');
            $this->db->exec('PRAGMA foreign_keys = ON;');
        }
    }








>
>
|
|
>




>
>
|
|
>










|

<
<





>
>
>
>







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
114
115
116
117
118
119
120
121
122
123
124
125
		foreach ($this->iterate(true) as $row) {
			$out[] = $row;
		}

		return $out;
	}

	public function paginationURL()
	{
		return Utils::getModifiedURL('?p=[ID]');
	}

	public function orderURL(string $order, bool $desc)
	{
		$query = array_merge($_GET, ['o' => $order, 'd' => (int) $desc]);
		$url = Utils::getSelfURI($query);
		return $url;
	}








<
<
<
<
<







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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
			}

			$columns[$alias] = $export ? $properties['label'] : $properties;
		}

		return $columns;
	}






	public function getExportHeaderColumns(): array
	{
		return $this->getHeaderColumns(true);
	}

	public function iterate(bool $include_hidden = true)
	{
		foreach (DB::getInstance()->iterate($this->getSQL()) 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 getSQL(): string
	{
		$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)







>
>
>
>
>








|














|







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
232
233





















234





235
236
237
238






239

240



















































241
242
243
244
245
246
247
		if (null !== $this->per_page) {
			$sql .= sprintf(' LIMIT %d,%d', $start, $this->per_page);
		}

		return $sql;
	}

	public function loadFromQueryString()
	{





















		if (!empty($_GET['export'])) {





			$this->export($this->title, $_GET['export']);
			exit;
		}







		if (!empty($_GET['o'])) {

			$this->orderBy($_GET['o'], !empty($_GET['d']));



















































		}

		if (!empty($_GET['p'])) {
			$this->page = (int)$_GET['p'];
		}
	}
}







|

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
|



>
>
>
>
>
>
|
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>


|
|
|
|
<
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
5
6
7
8
9


10
11
12
13
14
15
16
<?php

namespace Garradin\Entities;

use Garradin\Membres\Session;
use Garradin\Entity;

class API_Credentials extends Entity
{


	const TABLE = 'api_credentials';

	protected ?int $id;
	protected string $label;
	protected string $key;
	protected string $secret;
	protected \DateTime $created;




|




>
>







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

567

568
569
570
571
572
573
574
575

	/**
	 * 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
	{

		if ($simple && in_array($this->type, [self::TYPE_BANK, self::TYPE_CASH, self::TYPE_OUTSTANDING, self::TYPE_EXPENSE, self::TYPE_THIRD_PARTY])) {

			return false;
		}

		$position = $this->getPosition($id_year);

		if ($position == self::ASSET || $position == self::EXPENSE) {
			return false;
		}







>
|
>
|







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
855
856
857
858
859
860
861
862
		$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
	{







<







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
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\Accounting;

use KD2\DB\EntityManager;

use Garradin\Entity;
use Garradin\DB;
use Garradin\Config;

use Garradin\Utils;
use Garradin\UserException;



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 TABLE = 'acc_transactions';

	const TYPE_ADVANCED = 0;
	const TYPE_REVENUE = 1;
	const TYPE_EXPENSE = 2;
	const TYPE_TRANSFER = 3;
	const TYPE_DEBT = 4;






|

|
>


>
>











>
>
>







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
62
63
64

65
66
67
68
69
70
71
		'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;

	protected bool $validated = false;

	protected ?string $hash = null;

	protected ?string $prev_hash = null;

	protected int $id_year;
	protected ?int $id_creator = null;
	protected ?int $id_related = null;

	protected $_lines;







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>










<
<

>







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
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
			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;
		}

		$new->status = 0;

		return $new;
	}

	public function payment_reference(): ?string
	{
		$line = current($this->getLines());

		if (!$line) {
			return null;
		}

		return $line->reference;
	}


/*
	public function getHash()
	{
		if (!$this->id_year) {
			throw new \LogicException('Il n\'est pas possible de hasher un mouvement qui n\'est pas associé à un exercice');
		}

		static $keep_keys = [
			'label',
			'notes',
			'reference',
			'date',
			'validated',
			'prev_hash',
		];

		$hash = hash_init('sha256');
		$values = $this->asArray();
		$values = array_intersect_key($values, $keep_keys);

		hash_update($hash, implode(',', array_keys($values)));
		hash_update($hash, implode(',', $values));

		foreach ($this->getLines() as $line) {




			hash_update($hash, implode(',', [$line->compte, $line->debit, $line->credit]));
		}

		return hash_final($hash, false);
	}






































































	public function checkHash()
	{
		return hash_equals($this->getHash(), $this->hash);
	}
*/











	public function addLine(Line $line)
	{
		$this->_lines[] = $line;
	}

	public function sum(): int







<

















<
<















<
|





<
<
<
<
<
<
<
<
<

|
|





>
>
>
>
|





>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|



|
>
>
>
>
>
>
>
>
>
>







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
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
		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);
			}
		}

		$db = DB::getInstance();

		// Allow only status to be modified
		if (!(count($this->_modified) === 1 && array_key_exists('status', $this->_modified))) {
			if ($this->validated && !(isset($this->_modified['validated']) && $this->_modified['validated'] === 0)) {
				throw new ValidationException('Il n\'est pas possible de modifier une écriture qui a été validée');
			}


			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é');
			}
		}

		$this->selfCheck();

		$lines = $this->getLinesWithAccounts();

		// Self check lines before saving Transaction
		foreach ($lines as $i => $l) {
			$line = $l->line;







<
|
<
<
<
<
<
<
<
<
<
<
<
<







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
597
598
599
600
601
602
603
604
		$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('membres', '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),







|







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
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

			$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)));

		$this->assert($db->test('acc_years', 'id = ?', $this->id_year), 'L\'exercice sélectionné n\'existe pas');
		$this->assert($this->id_creator === null || $db->test('membres', 'id = ?', $this->id_creator), 'Le compte membre créateur de l\'écriture n\'existe pas');

		$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'];

		$key = 'account_transfer';

		if (empty($source[$key]) || !count($source[$key])) {
			throw new ValidationException('Aucun compte de dépôt n\'a été sélectionné');
		}

		$account = key($source[$key]);

		$line = new Line;
		$line->importForm([
			'debit'      => $amount,
			'credit'     => 0,
			'id_account' => $account,
		]);








|
<




















|

|



<
<







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
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

		// Add lines
		if (isset($source['lines']) && is_array($source['lines'])) {
			$this->resetLines();
			$db = DB::getInstance();

			foreach ($source['lines'] as $i => $line) {
				if (empty($line['account'])
					&& empty($line['id_account'])
					&& (empty($line['account_selector'])
						|| !is_array($line['account_selector']) || empty(key($line['account_selector'])))) {
					throw new ValidationException(sprintf('Ligne %d : aucun compte n\'a été sélectionné', $i + 1));
				}

				if (isset($line['account_selector'])) {
					$line['id_account'] = (int)key($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));
					}
				}






				$l = new Line;
				$l->importForm($line);
				$this->addLine($l);
			}
		}








<
<
<
<
<
<
<

|













>
>
>
>
>







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
961
962
963
964
965
966
967
968

		$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) VALUES (?, ?);', $this->id(), $id);
		}

		$db->commit();
	}

	public function checkLinkedUsersChange(array $users): bool
	{







|







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
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001

		return true;
	}

	public function listLinkedUsers(): array
	{
		$db = EntityManager::getInstance(self::class)->DB();
		$identity_column = Config::getInstance()->get('champ_identite');
		$sql = sprintf('SELECT m.id, m.%s AS identity, l.id_service_user FROM membres m INNER JOIN acc_transactions_users l ON l.id_user = m.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 = Config::getInstance()->get('champ_identite');
		$sql = sprintf('SELECT m.id, m.%s AS identity, l.id_service_user
			FROM membres m
			INNER JOIN acc_transactions_users l ON l.id_user = m.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();







|
|






|
|
|
|







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
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
		$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,
			'validated'  => 1,
			'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);




		$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.







<


















>
>
>







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
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
<?php

namespace Garradin\Entities\Files;

use KD2\Graphics\Image;

use KD2\DB\EntityManager as EM;
use KD2\Security;


use Garradin\DB;
use Garradin\Entity;
use Garradin\Plugin;
use Garradin\Template;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Membres\Session;
use Garradin\Static_Cache;
use Garradin\Utils;
use Garradin\Entities\Web\Page;
use Garradin\Web\Render\Render;





use Garradin\Files\Files;

use const Garradin\{WWW_URL, BASE_URL, ENABLE_XSENDFILE, SECRET_KEY};

/**
 * This is a virtual entity, it cannot be saved to a SQL table
 */
class File extends Entity
{
	const TABLE = 'files';

	protected $id;

	/**
	 * Parent directory of file
	 */
	protected $parent;

	/**
	 * File name
	 */
	protected $name;

	/**
	 * Complete file path (parent + '/' + name)
	 */
	protected $path;

	/**
	 * Type of file: file or directory
	 */
	protected $type = self::TYPE_FILE;
	protected $mime;
	protected $size;
	protected $modified;
	protected $image;

	protected $_types = [
		'id'           => '?int',
		'path'         => 'string',
		'parent'       => '?string',
		'name'         => 'string',
		'type'         => 'int',
		'mime'         => '?string',
		'size'         => '?int',
		'modified'     => 'DateTime',
		'image'        => 'int',
	];

	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





>



>


|



|




>
>
>
>



|








|




|




|




|




|
|
|
|
|
<
<
<
<
<
<
<
<
<
<
<
<







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
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
		'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?|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($this->image === 0 || $this->image === 1, 'Unknown image value');
		$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 fullpath(): string
	{
		$path = Files::callStorage('getFullPath', $this);

		if (null === $path) {
			throw new \RuntimeException('File does not exist: ' . $this->path);
		}

		return $path;
	}






	/**
	 * Return TRUE if the file can be previewed natively in a browser
	 * @return bool
	 */
	public function canPreview(): bool
	{
		return in_array($this->mime, self::PREVIEW_TYPES);

	}













































	public function delete(): bool
	{
		Files::callStorage('checkLock');



		// Delete actual file content
		Files::callStorage('delete', $this);

		Plugin::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 . '/%');


		if ($this->exists()) {
			return parent::delete();
		}

		return true;
	}








|










<









>
>
>
>
>











>
>
>
>
>







|
>
|

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>




>
>



|









>







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
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
	/**
	 * 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);
		self::validateFileName(Utils::basename($new_path));


		if ($new_path == $this->path || 0 === strpos($new_path . '/', $this->path . '/')) {


			throw new UserException('Impossible de renommer ou déplacer un fichier vers lui-même');



		}



		$return = Files::callStorage('move', $this, $new_path);

		Plugin::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 self::createAndStore(Utils::dirname($target), Utils::basename($target), Files::callStorage('getFullPath', $this), null);
	}

	public function setContent(string $content): self
	{
		$this->set('modified', new \DateTime);
		$this->store(null, rtrim($content));
		$this->indexForSearch($content);
		return $this;
	}

	/**
	 * Store contents in file, either from a local path or from a binary string
	 * If one parameter is supplied, the other must be NULL (you cannot omit one)
	 *
	 * @param  string $source_path
	 * @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(?string $source_path, ?string $source_content, 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 ($source_path && !$source_content)



		{

			$this->set('size', filesize($source_path));

		}
		else
		{
			$this->set('size', strlen($source_content));

		}













		Files::checkQuota($this->size);


		// Check that it's a real image
		if ($this->image) {
			try {


				if ($source_path && !$source_content) {
					$i = new Image($source_path);

				}
				else {


					$i = Image::createFromBlob($source_content);




				}

				// Recompress PNG files from base64, assuming they are coming
				// from JS canvas which doesn't know how to gzip (d'oh!)
				if ($i->format() == 'png' && null !== $source_content) {

					$source_content = $i->output('png', true);
					$this->set('size', strlen($source_content));

				}

				unset($i);


			}
			catch (\RuntimeException $e) {


				$this->set('image', 0);
			}
		}

		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 (!$this->modified) {
			$this->set('modified', new \DateTime);
		}

		if (null !== $source_path) {
			$return = Files::callStorage('storePath', $this, $source_path);
		}



		else {
			$return = Files::callStorage('storeContent', $this, $source_content);

		}

		if (!$return) {
			throw new UserException('Le fichier n\'a pas pu être enregistré.');
		}

		Plugin::fireSignal('files.store', ['file' => $this]);

		if ($index_search) {
			$this->indexForSearch($source_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));
		}



		return $this;
	}

	public function indexForSearch(?string $source_content, ?string $title = null, ?string $forced_mime = null): void
	{
		$mime = $forced_mime ?? $this->mime;







>
>

|
>

|
>
>
|
>
>
>
|
|
>
>


|



















|





|
<




|
<

|




|









>
>
>
>
>
>
|
>
>
>
|
>
|
>

|
<
|
>

>
>
>
>
>
|
>
>
>
>
>
>
>
|
|
>


|
>
>
|
|
>
|
|
>
>
|
>
>
>
>




|
>
|
|
>

|
<
>
>

<
>
>
|












|



|
|

>
>
>

|
>






|

|
|










>
>







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
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

	public function removeFromSearch(): void
	{
		$db = DB::getInstance();
		$db->preparedQuery('DELETE FROM files_search WHERE path = ?;', $this->path);
	}

	/**
	 * Create and store a file
	 * If one parameter is supplied, the other must be NULL (you cannot omit one)
	 * @param  string $path           Target path
	 * @param  string $name           Target name
	 * @param  string $source_path    Source file path
	 * @param  string $source_content OR source file content (binary string)
	 * @return self
	 */
	static public function createAndStore(string $path, string $name, ?string $source_path, ?string $source_content): self
	{
		$file = self::create($path, $name, $source_path, $source_content);

		$file->store($source_path, $source_content);

		return $file;
	}

	/**
	 * Create a new directory
	 * @param  string $path          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 createDirectory(string $path, string $name, bool $create_parent = true): self
	{
		$name = self::filterName($name);

		$fullpath = trim($path . '/' . $name, '/');

		self::validatePath($fullpath);
		Files::checkQuota();

		if (Files::callStorage('exists', $fullpath)) {
			throw new ValidationException('Le nom de répertoire choisi existe déjà: ' . $fullpath);
		}

		if ($path !== '' && $create_parent) {
			self::ensureDirectoryExists($path);
		}

		$file = new self;
		$file->set('path', $fullpath);
		$file->set('name', $name);
		$file->set('parent', $path);
		$file->set('type', self::TYPE_DIRECTORY);
		$file->set('image', 0);
		$file->set('modified', new \DateTime);

		Files::callStorage('mkdir', $file);

		Plugin::fireSignal('files.mkdir', ['file' => $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 = ?', self::TYPE_DIRECTORY, $tree);

			if (!$exists) {
				try {
					self::createDirectory(Utils::dirname($tree), Utils::basename($tree), false);
				}
				catch (ValidationException $e) {
					// Ignore when directory already exists
				}
			}
		}
	}

	static public function create(string $path, string $name, ?string $source_path, ?string $source_content): self
	{
		if (!isset($source_path) && !isset($source_content)) {
			throw new \InvalidArgumentException('Either source path or source content should be set but not both');
		}

		self::validateFileName($name);
		self::validatePath($path);
		self::ensureDirectoryExists($path);

		$finfo = \finfo_open(\FILEINFO_MIME_TYPE);

		$fullpath = $path . '/' . $name;
		$file = Files::callStorage('get', $fullpath) ?: new self;
		$file->set('path', $fullpath);
		$file->set('parent', $path);
		$file->set('name', $name);

		if ($source_path && !$source_content) {
			$file->set('mime', finfo_file($finfo, $source_path));
			$file->set('size', filesize($source_path));
			$file->set('modified', new \DateTime('@' . filemtime($source_path)));
		}
		else {
			$file->set('mime', finfo_buffer($finfo, $source_content));
			$file->set('size', strlen($source_content));
		}

		$file->set('image', (int) in_array($file->mime, self::IMAGE_TYPES));

		// Force empty files as text/plain
		if ($file->mime == 'application/x-empty' && !$file->size) {
			$file->set('mime', 'text/plain');
		}

		return $file;
	}

	/**
	 * Upload multiple files
	 * @param  string $path 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 $path, 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::getErrorMessage($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 = self::filterName($file['name']);

			$out[] = self::createAndStore($path, $name, $file['tmp_name'], null);
		}

		return $out;
	}

	/**
	 * Upload a file using POST from a HTML form
	 * @param  string $path 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 $path, string $key, ?string $name = null): self
	{
		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::getErrorMessage($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 = self::filterName($name ?? $file['name']);

		return self::createAndStore($path, $name, $file['tmp_name'], null);
	}


	/**
	 * Récupération du message d'erreur
	 * @param  integer $error Code erreur du $_FILE
	 * @return string Message d'erreur
	 */
	static public function getErrorMessage($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;
		}
	}

	/**
	 * 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;
	}
























































	/**
	 * Full URL with https://...
	 */
	public function url(bool $download = false): string
	{
		$base = in_array($this->context(), [self::CONTEXT_WEB, self::CONTEXT_SKELETON, self::CONTEXT_CONFIG]) ? WWW_URL : BASE_URL;
		$url = $base . $this->uri();

		if ($download) {
			$url .= '?download';
		}

		return $url;







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<














>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>





|







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
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
		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
	{
		$can_access = $this->checkReadAccess($session);

		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');
				exit;
			}
		}

		if (!$can_access) {
			header('HTTP/1.1 403 Forbidden', true, 403);
			throw new UserException('Vous n\'avez pas accès à ce fichier.');
			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');
		}

		$path = Files::callStorage('getFullPath', $this);
		$content = null === $path ? Files::callStorage('fetch', $this) : null;

		$this->_serve($path, $content, $download);

















	}

	/**
	 * Envoie une miniature à la taille indiquée au client HTTP
	 */
	public function serveThumbnail(?Session $session = null, string $size = null): void
	{
		if (!$this->checkReadAccess($session)) {
			header('HTTP/1.1 403 Forbidden', true, 403);
			throw new UserException('Accès interdit');
			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.');
		}








>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>






|










|





|






|






>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







|

|







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('/[_.-]/', '&shy;$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
787
788
789
790
791
792
793
794
			}
			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()) {
			Utils::HTTPCache(md5($this->path . $this->size . $this->modified->getTimestamp()), $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');
		}







>
>
>
>












|







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
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
			$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 && ENABLE_XSENDFILE && isset($_SERVER['SERVER_SOFTWARE']))
		{
			if (stristr($_SERVER['SERVER_SOFTWARE'], 'apache')
				&& function_exists('apache_get_modules')
				&& in_array('mod_xsendfile', apache_get_modules()))
			{
				header('X-Sendfile: ' . $path);
				return;
			}
			else if (stristr($_SERVER['SERVER_SOFTWARE'], 'lighttpd'))
			{
				header('X-Sendfile: ' . $path);
				return;
			}
		}

		// Désactiver gzip
		if (function_exists('apache_setenv'))
		{
			@apache_setenv('no-gzip', 1);
		}







|

<
<
<
<
<
|
<
<
<
<
<
<







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
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
		return Files::callStorage('fetch', $this);
	}

	public function render(?string $user_prefix = null)
	{
		$editor_type = $this->renderFormat();

		if ($editor_type == 'text') {
			return sprintf('<pre>%s</pre>', htmlspecialchars($this->fetch()));
		}
		elseif (!$editor_type) {
			throw new \LogicException('Cannot render file of this type');
		}
		else {
			return Render::render($editor_type, $this, $this->fetch(), $user_prefix);
		}
	}

	public function checkReadAccess(?Session $session): bool
	{
		// Web pages and config files are always public
		if ($this->isPublic()) {
			return true;
		}


		$context = $this->context();
		$ref = strtok(substr($this->path, strpos($this->path, '/')), '/');

		if (null === $session || !$session->isLogged()) {
			return false;
		}


		if ($context == self::CONTEXT_TRANSACTION && $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_READ)) {
			return true;
		}
		// The user can access his own profile files
		else if ($context == self::CONTEXT_USER && $ref == $session->getUser()->id) {
			return true;
		}
		// Only users able to manage users can see their profile files
		else if ($context == self::CONTEXT_USER && $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE)) {
			return true;
		}
		// Only users with right to access documents can read documents



		else if ($context == self::CONTEXT_DOCUMENTS && $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_READ)) {

			return true;
		}

		return false;
	}



	public function checkWriteAccess(?Session $session): bool
	{
		if (null === $session) {
			return false;
		}

		switch ($this->context()) {
			case self::CONTEXT_WEB:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE);

			case self::CONTEXT_DOCUMENTS:
				// Only admins can delete files


				return $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_WRITE);
			case self::CONTEXT_CONFIG:
				return $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN);
			case self::CONTEXT_TRANSACTION:
				return $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE);
			case self::CONTEXT_SKELETON:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN);

			case self::CONTEXT_USER:
				return $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE);
		}






		return false;
	}



	public function checkDeleteAccess(?Session $session): bool

	{
		if (null === $session) {
			return false;
		}

		switch ($this->context()) {
			case self::CONTEXT_WEB:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE);
			case self::CONTEXT_DOCUMENTS:
				// Only admins can delete files
				return $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_ADMIN);

			case self::CONTEXT_CONFIG:
				return $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN);

			case self::CONTEXT_TRANSACTION:


				return $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_ADMIN);
			case self::CONTEXT_SKELETON:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN);
			case self::CONTEXT_USER:
				return $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE);
		}

		return false;
	}






	static public function checkCreateAccess(string $path, ?Session $session): bool

	{
		if (null === $session) {
			return false;
		}

		$context = strtok($path, '/');

		switch ($context) {
			case self::CONTEXT_WEB:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_WRITE);
			case self::CONTEXT_DOCUMENTS:
				return $session->canAccess($session::SECTION_DOCUMENTS, $session::ACCESS_WRITE);
			case self::CONTEXT_CONFIG:

				return $session->canAccess($session::SECTION_CONFIG, $session::ACCESS_ADMIN);

			case self::CONTEXT_TRANSACTION:


				return $session->canAccess($session::SECTION_ACCOUNTING, $session::ACCESS_WRITE);
			case self::CONTEXT_SKELETON:
				return $session->canAccess($session::SECTION_WEB, $session::ACCESS_ADMIN);
			case self::CONTEXT_USER:
				return $session->canAccess($session::SECTION_USERS, $session::ACCESS_WRITE);
		}

		return false;
	}

	public function pathHash(): string
	{
		return sha1($this->path);
	}

	public function isPublic(): bool
	{
		$context = $this->context();





		if ($context == self::CONTEXT_SKELETON || $context == self::CONTEXT_CONFIG || $context == self::CONTEXT_WEB) {



			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 (substr($name[0], 0, 1) === '.') {
			throw new ValidationException('Le nom de fichier ne peut commencer par un point');
		}

		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
	{




		$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);
		}

		foreach ($parts as $part) {
			if (substr($part, 0, 1) == '.') {
				throw new ValidationException('Chemin invalide: ' . $path);
			}
		}

		$name = array_pop($parts);
		$ref = implode('/', $parts);
		return [$context, $ref ?: null, $name];
	}
























	public function renderFormat(): ?string
	{
		if (substr($this->name, -6) == '.skriv') {
			$format = Render::FORMAT_SKRIV;
		}
		elseif (substr($this->name, -3) == '.md') {
			$format = Render::FORMAT_MARKDOWN;
		}
		else if (substr($this->mime, 0, 5) == 'text/') {
			$format = 'text';
		}
		else if ($this->size == 0) {
			$format = 'text';
		}
		else {
			$format = null;
		}

		return $format;
	}

	public function editorType(): ?string
	{




		$format = $this->renderFormat();




		if ($format == 'text') {
			return 'code';
		}



		elseif ($format == Render::FORMAT_SKRIV || $format == Render::FORMAT_MARKDOWN) {

			return 'web';
		}

		return null;
	}



































































	/**
	 * Returns a sharing link for a file, valid
	 * @param  int $expiry Expiry, in hours
	 * @param  string|null $password
	 * @return string
	 */







|
|

|
|


|



|






>
|
|
|
<
<
|
>
|
|
<
|
<
|
|

|
<
|
|
|
>
>
>
|
>
|


|

>
>
|
|
|
|



<
<
|
>
|
<
>
>
|
|
|
<
<
<
|
>
|
|
|

>
>
>
>
>
|
|
>
>
|
|
>

|



<
<
|
|
|
|
>
|
|
>
|
>
>
|
|
|
<
|


|

>
>
|
>
>
>
|
>
|
|



|
|
|
<
|
<
<
<
>
|
>
|
>
>
|
|
|
<
|


|











>
>
>
>
|
>
>
>
|
|
>
















|
|



















>
>
>
>












<
<
<
<
<
<




>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>









|
<
<
<











>
>
>
>


>
>
>
|


>
>
>
|
>
|




>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
1146



















			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
5
6
7
8

9
10

11
12
13
14
15
16
17
18



19
20
21
22
23
24
25
<?php

namespace Garradin\Entities\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;

use Garradin\ValidationException;
use Garradin\Utils;

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 TABLE = 'services_fees';

	protected ?int $id;
	protected string $label;
	protected ?string $description = null;
	protected ?int $amount = null;
	protected ?string $formula = null;




<



>


>








>
>
>







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
46
47
48
49
50
51
52
53
54

	public function importForm(array $source = null)
	{
		if (null === $source) {
			$source = $_POST;
		}

		if (isset($source['account']) && is_array($source['account'])) {
			$source['id_account'] = (int)key($source['account']);
		}

		if (isset($source['amount_type'])) {
			if ($source['amount_type'] == 2) {
				$source['amount'] = null;
			}
			elseif ($source['amount_type'] == 1) {







|
|







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
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
		}

		return null;
	}

	protected function getFormulaSQL()
	{
		return sprintf('SELECT %s FROM membres WHERE id = ?;', $this->formula);
	}

	protected function checkFormula(): ?string
	{
		try {
			$db = DB::getInstance();
			$sql = $this->getFormulaSQL();
			$db->protectSelect(['membres' => 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 = Config::getInstance()->get('champ_identite');
		$columns = [
			'id_user' => [
				'select' => 'su.id_user',
			],
			'user_number' => [
				'label' => 'Numéro de membre',
				'select' => 'm.numero',
				'export_only' => true,
			],
			'identity' => [
				'label' => 'Membre',
				'select' => 'm.' . $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 membres m ON m.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 m.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 m.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 m.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 m.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());
	}
}







|







|














>
|






|




|

















|





|


















|







|







|













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
8

9
10
11
12
13
14
15
<?php

namespace Garradin\Entities\Services;

use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Config;

use KD2\DB\EntityManager;

class Reminder extends Entity
{
	const TABLE = 'services_reminders';

	protected $id;







|
>







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
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
		}

		parent::importForm($source);
	}

	public function sentList(): DynamicList
	{
		$identity = Config::getInstance()->get('champ_identite');
		$columns = [
			'id_user' => [
				'select' => 'srs.id_user',
			],
			'identity' => [
				'label' => 'Membre',
				'select' => 'm.' . $identity,
			],
			'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 membres m ON m.id = srs.id_user';
		$conditions = sprintf('srs.id_reminder = %d', $this->id());

		$list = new DynamicList($columns, $tables, $conditions);
		$list->orderBy('date', true);
		return $list;
	}

}







|






|













|








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
5
6
7
8
9
10

11
12
13
14



15
16
17
18
19
20
21
<?php

namespace Garradin\Entities\Services;

use Garradin\Config;
use Garradin\DB;
use Garradin\DynamicList;
use Garradin\Entity;
use Garradin\ValidationException;
use Garradin\Utils;

use Garradin\Services\Fees;

class Service extends Entity
{



	const TABLE = 'services';

	protected $id;
	protected $label;
	protected $description;
	protected $duration;
	protected $start_date;




<





>




>
>
>







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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
	public function fees()
	{
		return new Fees($this->id());
	}

	public function allUsersList(): DynamicList
	{
		$identity = Config::getInstance()->get('champ_identite');
		$columns = [
			'id_user' => [
			],
			'end_date' => [
			],
			'user_number' => [
				'label' => 'Numéro de membre',
				'select' => 'm.numero',
				'export_only' => true,
			],
			'identity' => [
				'label' => 'Membre',
				'select' => 'm.' . $identity,
			],
			'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é ?',







|







|




|







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
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
			'date' => [
				'label' => 'Date d\'inscription',
				'select' => 'su.date',
			],
		];

		$tables = 'services_users su
			INNER JOIN membres m ON m.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 m.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 m.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 m.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 m.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 = 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_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);







|




|













|







|







|






|
|







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
7
8
9
10

11
12
13
14
15
16
17
<?php

namespace Garradin\Entities\Services;

use Garradin\DB;
use Garradin\Entity;
use Garradin\Membres;
use Garradin\ValidationException;
use Garradin\Services\Fees;
use Garradin\Services\Services;

use Garradin\Accounting\Transactions;
use Garradin\Entities\Accounting\Transaction;
use Garradin\Entities\Accounting\Line;

use KD2\DB\Date;

class Service_User extends 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
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

	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 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,

		];

		if ($using_date) {
			$params['date'] = $this->date->format('Y-m-d');
		}

		$where = array_map(fn($k) => sprintf('%s = ?', $k), array_keys($params));







|

















>







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

156

157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
			throw new ValidationException('Le tarif indiqué ne possède pas d\'exercice lié');
		}

		if (empty($source['amount'])) {
			throw new ValidationException('Montant non précisé');
		}


		if (empty($source['account_selector']) || !is_array($source['account_selector']) || !key($source['account_selector'])) {

			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)', (new Membres)->getNom($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],







>
|
>









|







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

5
6
7
8
9
10
11
12



13
14
15
16
17
18
19
<?php

namespace Garradin\Entities\Users;


use Garradin\Membres\Session;
use Garradin\Config;
use Garradin\DB;
use Garradin\UserException;
use Garradin\Entity;

class Category extends Entity
{



	const TABLE = 'users_categories';

	protected $id;
	protected $name;

	protected $hidden = 0;





>
|
|
|

|



>
>
>







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
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
		'perm_connect'    => 'int',
		'perm_config'     => 'int',
	];

	const PERMISSIONS = [
		'connect' => [
			'label' => 'Les membres de cette catégorie peuvent-ils se connecter ?',
			'shape' => 'C',
			'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' => 'M',
			'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)',
				Session::ACCESS_ADMIN => 'Administration (peut tout faire)',
			],
		],
		'accounting' => [
			'label' => 'Comptabilité',
			'shape' => '€',
			'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 modifier et supprimer des écritures, gérer les comptes, les exercices, etc.)',
			],
		],
		'documents' => [
			'label' => 'Documents',
			'shape' => 'D',
			'options' => [
				Session::ACCESS_NONE => 'Pas d\'accès',
				Session::ACCESS_READ => 'Lecture uniquement (peut lire tous les fichiers)',
				Session::ACCESS_WRITE => 'Lecture & écriture (peut lire, ajouter, modifier et déplacer des fichiers, mais pas les supprimer)',
				Session::ACCESS_ADMIN => 'Administration (peut tout faire)',
			],
		],
		'web' => [
			'label' => 'Gestion du site web',
			'shape' => 'W',
			'options' => [
				Session::ACCESS_NONE => 'Pas d\'accès',
				Session::ACCESS_READ => 'Lecture uniquement (peut lire les pages)',
				Session::ACCESS_WRITE => 'Lecture & écriture (peut ajouter, modifier et supprimer des pages et catégories, mais pas modifier la configuration)',
				Session::ACCESS_ADMIN => 'Administration (peut tout faire)',
			],
		],
		'config' => [
			'label' => 'Les membres de cette catégorie peuvent-ils modifier la configuration ?',
			'shape' => '☑',
			'options' => [
				Session::ACCESS_NONE => 'Ne peut pas modifier la configuration',
				Session::ACCESS_ADMIN => 'Peut modifier la configuration',
			],
		],
	];








|







|



|





|




|




|



|
|




|



|





|







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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137











	}

	public function delete(): bool
	{
		$db = DB::getInstance();
		$config = Config::getInstance();

		if ($this->id() == $config->get('categorie_membres')) {
			throw new UserException('Il est interdit de supprimer la catégorie définie par défaut dans la configuration.');
		}

		if ($db->test('membres', '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);
		}
	}
}


















|



|














|
>
>
>
>
>
>
>
>
>
>
>
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].

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
declare(strict_types=1);

namespace Garradin\Entities\Users;

use Garradin\Entity;
use Garradin\UserException;
use Garradin\Users\Emails;

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');
		}

		$message = "Bonjour,\n\nPour vérifier votre adresse e-mail pour notre association,\ncliquez sur le lien ci-dessous :\n\n";
		$message.= self::getOptoutURL($this->hash) . '&v=' . $this->getVerificationCode();
		$message.= "\n\nSi vous n'avez pas demandé à recevoir ce message, ignorez-le.";

		Emails::queue(Emails::CONTEXT_SYSTEM, [$email => null], null, 'Confirmez votre adresse e-mail', $message);
	}

	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/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
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
<?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';

	protected $id;
	protected $parent;
	protected $path;
	protected $uri;
	protected $_name = 'index.txt';
	protected $file_path;
	protected $title;
	protected $type;
	protected $status;
	protected $format;
	protected $published;
	protected $modified;
	protected $content;

	protected $_types = [
		'id'        => 'int',
		'parent'    => 'string',
		'path'      => 'string',
		'uri'       => 'string',
		'file_path' => 'string',
		'title'     => 'string',
		'type'      => 'int',
		'status'    => 'string',
		'format'    => 'string',
		'published' => 'DateTime',
		'modified'  => 'DateTime',
		'content'   => 'string',
	];

	const FORMATS_LIST = [
		Render::FORMAT_MARKDOWN => 'MarkDown',
		Render::FORMAT_SKRIV => 'SkrivML',
		Render::FORMAT_ENCRYPTED => 'Chiffré',
	];







>






>







>
>


|
|
|
|
|
|
|
|
|
|
|
|
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







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
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

		$export = $this->export();

		$exists = Files::callStorage('exists', $path);

		// Create file if required
		if (!$exists) {
			$file = $this->_file = File::createAndStore(Utils::dirname($path), Utils::basename($path), null, $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(null, $export, false);
			}
		}

		$this->syncSearch();
	}

	public function syncSearch(): void







|
















|







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
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

		if (isset($source['date']) && isset($source['date_time'])) {
			$source['published'] = $source['date'] . ' ' . $source['date_time'];
		}

		$parent = $this->parent;

		if (isset($source['title']) && is_null($this->path)) {
			$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;

		if (array_key_exists('parent', $source)) {
			if (is_array($source['parent'])) {
				$source['parent'] = key($source['parent']);
			}

			if (empty($source['parent'])) {
				$source['parent'] = '';
			}

			$parent = $source['parent'];
			$source['path'] = trim($parent . '/' . $uri, '/');
		}

		if (!empty($source['encryption']) ) {
			$this->set('format', Render::FORMAT_ENCRYPTED);
		}
		elseif (empty($source['format'])) {
			$this->set('format', Render::FORMAT_MARKDOWN);







|













|


<
<
<
<
<
|
<
<
<
|







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
562
563
564



565
566
567
568
569
570
571
			throw new \LogicException('Invalid page content: ' . $file->parent);
		}

		if (empty($this->modified)) {
			$this->set('modified', $file->modified);
		}

		if ($this->type != self::TYPE_CATEGORY) {
			$this->set('type', $this->checkRealType());
		}



	}

	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) {







|


>
>
>







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
66
67
68
69
70
71
72
73
			}
			elseif ($value instanceof \DateTimeInterface) {
				return Date::createFromInterface($value);
			}

			return self::filterUserDateValue($value);
		}
		elseif ($type == 'DateTime') {
			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);
	}







|







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
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
	// 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
		if (Plugin::fireSignal($name . '.before', ['entity' => $this])) {
			return true;
		}

		// Generic entity signal
		if (Plugin::fireSignal('entity.save.before', ['entity' => $this])) {
			return true;
		}

		$return = parent::save($selfcheck);







		Plugin::fireSignal($name . '.after', ['entity' => $this, 'success' => $return]);

		Plugin::fireSignal('entity.save.after', ['entity' => $this, 'success' => $return]);

		return $return;
	}

	public function delete(): bool
	{
		$name = get_class($this);
		$name = str_replace('Garradin\Entities\\', '', $name);
		$name = 'entity.' . $name . '.delete';



		if (Plugin::fireSignal($name . '.before', ['entity' => $this])) {
			return true;
		}

		// Generic entity signal
		if (Plugin::fireSignal('entity.delete.before', ['entity' => $this])) {
			return true;
		}

		$return = parent::delete();





		Plugin::fireSignal($name . '.after', ['entity' => $this, 'success' => $return]);
		Plugin::fireSignal('entity.delete.after', ['entity' => $this, 'success' => $return]);

		return $return;
	}
}







>
>
>
>
>
>
>
>

|




|



|
>
>
>
>
>
>
>
|

|






|
|
|

>
>
|




|




>
>
>
>
>
|
|




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
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24














































































































































































25
26
27
28
29
30
31
<?php

namespace Garradin\Files;

use Garradin\Static_Cache;

use Garradin\DB;

use Garradin\Utils;
use Garradin\UserException;
use Garradin\ValidationException;
use Garradin\Membres\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 search(string $search, string $path = null): array
	{
		if (strlen($search) > 100) {
			throw new ValidationException('Recherche trop longue : maximum 100 caractères');
		}






>

>



|














>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
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
		}

		$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) {
			throw new UserException('Ce chemin n\'est pas un répertoire');
		}

		// Update this path
		return self::callStorage('list', $parent);
	}





	static public function zip(string $parent, ?Session $session)
	{
		$file = Files::get($parent);


		if (!$file) {
			throw new UserException('Ce répertoire n\'existe pas.');
		}









		if ($session && !$file->checkReadAccess($session)) {
			throw new UserException('Vous n\'avez pas accès à ce répertoire');




		}

		$zip = new ZipWriter('php://output');
		$zip->setCompression(0);

		$add_file = function ($subpath) use ($zip, $parent, &$add_file) {
			foreach (self::list($subpath) as $file) {
				if ($file->type == $file::TYPE_DIRECTORY) {
					$add_file($file->path);
					continue;
				}

				$dest_path = substr($file->path, strlen($parent . '/'));
				$zip->add($dest_path, null, $file->fullpath());
			}
		};

		$add_file($parent);

		$zip->close();
	}
























	static public function listForContext(string $context, ?string $ref = null)
	{
		$path = $context;

		if ($ref) {
			$path .= '/' . $ref;
		}

		return self::list($path);
	}




	static public function delete(string $path): void
	{
		$file = self::get($path);

		if (!$file) {
			return;
		}







>
>
>
>
>









|






>
>
>
>
|

<
>
|
<
<
|
>
>
>
>
>
>
>
>
|
|
<
>
>
>
>


|


<
|
<
<
<
<
|
<
|

<
|
<




>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|










>
>
>







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
270










271
272
273
274
275
276
277

















278
279
280
281
282
283
284
		}

		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
	{
		$context = strtok($path, '/');











		if (!array_key_exists($context, File::CONTEXTS_NAMES)) {
			return null;
		}

		return $context;
	}


















	static public function getContextRef(string $path): ?string
	{
		$context = strtok($path, '/');
		return strtok('/') ?: null;
	}








>
>
>
>
>
>
>
>
>
>















>
>
>
>
>
>
>
>
>











|
>
>
>
>
>
>
>
>
>
>







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
		$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 enableQuota(): void
	{
		self::$quota = true;
	}

	static public function disableQuota(): void
	{
		self::$quota = false;
	}

	static public function getVirtualTableName(): string
	{
		if (FILE_STORAGE_BACKEND == 'SQLite') {
			return 'files';
		}

		return 'tmp_files';







<
<
<
<
<
<
<
<
<
<







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
388


























































































































































































































































































































			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
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

		if ($return) {
			touch($target, $file->modified->getTimestamp());
		}

		return $return;
	}



















	static public function mkdir(File $file): bool
	{
		return Utils::safe_mkdir(self::getFullPath($file));
	}

	static public function touch(string $path): bool
	{




		return touch(self::_getRealPath($path));
	}

	static protected function _getRealPath(string $path): ?string
	{
		$path = self::_getRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path);

		$parts = explode(DIRECTORY_SEPARATOR, $path);

		foreach ($parts as $part) {
			if (substr($part, 0, 1) === '.') {
				return null;
			}
		}

		return $path;
	}

	static public function getFullPath(File $file): ?string
	{
		return self::_getRealPath($file->path);
	}






	static public function display(File $file): void
	{
		readfile(self::getFullPath($file));
	}

	static public function fetch(File $file): string







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>






|

>
>
>
>
|




<
|
<
<
<
<
|
|
|
|
<






>
>
>
>
>







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
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

class SQLite implements StorageInterface
{
	static public function configure(?string $config): void
	{
	}

	/**
	 * Renvoie le chemin vers le fichier local en cache, et le crée s'il n'existe pas
	 * @return string Chemin local
	 */
	static protected function _getFilePathFromCache(File $file): string
	{
		$cache_id = 'files.' . $file->pathHash();

		if (!Static_Cache::exists($cache_id))
		{
			$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);
			}

			Static_Cache::storeFromPointer($cache_id, $blob);
			fclose($blob);
		}



		return Static_Cache::getPath($cache_id);
	}

	static public function storePath(File $file, string $source_path): bool
	{
		return self::store($file, $source_path, null);
	}

	static public function storeContent(File $file, string $source_content): bool
	{
		return self::store($file, null, $source_content);
	}

	static protected function store(File $file, ?string $source_path, ?string $source_content): bool
	{
		if (!isset($source_path) && !isset($source_content)) {
			throw new \InvalidArgumentException('Either source_path or source_content must be supplied');
		}



		$db = DB::getInstance();



		$file->size = $source_content !== null ? strlen($source_content) : filesize($source_path);


		$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 !== $source_content) {
			fwrite($blob, $source_content);
		}
		else {
			$in = fopen($source_path, 'r');

			stream_copy_to_stream($in, $blob);






			fclose($in);

		}

		fclose($blob);

		$cache_id = 'files.' . $file->pathHash();
		Static_Cache::remove($cache_id);

		if ($file->parent) {
			self::touch($file->parent);
		}




		return true;
	}

	static public function getFullPath(File $file): ?string
	{














		return self::_getFilePathFromCache($file);
	}

	static public function display(File $file): void
	{

		readfile(self::getFullPath($file));





	}

	static public function fetch(File $file): string
	{


		return file_get_contents(self::getFullPath($file));






	}

	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 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 exists(string $path): bool
	{
		return DB::getInstance()->test('files', 'path = ?', $path);
	}

	static public function delete(File $file): bool







<
<
<
<
|
<
<
<
<
|
|

|
|
|
|
|
|
|

|
|

<
|
|

>
>
|


|

|


|

|


|

|
|

>
>
|
|
>
>

<
>










|
|

|
|
>
|
>
>
>
>
>
>
|
>




<
<
<



>
>
>






>
>
>
>
>
>
>
>
>
>
>
>
>
>
|




>
|
>
>
>
>
>




>
>
|
>
>
>
>
>
>







>
>
>
>
>

















>
>
>
>
>







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
188
189










190
191
192
193
194
195
196
197
		}

		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;
	}

	static public function touch(string $path): bool
	{










		return DB::getInstance()->preparedQuery('UPDATE files SET modified = ? WHERE path = ?;', new \DateTime, $path);
	}

	static public function mkdir(File $file): bool
	{
		$file->save();

		if ($file->parent) {







>
>
>


















|

>
>
>
>
>
>
>
>
>
>
|







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
54
55
56
57
58
59
60
61
	/**
	 * 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
	 */
	static public function touch(string $path): bool;

	/**
	 * Return TRUE if file exists
	 */
	static public function exists(string $path): bool;

	/**







>
>
>
>
>


















|







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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

		$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());

		$sum = 0;

		// 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;
	}
}







<
<













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
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\Files;

use Garradin\Entities\Files\File;
use Garradin\DynamicList;
use Garradin\Config;

class Users
{
	const LIST_COLUMNS = [
		'number' => [
			'select' => 'm.numero',
			'label' => 'Numéro',
		],
		'identity' => [
			'select' => '',
			'label' => '',
		],
		'path' => [
		],
		'id' => [
			'label' => null,
			'select' => 'm.id',
		],
	];

	static public function list()
	{
		Files::syncVirtualTable(File::CONTEXT_USER);

		$config = Config::getInstance();
		$name_field = $config->get('champ_identite');
		$champs = $config->get('champs_membres');

		$columns = self::LIST_COLUMNS;
		$columns['identity']['select'] = 'm.' . $name_field;
		$columns['identity']['label'] = $champs->get($name_field)->title;


		$tables = sprintf('%s f INNER JOIN membres m ON m.id = f.name', Files::getVirtualTableName());

		$sum = 0;

		// 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 m.id)');

		return $list;
	}
}






|





<










|



|



<
<
<
<

|
|
>

|
<
<






|




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
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
	{
		if (is_string($condition) && empty($_POST[$condition])) {
			return null;
		}
		elseif (is_bool($condition) && !$condition) {
			return null;
		}

		return $this->run($fn, $csrf_key, $redirect);
	}

	/**
	 * @deprecated
	 */
	public function check($token_action = '', Array $rules = null)
	{







|








|







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
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






















	}

	public function addError($msg)
	{
		$this->errors[] = $msg;
	}

	public function getErrorMessages($membre = false)
	{
		$errors = [];
		$champs = null;

		if ($membre) {
			$champs = Config::getInstance()->get('champs_membres');
		}

		foreach ($this->errors as $error)
		{
			if (is_array($error))
			{
				if ($membre && $champs) {
					$error['name'] = $champs->get($error['name'], 'title');
				}

				$errors[] = $this->getErrorMessage($error['rule'], $error['name'], $error['params']);
			}
			else
			{
				$errors[] = $error;
			}
		}

		return $errors;
	}

	protected function getFieldName($name)
	{
		switch ($name)
		{
			case '_id': return 'identifiant';
			case 'passe': return 'mot de passe';
			case 'debut': return 'date de début';
			case 'fin': return 'date de fin';
			case 'duree': return 'durée';
			case 'passe_check': return 'vérification de mot de passe';
			case 'id_account': return 'compte';
			case 'label': return 'libellé';
			default: return $name;
		}
	}

	protected function getErrorMessage($rule, $element, Array $params)
	{
		$element = $this->getFieldName($element);

		switch ($rule)
		{
			case 'required':
			case 'required_if':
			case 'required_unless':
			case 'required_with':
			case 'required_with_all':
			case 'required_without':
			case 'required_without_all':
				return sprintf('Le champ %s est vide.', $element);
			case 'min':
				return sprintf('Le champ %s doit faire au moins %d caractères.', $element, $params[0]);
			case 'max':
				return sprintf('Le champ %s doit faire moins de %d caractères.', $element, $params[0]);
			case 'file':
				return sprintf('Le fichier envoyé n\'est pas valide.');
			case 'confirmed':
				return sprintf('La vérification du champ %s n\'est pas identique au champ lui-même.', $element);
			case 'date_format':
				return sprintf('Format de date invalide dans le champ %s.', $element);
			case 'numeric':
				return sprintf('Le champ %s doit être un nombre.', $element);
			case 'money':
				return sprintf('Le champ %s n\'est pas un nombre valide.', $element);
			case 'in':
			case 'in_table':
				return sprintf('Valeur invalide dans le champ \'%s\'.', $element);
			default:
				return sprintf('Erreur "%s" dans le champ "%s"', $rule, $element);
		}
	}

	public function __invoke($key)
	{
		return \KD2\Form::get($key);
	}
}





























|

<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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

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 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;

use Garradin\Membres\Session;

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
{

















	/**
	 * 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.local.php
	 */
	static public function ping(): void
	{
		if (DISABLE_INSTALL_PING) {
			return;
		}









>

>
>
>

>
|









>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>




|







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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
			'sqlite_options' => trim($options, ', '),
		]);
	}

	/**
	 * Reset the database to empty and create a new user with the same password
	 */
	static public function reset(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->nom_asso)) {
			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.');
		}








|









|







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
99
100
101
102
103
104
105
106
107
108
109

		Config::deleteInstance();
		DB::getInstance()->close();
		DB::deleteInstance();

		file_put_contents(CACHE_ROOT . '/reset', json_encode([
			'password'     => $session::hashPassword($password),
			'name'         => $user->identite,
			'email'        => $user->email,
			'organization' => $config->nom_asso,
			'country'      => $config->pays,
		]));

		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;
	}







|

|
|







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
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
		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['user_password']) && isset($source['user_password_confirm']) && trim($source['user_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['user_password']) >= Session::MINIMUM_PASSWORD_LENGTH, 'Le mot de passe est trop court');
		self::assert($source['user_password'] === $source['user_password_confirm'], 'La vérification du mot de passe ne correspond pas');




		try {
			self::install($source['country'], $source['name'], $source['user_name'], $source['user_email'], $source['user_password']);
			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, ?string $welcome_text = null): void
	{
		if (file_exists(DB_FILE)) {
			throw new UserException('La base de données existe déjà.');
		}

		self::checkAndCreateDirectories();
		Files::disableQuota();
		$db = DB::getInstance();



		// 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
		// c'est dans Config::set que sont vérifiées les données utilisateur (renvoie UserException)
		$config = Config::getInstance();
		$config->set('nom_asso', $name);
		$config->set('email_asso', $user_email);


		$config->set('monnaie', $currency);
		$config->set('pays', $country_code);
		$config->set('site_disabled', true);

		$config->set('analytical_set_all', true);


		$champs = Membres\Champs::importInstall();
		$champs->create(); // Pas de copie car pas de table membres existante
		$config->set('champs_membres', $champs);

		$config->set('champ_identifiant', 'email');
		$config->set('champ_identite', 'nom');

		// 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('categorie_membres', $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
		$membres = new Membres;
		$id_membre = $membres->add([
			'id_category' => $cat->id(),


			'nom'         => $user_name,
			'email'       => $user_email,
			'passe'       => $user_password,
			'pays'        => 'FR',
		]);








		$config->set('files', array_map(fn () => null, $config::FILES));

		$welcome_text = $welcome_text ?? sprintf("Bienvenue dans l'administration de %s !\n\nUtilisez le menu à gauche pour accéder aux différentes sections.\n\nCe message peut être modifié dans la 'Configuration'.", $name);

		$config->setFile('admin_homepage', $welcome_text);

        // Import accounting chart
        $chart = Charts::installCountryDefault($country_code);

		// Create an example saved search (users)
		$query = (object) [
			'query' => [[
				'operator' => 'AND',
				'conditions' => [
					[
						'column'   => 'lettre_infos',
						'operator' => '= 1',
						'values'   => [],
					],
				],
			]],
			'order' => 'numero',
			'desc' => true,
			'limit' => '10000',
		];

		$recherche = new Recherche;

		$recherche->add('Inscrits à la lettre d\'information', null, $recherche::TYPE_JSON, 'membres', $query);







		// Create an example saved search (accounting)
		$query = (object) [
			'query' => [[
				'operator' => 'AND',
				'conditions' => [
					[
						'column'   => 'p.code',
						'operator' => 'IS NULL',
						'values'   => [],
					],
				],
			]],
			'order' => 't.id',
			'desc' => false,
			'limit' => '100',
		];

		$recherche = new Recherche;


		$recherche->add('Écritures sans projet', null, $recherche::TYPE_JSON, 'compta', $query);









		// Install welcome plugin if available
		$has_welcome_plugin = Plugin::getPath('welcome');

		if ($has_welcome_plugin) {
			Plugin::install('welcome', true);
		}










		$config->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)
		{

			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.');
			}




			// Some basic safety against misconfigured hosts
			file_put_contents($path . '/index.html', '<!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.local.php';
		$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);








|



|
|

>
>
>

|








|








>
>













<

|
|
>
>
|
|
|
>
|
>

|
<
<
|
<
<










|



















|
<
|
>
>


<


>
>
>
>
>
>
>



|








|














|
>
|
>
>
>
>
>
>



|














|
>
>
|
>
>
>
>
>
>

>
>

|


|


>
>
>
>
>
>
>
>
>
|
>
>



















>












>
>
|
>

|







|







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
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

		$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 {
			margin: 0;
			margin-bottom: 1rem;
		}
		div {
			position: relative;
			border: 1px solid #999;
			max-width: 500px;
			padding: 1em;
			border-radius: .5em;

		}
		.spinner h2::after {
			display: block;
			content: " ";
			margin: 1rem auto;
			width: 50px;
			height: 50px;
			border: 5px solid #000;
			border-radius: 50%%;
			border-top-color: #999;
			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, htmlspecialchars($message));
	}

}








>
>
>
>


>
>
>
>
>


<




<



>







|

|










|
|
>
|
>
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].

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
<?php

namespace Garradin;

use KD2\Security;
use KD2\SMTP;
use Garradin\Membres\Session;

use Garradin\Files\Files;
use Garradin\Entities\Files\File;
use Garradin\Entities\Users\Email;

use Garradin\Users\Emails;
use Garradin\UserTemplate\UserTemplate;

class Membres
{
    const ITEMS_PER_PAGE = 50;

    // Gestion des données ///////////////////////////////////////////////////////

    public function _checkFields(&$data, $check_editable = true, $check_password = true)
    {
        $champs = Config::getInstance()->get('champs_membres');

        foreach ($champs->getAll() as $key=>$config)
        {
            if (!$check_editable && (!empty($config->private) || empty($config->editabl