Line data Source code
1 : /*
2 : This file is part of TALER
3 : Copyright (C) 2019, 2020, 2022, 2024 Taler Systems SA
4 :
5 : TALER is free software; you can redistribute it and/or modify it under the
6 : terms of the GNU Affero General Public License as published by the Free Software
7 : Foundation; either version 3, or (at your option) any later version.
8 :
9 : TALER is distributed in the hope that it will be useful, but WITHOUT ANY
10 : WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
11 : A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
12 :
13 : You should have received a copy of the GNU Affero General Public License along with
14 : TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
15 : */
16 : /**
17 : * @file mhd_legal.c
18 : * @brief API for returning legal documents based on client language
19 : * and content type preferences
20 : * @author Christian Grothoff
21 : */
22 : #include "platform.h"
23 : #include <gnunet/gnunet_util_lib.h>
24 : #include <gnunet/gnunet_json_lib.h>
25 : #include <jansson.h>
26 : #include <microhttpd.h>
27 : #include "taler/taler_util.h"
28 : #include "taler/taler_mhd_lib.h"
29 :
30 : /**
31 : * How long should browsers/proxies cache the "legal" replies?
32 : */
33 : #define MAX_TERMS_CACHING GNUNET_TIME_UNIT_DAYS
34 :
35 : /**
36 : * HTTP header with the version of the terms of service.
37 : */
38 : #define TALER_TERMS_VERSION "Taler-Terms-Version"
39 :
40 : /**
41 : * Entry in the terms-of-service array.
42 : */
43 : struct Terms
44 : {
45 : /**
46 : * Kept in a DLL.
47 : */
48 : struct Terms *prev;
49 :
50 : /**
51 : * Kept in a DLL.
52 : */
53 : struct Terms *next;
54 :
55 : /**
56 : * Mime type of the terms.
57 : */
58 : const char *mime_type;
59 :
60 : /**
61 : * The terms (NOT 0-terminated!), mmap()'ed. Do not free,
62 : * use munmap() instead.
63 : */
64 : void *terms;
65 :
66 : /**
67 : * The desired language.
68 : */
69 : char *language;
70 :
71 : /**
72 : * deflated @e terms, to return if client supports deflate compression.
73 : * malloc()'ed. NULL if @e terms does not compress.
74 : */
75 : void *compressed_terms;
76 :
77 : /**
78 : * Etag we use for this response.
79 : */
80 : char *terms_etag;
81 :
82 : /**
83 : * Number of bytes in @e terms.
84 : */
85 : size_t terms_size;
86 :
87 : /**
88 : * Number of bytes in @e compressed_terms.
89 : */
90 : size_t compressed_terms_size;
91 :
92 : /**
93 : * Sorting key by format preference in case
94 : * everything else is equal. Higher is preferred.
95 : */
96 : unsigned int priority;
97 :
98 : };
99 :
100 :
101 : /**
102 : * Prepared responses for legal documents
103 : * (terms of service, privacy policy).
104 : */
105 : struct TALER_MHD_Legal
106 : {
107 : /**
108 : * DLL of terms of service.
109 : */
110 : struct Terms *terms_head;
111 :
112 : /**
113 : * DLL of terms of service.
114 : */
115 : struct Terms *terms_tail;
116 :
117 : /**
118 : * Etag to use for the terms of service (= version).
119 : */
120 : char *terms_version;
121 : };
122 :
123 :
124 : /**
125 : * Check if the comma-separated list @a langs already contains
126 : * the entry @a lang.
127 : *
128 : * @param langs comma-separated list of languages
129 : * @param lang language to look for
130 : * @return true if @a lang is an entry of @a langs
131 : */
132 : static bool
133 17 : lang_in_list (const char *langs,
134 : const char *lang)
135 : {
136 17 : size_t len = strlen (lang);
137 17 : const char *pos = langs;
138 :
139 26 : while (NULL != pos)
140 : {
141 19 : const char *end = strchr (pos,
142 : ',');
143 19 : size_t elen = (NULL == end)
144 9 : ? strlen (pos)
145 19 : : (size_t) (end - pos);
146 :
147 19 : if ( (elen == len) &&
148 19 : (0 == strncasecmp (pos,
149 : lang,
150 : len)) )
151 10 : return true;
152 9 : pos = (NULL == end)
153 : ? NULL
154 9 : : end + 1;
155 : }
156 7 : return false;
157 : }
158 :
159 :
160 : enum MHD_Result
161 19 : TALER_MHD_reply_legal (struct MHD_Connection *conn,
162 : struct TALER_MHD_Legal *legal)
163 : {
164 : /* Default terms of service if none are configured */
165 : static struct Terms none = {
166 : .mime_type = "text/plain",
167 : .terms = (void *) "not configured",
168 : .language = (void *) "en",
169 : .terms_size = strlen ("not configured")
170 : };
171 : struct MHD_Response *resp;
172 : struct Terms *t;
173 : struct GNUNET_TIME_Absolute a;
174 : struct GNUNET_TIME_Timestamp m;
175 : char dat[128];
176 : char *langs;
177 :
178 19 : t = NULL;
179 19 : langs = NULL;
180 :
181 19 : a = GNUNET_TIME_relative_to_absolute (MAX_TERMS_CACHING);
182 : /* Round up to next full day to ensure the expiration
183 : time does not become a fingerprint! */
184 19 : a = GNUNET_TIME_absolute_round_down (a,
185 : MAX_TERMS_CACHING);
186 19 : a = GNUNET_TIME_absolute_add (a,
187 : MAX_TERMS_CACHING);
188 19 : m = GNUNET_TIME_absolute_to_timestamp (a);
189 19 : TALER_MHD_get_date_string (m.abs_time,
190 : dat);
191 19 : GNUNET_log (GNUNET_ERROR_TYPE_INFO,
192 : "Setting '%s' header to '%s'\n",
193 : MHD_HTTP_HEADER_EXPIRES,
194 : dat);
195 19 : if (NULL == legal)
196 : {
197 8 : t = &none;
198 8 : goto return_t;
199 : }
200 :
201 11 : if (NULL != legal)
202 : {
203 : const char *mime;
204 : const char *lang;
205 11 : double best_mime_q = 0.0;
206 11 : double best_lang_q = 0.0;
207 :
208 11 : mime = MHD_lookup_connection_value (conn,
209 : MHD_HEADER_KIND,
210 : MHD_HTTP_HEADER_ACCEPT);
211 11 : if (NULL == mime)
212 1 : mime = "text/plain";
213 11 : lang = MHD_lookup_connection_value (conn,
214 : MHD_HEADER_KIND,
215 : MHD_HTTP_HEADER_ACCEPT_LANGUAGE);
216 11 : if (NULL == lang)
217 9 : lang = "en";
218 : /* Find best match: must match mime type (if possible), and if
219 : mime type matches, ideally also language */
220 11 : for (struct Terms *p = legal->terms_head;
221 77 : NULL != p;
222 66 : p = p->next)
223 : {
224 : double q;
225 :
226 66 : q = TALER_pattern_matches (mime,
227 : p->mime_type);
228 66 : if (q > best_mime_q)
229 13 : best_mime_q = q;
230 : }
231 11 : for (struct Terms *p = legal->terms_head;
232 77 : NULL != p;
233 66 : p = p->next)
234 : {
235 : double q;
236 :
237 66 : q = TALER_pattern_matches (mime,
238 : p->mime_type);
239 66 : if (q < best_mime_q)
240 38 : continue;
241 28 : q = TALER_pattern_matches (lang,
242 28 : p->language);
243 : /* create 'available-languages' (for this mime-type) */
244 28 : if (NULL == langs)
245 : {
246 11 : langs = GNUNET_strdup (p->language);
247 : }
248 17 : else if (! lang_in_list (langs,
249 17 : p->language))
250 : {
251 7 : char *tmp = langs;
252 :
253 7 : GNUNET_asprintf (&langs,
254 : "%s,%s",
255 : tmp,
256 : p->language);
257 7 : GNUNET_free (tmp);
258 : }
259 28 : if (NULL != t)
260 : {
261 17 : if (q < best_lang_q)
262 9 : continue;
263 : /* Client expressed no preference between these two formats,
264 : so fall back to our own ranking instead of letting the
265 : last entry in the list win. */
266 8 : if ( (q <= best_lang_q) &&
267 6 : (p->priority <= t->priority) )
268 6 : continue;
269 : }
270 13 : best_lang_q = q;
271 13 : t = p;
272 : }
273 11 : GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
274 : "Best match for %s/%s: %s / %s\n",
275 : lang,
276 : mime,
277 : (NULL != t) ? t->mime_type : "<none>",
278 : (NULL != t) ? t->language : "<none>");
279 : }
280 :
281 11 : if (NULL != t)
282 : {
283 : const char *etag;
284 :
285 11 : etag = MHD_lookup_connection_value (conn,
286 : MHD_HEADER_KIND,
287 : MHD_HTTP_HEADER_IF_NONE_MATCH);
288 11 : if ( (NULL != etag) &&
289 0 : (NULL != t->terms_etag) &&
290 0 : (0 == strcasecmp (etag,
291 0 : t->terms_etag)) )
292 : {
293 : enum MHD_Result ret;
294 :
295 0 : resp = MHD_create_response_from_buffer (0,
296 : NULL,
297 : MHD_RESPMEM_PERSISTENT);
298 0 : TALER_MHD_add_global_headers (resp,
299 : true);
300 0 : GNUNET_break (MHD_YES ==
301 : MHD_add_response_header (resp,
302 : MHD_HTTP_HEADER_EXPIRES,
303 : dat));
304 0 : GNUNET_break (MHD_YES ==
305 : MHD_add_response_header (resp,
306 : MHD_HTTP_HEADER_ETAG,
307 : t->terms_etag));
308 0 : if (NULL != legal)
309 0 : GNUNET_break (MHD_YES ==
310 : MHD_add_response_header (resp,
311 : TALER_TERMS_VERSION,
312 : legal->terms_version));
313 0 : ret = MHD_queue_response (conn,
314 : MHD_HTTP_NOT_MODIFIED,
315 : resp);
316 0 : GNUNET_break (MHD_YES == ret);
317 0 : MHD_destroy_response (resp);
318 0 : GNUNET_free (langs);
319 0 : return ret;
320 : }
321 : }
322 :
323 11 : if (NULL == t)
324 0 : t = &none; /* 501 if not configured */
325 :
326 11 : return_t:
327 : /* try to compress the response */
328 19 : resp = NULL;
329 19 : if ( (TALER_MHD_CT_DEFLATE ==
330 19 : TALER_MHD_can_compress (conn,
331 0 : TALER_MHD_CT_DEFLATE)) &&
332 0 : (NULL != t->compressed_terms) )
333 : {
334 0 : resp = MHD_create_response_from_buffer (t->compressed_terms_size,
335 : t->compressed_terms,
336 : MHD_RESPMEM_PERSISTENT);
337 0 : if (MHD_NO ==
338 0 : MHD_add_response_header (resp,
339 : MHD_HTTP_HEADER_CONTENT_ENCODING,
340 : "deflate"))
341 : {
342 0 : GNUNET_break (0);
343 0 : MHD_destroy_response (resp);
344 0 : resp = NULL;
345 : }
346 : }
347 19 : if (NULL == resp)
348 : {
349 : /* could not generate compressed response, return uncompressed */
350 19 : resp = MHD_create_response_from_buffer (t->terms_size,
351 : (void *) t->terms,
352 : MHD_RESPMEM_PERSISTENT);
353 : }
354 19 : TALER_MHD_add_global_headers (resp,
355 : true);
356 19 : GNUNET_break (MHD_YES ==
357 : MHD_add_response_header (resp,
358 : MHD_HTTP_HEADER_EXPIRES,
359 : dat));
360 19 : if (NULL != langs)
361 : {
362 11 : GNUNET_break (MHD_YES ==
363 : MHD_add_response_header (resp,
364 : "Avail-Languages",
365 : langs));
366 11 : GNUNET_free (langs);
367 : }
368 : /* Set cache control headers: our response varies depending on these headers */
369 19 : GNUNET_break (MHD_YES ==
370 : MHD_add_response_header (resp,
371 : MHD_HTTP_HEADER_VARY,
372 : MHD_HTTP_HEADER_ACCEPT_LANGUAGE ","
373 : MHD_HTTP_HEADER_ACCEPT ","
374 : MHD_HTTP_HEADER_ACCEPT_ENCODING));
375 : /* Information is always public, revalidate after 10 days */
376 19 : GNUNET_break (MHD_YES ==
377 : MHD_add_response_header (resp,
378 : MHD_HTTP_HEADER_CACHE_CONTROL,
379 : "public,max-age=864000"));
380 19 : if (NULL != t->terms_etag)
381 11 : GNUNET_break (MHD_YES ==
382 : MHD_add_response_header (resp,
383 : MHD_HTTP_HEADER_ETAG,
384 : t->terms_etag));
385 19 : if (NULL != legal)
386 11 : GNUNET_break (MHD_YES ==
387 : MHD_add_response_header (resp,
388 : TALER_TERMS_VERSION,
389 : legal->terms_version));
390 19 : GNUNET_break (MHD_YES ==
391 : MHD_add_response_header (resp,
392 : MHD_HTTP_HEADER_CONTENT_TYPE,
393 : t->mime_type));
394 19 : GNUNET_break (MHD_YES ==
395 : MHD_add_response_header (resp,
396 : MHD_HTTP_HEADER_CONTENT_LANGUAGE,
397 : t->language));
398 : {
399 : enum MHD_Result ret;
400 :
401 19 : ret = MHD_queue_response (conn,
402 : t == &none
403 : ? MHD_HTTP_NOT_IMPLEMENTED
404 : : MHD_HTTP_OK,
405 : resp);
406 19 : MHD_destroy_response (resp);
407 19 : return ret;
408 : }
409 : }
410 :
411 :
412 : /**
413 : * Load all the terms of service from @a path under language @a lang
414 : * from file @a name
415 : *
416 : * @param[in,out] legal where to write the result
417 : * @param path where the terms are found
418 : * @param lang which language directory to crawl
419 : * @param name specific file to access
420 : */
421 : static void
422 6 : load_terms (struct TALER_MHD_Legal *legal,
423 : const char *path,
424 : const char *lang,
425 : const char *name)
426 : {
427 : static struct MimeMap
428 : {
429 : const char *ext;
430 : const char *mime;
431 : unsigned int priority;
432 : } mm[] = {
433 : { .ext = ".txt", .mime = "text/plain", .priority = 150 },
434 : { .ext = ".html", .mime = "text/html", .priority = 100 },
435 : { .ext = ".htm", .mime = "text/html", .priority = 99 },
436 : { .ext = ".md", .mime = "text/markdown", .priority = 50 },
437 : { .ext = ".pdf", .mime = "application/pdf", .priority = 25 },
438 : { .ext = ".jpg", .mime = "image/jpeg" },
439 : { .ext = ".jpeg", .mime = "image/jpeg" },
440 : { .ext = ".png", .mime = "image/png" },
441 : { .ext = ".gif", .mime = "image/gif" },
442 : { .ext = ".epub", .mime = "application/epub+zip", .priority = 10 },
443 : { .ext = ".xml", .mime = "text/xml", .priority = 10 },
444 : { .ext = NULL, .mime = NULL }
445 : };
446 6 : const char *ext = strrchr (name, '.');
447 : const char *mime;
448 : unsigned int priority;
449 :
450 6 : if (NULL == ext)
451 : {
452 0 : GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
453 : "Unsupported file `%s' in directory `%s/%s': lacks extension\n",
454 : name,
455 : path,
456 : lang);
457 0 : return;
458 : }
459 6 : if ( (NULL == legal->terms_version) ||
460 6 : (0 != strncmp (legal->terms_version,
461 : name,
462 6 : ext - name)) )
463 : {
464 0 : GNUNET_log (GNUNET_ERROR_TYPE_DEBUG,
465 : "Filename `%s' does not match Etag `%s' in directory `%s/%s'. Ignoring it.\n",
466 : name,
467 : legal->terms_version,
468 : path,
469 : lang);
470 0 : return;
471 : }
472 6 : mime = NULL;
473 18 : for (unsigned int i = 0; NULL != mm[i].ext; i++)
474 18 : if (0 == strcasecmp (mm[i].ext,
475 : ext))
476 : {
477 6 : mime = mm[i].mime;
478 6 : priority = mm[i].priority;
479 6 : break;
480 : }
481 6 : if (NULL == mime)
482 : {
483 0 : GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
484 : "Unsupported file extension `%s' of file `%s' in directory `%s/%s'\n",
485 : ext,
486 : name,
487 : path,
488 : lang);
489 0 : return;
490 : }
491 : /* try to read the file with the terms of service */
492 : {
493 : struct stat st;
494 : char *fn;
495 : int fd;
496 :
497 6 : GNUNET_asprintf (&fn,
498 : "%s/%s/%s",
499 : path,
500 : lang,
501 : name);
502 6 : fd = open (fn, O_RDONLY);
503 6 : if (-1 == fd)
504 : {
505 0 : GNUNET_log_strerror_file (GNUNET_ERROR_TYPE_WARNING,
506 : "open",
507 : fn);
508 0 : GNUNET_free (fn);
509 0 : return;
510 : }
511 6 : if (0 != fstat (fd, &st))
512 : {
513 0 : GNUNET_log_strerror_file (GNUNET_ERROR_TYPE_WARNING,
514 : "fstat",
515 : fn);
516 0 : GNUNET_break (0 == close (fd));
517 0 : GNUNET_free (fn);
518 0 : return;
519 : }
520 : if (SIZE_MAX < ((unsigned long long) st.st_size))
521 : {
522 : GNUNET_log_strerror_file (GNUNET_ERROR_TYPE_WARNING,
523 : "fstat-size",
524 : fn);
525 : GNUNET_break (0 == close (fd));
526 : GNUNET_free (fn);
527 : return;
528 : }
529 6 : GNUNET_log (GNUNET_ERROR_TYPE_INFO,
530 : "Loading legal information from file `%s'\n",
531 : fn);
532 : {
533 : void *buf;
534 : size_t bsize;
535 :
536 6 : bsize = (size_t) st.st_size;
537 6 : buf = mmap (NULL,
538 : bsize,
539 : PROT_READ,
540 : MAP_SHARED,
541 : fd,
542 : 0);
543 6 : if (MAP_FAILED == buf)
544 : {
545 0 : GNUNET_log_strerror_file (GNUNET_ERROR_TYPE_WARNING,
546 : "mmap",
547 : fn);
548 0 : GNUNET_break (0 == close (fd));
549 0 : GNUNET_free (fn);
550 0 : return;
551 : }
552 6 : GNUNET_break (0 == close (fd));
553 6 : GNUNET_free (fn);
554 :
555 : /* insert into global list of terms of service */
556 : {
557 : struct Terms *t;
558 : struct GNUNET_HashCode hc;
559 :
560 6 : GNUNET_CRYPTO_hash (buf,
561 : bsize,
562 : &hc);
563 6 : t = GNUNET_new (struct Terms);
564 6 : t->mime_type = mime;
565 6 : t->terms = buf;
566 6 : t->language = GNUNET_strdup (lang);
567 6 : t->terms_size = bsize;
568 6 : t->priority = priority;
569 : t->terms_etag
570 6 : = GNUNET_STRINGS_data_to_string_alloc (&hc,
571 : sizeof (hc) / 2);
572 6 : buf = GNUNET_memdup (t->terms,
573 : t->terms_size);
574 6 : if (TALER_MHD_body_compress (&buf,
575 : &bsize))
576 : {
577 0 : t->compressed_terms = buf;
578 0 : t->compressed_terms_size = bsize;
579 : }
580 : else
581 : {
582 6 : GNUNET_free (buf);
583 : }
584 : {
585 6 : struct Terms *prev = NULL;
586 :
587 6 : for (struct Terms *pos = legal->terms_head;
588 15 : NULL != pos;
589 9 : pos = pos->next)
590 : {
591 12 : if (pos->priority < priority)
592 3 : break;
593 9 : prev = pos;
594 : }
595 6 : GNUNET_CONTAINER_DLL_insert_after (legal->terms_head,
596 : legal->terms_tail,
597 : prev,
598 : t);
599 : }
600 : }
601 : }
602 : }
603 : }
604 :
605 :
606 : /**
607 : * Load all the terms of service from @a path under language @a lang.
608 : *
609 : * @param[in,out] legal where to write the result
610 : * @param path where the terms are found
611 : * @param lang which language directory to crawl
612 : */
613 : static void
614 2 : load_language (struct TALER_MHD_Legal *legal,
615 : const char *path,
616 : const char *lang)
617 : {
618 : char *dname;
619 : DIR *d;
620 :
621 2 : GNUNET_asprintf (&dname,
622 : "%s/%s",
623 : path,
624 : lang);
625 2 : d = opendir (dname);
626 2 : if (NULL == d)
627 : {
628 0 : GNUNET_free (dname);
629 0 : return;
630 : }
631 2 : for (struct dirent *de = readdir (d);
632 12 : NULL != de;
633 10 : de = readdir (d))
634 : {
635 10 : const char *fn = de->d_name;
636 :
637 10 : if (fn[0] == '.')
638 4 : continue;
639 6 : load_terms (legal,
640 : path,
641 : lang,
642 : fn);
643 : }
644 2 : GNUNET_break (0 == closedir (d));
645 2 : GNUNET_free (dname);
646 : }
647 :
648 :
649 : struct TALER_MHD_Legal *
650 41 : TALER_MHD_legal_load (const struct GNUNET_CONFIGURATION_Handle *cfg,
651 : const char *section,
652 : const char *diroption,
653 : const char *tagoption)
654 : {
655 : struct TALER_MHD_Legal *legal;
656 : char *path;
657 : DIR *d;
658 :
659 41 : legal = GNUNET_new (struct TALER_MHD_Legal);
660 41 : if (GNUNET_OK !=
661 41 : GNUNET_CONFIGURATION_get_value_string (cfg,
662 : section,
663 : tagoption,
664 : &legal->terms_version))
665 : {
666 0 : GNUNET_log_config_missing (GNUNET_ERROR_TYPE_WARNING,
667 : section,
668 : tagoption);
669 0 : GNUNET_free (legal);
670 0 : return NULL;
671 : }
672 41 : if (GNUNET_OK !=
673 41 : GNUNET_CONFIGURATION_get_value_filename (cfg,
674 : section,
675 : diroption,
676 : &path))
677 : {
678 0 : GNUNET_log_config_missing (GNUNET_ERROR_TYPE_WARNING,
679 : section,
680 : diroption);
681 0 : GNUNET_free (legal->terms_version);
682 0 : GNUNET_free (legal);
683 0 : return NULL;
684 : }
685 41 : d = opendir (path);
686 41 : if (NULL == d)
687 : {
688 40 : GNUNET_log_config_invalid (GNUNET_ERROR_TYPE_WARNING,
689 : section,
690 : diroption,
691 : "Could not open directory");
692 40 : GNUNET_free (legal->terms_version);
693 40 : GNUNET_free (legal);
694 40 : GNUNET_free (path);
695 40 : return NULL;
696 : }
697 1 : for (struct dirent *de = readdir (d);
698 5 : NULL != de;
699 4 : de = readdir (d))
700 : {
701 4 : const char *lang = de->d_name;
702 :
703 4 : if (lang[0] == '.')
704 2 : continue;
705 2 : if (0 == strcmp (lang,
706 : "locale"))
707 0 : continue;
708 2 : load_language (legal,
709 : path,
710 : lang);
711 : }
712 1 : GNUNET_break (0 == closedir (d));
713 1 : GNUNET_free (path);
714 1 : return legal;
715 : }
716 :
717 :
718 : void
719 1 : TALER_MHD_legal_free (struct TALER_MHD_Legal *legal)
720 : {
721 : struct Terms *t;
722 1 : if (NULL == legal)
723 0 : return;
724 7 : while (NULL != (t = legal->terms_head))
725 : {
726 6 : GNUNET_free (t->language);
727 6 : GNUNET_free (t->compressed_terms);
728 6 : if (0 != munmap (t->terms, t->terms_size))
729 0 : GNUNET_log_strerror (GNUNET_ERROR_TYPE_WARNING,
730 : "munmap");
731 6 : GNUNET_CONTAINER_DLL_remove (legal->terms_head,
732 : legal->terms_tail,
733 : t);
734 6 : GNUNET_free (t->terms_etag);
735 6 : GNUNET_free (t);
736 : }
737 1 : GNUNET_free (legal->terms_version);
738 1 : GNUNET_free (legal);
739 : }
|