Repository navigation
Expand file tree
/
Copy pathfunctions.php
More file actions
1020 lines (945 loc) · 32.4 KB
/
Copy pathfunctions.php
File metadata and controls
1020 lines (945 loc) · 32.4 KB
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
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
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
<?php if (!defined('W2APP')){ die('No direct access.'); }
/*
* SPDX-License-Identifier: MIT
*
* W2
*
* Helper functions without side effects (nothing happens when this file is
* loaded; the functions use the constants defined in config.php when called).
*/
use W2\MarkdownParser;
// Libraries installed via Composer (see INSTALL.md): without Composer the wiki runs with the copy of the Markdown
// library in the folder Michelf (loaded by the autoloader below), and without SVG uploads. The Composer version of
// the Markdown library is used if it is installed; tests/Unit/MarkdownLibraryTest.php checks that the copy is the same.
if ( is_file(__DIR__ . '/vendor/autoload.php') )
{
require_once __DIR__ . '/vendor/autoload.php';
}
// Install PSR-4-compatible class autoloader
spl_autoload_register(function($class){
// the classes of W2 (namespace W2) are in the folder src; others (like Michelf\Markdown) in the folder of their namespace
$class = ltrim($class, '\\');
$path = str_starts_with($class, 'W2\\') ? 'src' . DIRECTORY_SEPARATOR . substr($class, 3) : $class;
$file = __DIR__ . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $path) . '.php';
// don't fail fatally for unknown classes, so class_exists() can be used to probe for optional ones
if ( is_file($file) )
{
require $file;
}
});
const MAX_SVG_UPLOAD_SIZE = 1048576;
/**
* Escape a string for safe output in HTML text and attribute values
*/
function h($str)
{
return htmlspecialchars($str ?? '', ENT_QUOTES | ENT_SUBSTITUTE, W2_CHARSET);
}
/**
* Get translated word, as plain text (not escaped: for text which is escaped later, like the parts of a
* W2\Note; use __() for text which is put in HTML as it is)
*
* String $label Key for locale word
* String $alt_word Alternative word
* return String
*/
function translate( $label, $alt_word = null )
{
global $w2_word_set;
if( empty($w2_word_set[$label]) )
{
return is_null($alt_word) ? $label : $alt_word;
}
return $w2_word_set[$label];
}
/**
* Get translated word, escaped for use in HTML
*
* String $label Key for locale word
* String $alt_word Alternative word
* return String
*/
function __( $label, $alt_word = null )
{
return h(translate($label, $alt_word));
}
function descLengthSort($val_1, $val_2)
{
$firstVal = strlen($val_1);
$secondVal = strlen($val_2);
return ( $firstVal > $secondVal ) ?
-1 : ( ( $firstVal < $secondVal ) ? 1 : 0);
}
/**
* The pages of the wiki (see W2\PageStore, which does the work)
*/
function pageStore()
{
static $store = null;
return $store ??= new W2\PageStore(PAGES_PATH, PAGES_EXT, uploadFolderInPages());
}
function getAllPageNames()
{
return pageStore()->names();
}
/**
* The templates for the HTML of the pages (see W2\Template)
*/
function templates()
{
static $templates = null;
return $templates ??= new W2\Template(__DIR__ . '/src/templates');
}
/**
* The references to uploaded files in the text of pages (see W2\UploadReferences)
*/
function uploadReferences()
{
static $references = null;
return $references ??= new W2\UploadReferences(BASE_URI, UPLOAD_URL);
}
/**
* The text for a log entry without control characters (like line breaks, which could be used to forge
* entries of the log) and limited in length; control characters are shown as \xNN
*/
function logText($text)
{
$text = preg_replace_callback('/[\x00-\x1f\x7f]/', fn($m) => sprintf('\\x%02x', ord($m[0])), (string)$text);
return strlen($text) > 2000 ? substr($text, 0, 2000) . '...' : $text;
}
/**
* Write to the error log of the web server, with the prefix "W2: " (values from requests, file names and
* output of programs must be part of the message only via this function, so that they cannot add entries
* of their own)
*/
function w2Log($message)
{
error_log('W2: ' . logText($message));
}
/**
* Whether the request contains data the wiki cannot handle: the parameters it reads are single text values
* (not arrays like "page[]=x") without null bytes (which file functions refuse with an error), the same
* holds for the page name in the URL, and an uploaded file is a single file.
*
* @param array $get $_GET
* @param array $post $_POST
* @param array $server $_SERVER
* @param array $files $_FILES
*/
function hasMalformedRequestData(array $get, array $post, array $server, array $files = array())
{
$textParameters = array('action', 'page', 'q', 'sortBy', 'task', 'filename', 'newname', 'newName', 'oldPageName',
'imgName', 'prevpage', 'csrf_token', 'newText', 'isNew', 'gitmsg', 'overwrite', 'resize', 'maxsize', 'linkshere', 'p', 'draftKey', 'baseHash', 'json',
'name', 'v');
foreach ( array($get, $post) as $values )
{
foreach ( $textParameters as $name )
{
if ( isset($values[$name]) && (!is_string($values[$name]) || str_contains($values[$name], "\0")) )
{
return true;
}
}
}
if ( str_contains((string)($server['PATH_INFO'] ?? ''), "\0") )
{
return true;
}
return isset($files['userfile']) && !is_string($files['userfile']['tmp_name'] ?? null);
}
function fileNameForPage($page)
{
return pageStore()->path($page);
}
/**
* Replace the content of a file (a page) in one step: the text is written to a temporary file in the same folder,
* which then takes the place of the file. Readers (and a git command running at the same time) see either the old
* or the new text, never half of a write, and a write which fails (e.g. a full disk) keeps the old text.
* The permissions of an existing file are kept; if the file is a symbolic link, the file it points to is replaced.
*
* return bool whether the file was written
*/
function writeFileAtomically($filename, $text)
{
if ( is_link($filename) )
{
$target = realpath($filename);
if ( $target === false )
{
return false;
}
$filename = $target;
}
// (a hidden name: never a page, and not listed or found while it exists)
$temp = @tempnam(dirname($filename), '.w2-write-');
if ( $temp === false )
{
return false;
}
$mode = is_file($filename) ? (fileperms($filename) & 0777) : (0666 & ~umask());
if ( file_put_contents($temp, $text) !== strlen($text) || !chmod($temp, $mode) || !rename($temp, $filename) )
{
@unlink($temp);
return false;
}
return true;
}
/**
* Save the text of a page (see W2\PageStore::write())
*/
function writePage($page, $text)
{
return pageStore()->write($page, $text);
}
/**
* Whether the page of this action contains the editor (text area, save button
* and formatting help), and needs its script wiki.js
*/
function isEditorAction($action)
{
return $action === 'edit' || $action === 'new';
}
/**
* Whether the sidebar is shown on the page of this action: only where the
* wiki is read or browsed, not where a task is done in a form (editing,
* creating, uploading, renaming or deleting), which needs the whole width
*/
function showsSidebar($action)
{
return $action === 'view' || $action === 'all' || $action === 'search';
}
/**
* The file used to serialize git commands in the pages folder, which also
* holds the time of the last pull attempt (in the system's temporary folder)
*/
function gitSyncFile()
{
return rtrim(sys_get_temp_dir(), '/\\') . '/w2-git-' . substr(hash('sha256', PAGES_PATH), 0, 16);
}
/**
* Seconds which must pass after a pull attempt until the action may pull again:
* GIT_PULL_VIEW_INTERVAL for viewing, GIT_PULL_EDIT_INTERVAL (at most five
* minutes) for the editor
*/
function gitPullInterval($action)
{
if ( isEditorAction($action) )
{
return max(0, min(5 * 60, (int)GIT_PULL_EDIT_INTERVAL));
}
return max(0, (int)GIT_PULL_VIEW_INTERVAL);
}
/**
* The lock held by this request and how often it was taken (see gitLock())
*
* @return array{0: resource|null, 1: int}
*/
function &gitLockState()
{
static $state = array(null, 0);
return $state;
}
/**
* Wait for the lock for git commands in the pages folder (if $wait), or
* only take it if nobody else holds it. The lock is also held while pages
* and uploads are changed (see gitLockForChange()), so that a pull can't
* overwrite or conflict with a change which is not yet committed.
*
* A request can take the lock again while it holds it (e.g. to commit the
* changes it has made); it is released when gitUnlock() has been called for
* each time it was taken, or when the request ends.
*
* @return resource|null the lock to give to gitUnlock(), or null if it
* can't be taken (the caller then goes on without)
*/
function gitLock($wait)
{
$state = &gitLockState();
if ( $state[0] !== null )
{
$state[1]++;
return $state[0];
}
$file = @fopen(gitSyncFile(), 'c+');
if ( $file === false )
{
w2Log("cannot use " . gitSyncFile() . " to serialize git commands");
return null;
}
if ( !flock($file, $wait ? LOCK_EX : LOCK_EX | LOCK_NB) )
{
fclose($file);
return null;
}
$state = array($file, 1);
return $file;
}
/**
* Release a lock from gitLock() (it is held until this has been called as often as the lock was taken)
*/
function gitUnlock($lock)
{
$state = &gitLockState();
if ( $lock === null || $lock !== $state[0] )
{
return;
}
if ( --$state[1] > 0 )
{
return;
}
flock($lock, LOCK_UN);
fclose($lock);
$state = array(null, 0);
}
/**
* Take the lock before pages or uploads are changed, if changes are committed to git: a pull
* running at the same time could otherwise overwrite the change, or fail because of it. The lock
* is held until the request ends (the changes are committed before that, see gitChangeHandler()).
*
* @return resource|null
*/
function gitLockForChange()
{
return GIT_COMMIT_ENABLED ? gitLock(true) : null;
}
/**
* Whether $action should pull now: takes the lock (see gitLock()) and returns
* it if the last attempt, remembered in the lock file, is old enough, and no
* other request is using git. The attempt is recorded right away, so a remote
* which does not answer costs one timeout per interval, not one per request.
*
* @return resource|null the lock to give to gitUnlock(), or null: don't pull
*/
function gitPullStart($action, $now)
{
$lock = gitLock(false);
if ( $lock === null )
{
return null;
}
$last = (int)stream_get_contents($lock);
if ( $last > 0 && $last <= $now && $now - $last < gitPullInterval($action) )
{
gitUnlock($lock);
return null;
}
ftruncate($lock, 0);
rewind($lock);
fwrite($lock, (string)$now);
fflush($lock);
return $lock;
}
function isExistingPage($page)
{
return pageStore()->exists($page);
}
/**
* How links in pages refer to uploaded files, including the trailing slash: BASE_URI/UPLOAD_URL/ (followed by the
* name of the file). This address is only used in the text of pages: the rendered pages link to file.php instead,
* which serves the files (see uploadURL() and rewriteUploadLinks()).
*/
function uploadUrlPrefix()
{
return BASE_URI . "/" . UPLOAD_URL . "/";
}
/**
* The uploads folder (UPLOAD_PATH), or the path of an uploaded file in it (name without folder)
*/
function uploadPath($name = '')
{
return rtrim(UPLOAD_PATH, '/\\') . ($name === '' ? '' : "/" . $name);
}
/**
* The path of the uploads folder relative to the pages folder ("images" by default), if it is inside it (the folder
* then holds no pages); null if it is somewhere else
*/
function uploadFolderInPages()
{
// (real paths if the folders exist, so that links and ".." don't matter)
$normalized = function($path) {
$path = rtrim($path, '/\\');
$real = file_exists($path) ? realpath($path) : false;
return str_replace('\\', '/', $real === false ? $path : $real);
};
$pages = $normalized(PAGES_PATH);
$uploads = $normalized(UPLOAD_PATH);
if ( !str_starts_with($uploads, $pages . '/') )
{
return null;
}
$relative = trim(substr($uploads, strlen($pages) + 1), '/');
return $relative === '' ? null : $relative;
}
/**
* Whether the page name is within the uploads folder (which is not part of the wiki)
*/
function isInUploadFolder($page)
{
$folder = uploadFolderInPages();
return $folder !== null && ($page === $folder || str_starts_with($page, $folder . '/'));
}
/**
* Problems with the setup of the uploads which the administrator should know about (text for the log): a link or
* folder of the web server which still serves the uploads without the checks of the wiki, and an uploads folder
* outside the git repository of the pages (its files would not be committed)
*
* @return string[]
*/
function uploadSetupProblems()
{
$problems = array();
$static = __DIR__ . "/" . UPLOAD_URL;
if ( UPLOAD_URL !== '' && (is_link($static) || file_exists($static)) )
{
$problems[] = $static . " may let the web server serve the uploads without the password and IP checks of the wiki;"
. " the wiki serves them itself now (file.php): remove it, see \"Uploaded files\" in INSTALL.md";
}
if ( GIT_COMMIT_ENABLED && is_dir(PAGES_PATH) )
{
$top = (new W2\Git(PAGES_PATH))->run(array('rev-parse', '--show-toplevel'));
$repository = rtrim(str_replace('\\', '/', trim($top->output)), '/');
$uploads = is_dir(uploadPath()) ? realpath(uploadPath()) : false;
if ( $top->succeeded() && $repository !== '' && $uploads !== false && !str_starts_with(str_replace('\\', '/', $uploads) . '/', $repository . '/') )
{
$problems[] = "the uploads folder " . uploadPath() . " (UPLOAD_PATH) is not in the git repository of the pages (" . $repository
. "): uploads are not committed";
}
}
return $problems;
}
/**
* How to refer to an uploaded file in a page (plain text, not HTML: escape it where it is written)
*/
function imageLinkText($imgName)
{
return "."$imgName)";
}
function sanitizeFilename($inFileName)
{
return str_replace(array('~', '..', '\\', ':', '|', '&'), '-', $inFileName);
}
// escape a string for use as literal text in a preg_replace replacement
function pregReplacementQuote($str)
{
return str_replace(array('\\', '$'), array('\\\\', '\\$'), $str);
}
/**
* Whether a page with the given name may be created: page names may contain
* subfolders, but no hidden or empty path segments, and pages must not be
* stored within the uploads folder
*/
function isValidPageName($page)
{
return pageStore()->isValidName($page);
}
function getFileExt($fileName)
{
return preg_match('/\.([^.\/]+)$/', $fileName, $matches) ? strtolower($matches[1]) : null;
}
/**
* The part of an uploaded image to keep, from the "crop" field of the upload: "x,y,w,h", the position and size as
* fractions (0 to 1) of the width and height of the image (as it is shown, i.e. after rotating it by its EXIF orientation).
*
* @return float[]|null x, y, width, height (inside the image, at least 0.001 wide and high); null for no crop: nothing given,
* something else than four numbers, or the whole image
*/
function parseCropRect($text)
{
if (!is_string($text) || !preg_match('/^\s*(-?[0-9.]+(?:e-?[0-9]+)?(?:\s*,\s*-?[0-9.]+(?:e-?[0-9]+)?){3})\s*$/i', $text, $match))
{
return null;
}
$values = array_map('floatval', preg_split('/\s*,\s*/', $match[1]));
list($x, $y, $w, $h) = $values;
$x = max(0.0, min(1.0, $x));
$y = max(0.0, min(1.0, $y));
$w = min($w, 1.0 - $x);
$h = min($h, 1.0 - $y);
if (!is_finite($x + $y + $w + $h) || $w < 0.001 || $h < 0.001)
{
return null;
}
// (a rectangle which covers everything but a pixel or two of a large image is a click on the whole image)
if ($x < 0.0005 && $y < 0.0005 && $w > 0.9995 && $h > 0.9995)
{
return null;
}
return array($x, $y, $w, $h);
}
/**
* The name under which an uploaded file gets stored: the name of the uploaded file,
* or the (optional) name the user chose instead. The chosen name keeps the extension
* of the uploaded file, as the content must match it (so "cat" becomes "cat.jpg").
*/
function uploadTargetName($uploadedName, $chosenName = '')
{
$clean = function($name) {
// spaces are replaced as image display currently doesn't like them
return basename(str_replace(" ", "_", sanitizeFilename(trim((string)$name))));
};
$dstName = $clean($uploadedName);
$chosen = $clean($chosenName);
if ( $chosen === '' )
{
return $dstName;
}
$ext = getFileExt($dstName);
if ( $ext !== null && getFileExt($chosen) !== $ext )
{
$chosen .= "." . $ext;
}
return $chosen;
}
function isHiddenFile($fileName)
{
return str_starts_with(basename($fileName), '.');
}
/**
* Whether SVG uploads are enabled in the configuration, and the library
* needed to sanitize them (enshrined/svg-sanitize) is installed
*/
function svgUploadsAvailable()
{
return SVG_UPLOADS_ENABLED && class_exists('enshrined\\svgSanitize\\Sanitizer');
}
function validUploadTypes()
{
return explode(',', VALID_UPLOAD_TYPES);
}
function validUploadExts()
{
$exts = explode(',', VALID_UPLOAD_EXTS);
if ( svgUploadsAvailable() )
{
$exts[] = 'svg';
}
return $exts;
}
/**
* Remove everything from an uploaded SVG file that could be used to run
* scripts or load remote content (scripts, event handlers, javascript: URLs,
* external references, ...) with the enshrined/svg-sanitize library.
*
* return String|false cleaned up SVG, or false if the file isn't a valid SVG
*/
function sanitizeUploadedSvg($tmpName)
{
if ( !svgUploadsAvailable() || !is_uploaded_file($tmpName) || filesize($tmpName) > MAX_SVG_UPLOAD_SIZE )
{
return false;
}
$sanitizer = new enshrined\svgSanitize\Sanitizer();
$sanitizer->removeRemoteReferences(true);
$sanitizer->minify(true);
return sanitizeSvgText((string)file_get_contents($tmpName), $sanitizer);
}
/**
* Run the SVG sanitizer on the text of an SVG file. Whatever goes wrong inside the library (it doesn't
* only return false for broken files: version 1.1.0 throws a ValueError for some of them), the file is
* refused, never stored, and the request goes on.
*
* @param object $sanitizer an enshrined\svgSanitize\Sanitizer, set up for the wiki
* return String|false cleaned up SVG, or false if the text isn't a valid SVG file
*/
function sanitizeSvgText($svg, $sanitizer)
{
$previousLibxmlSetting = libxml_use_internal_errors(true);
try
{
$clean = $sanitizer->sanitize($svg);
}
catch ( \Throwable $e )
{
w2Log("SVG upload rejected: the SVG sanitizer failed: " . get_class($e) . ": " . $e->getMessage());
return false;
}
finally
{
libxml_clear_errors();
libxml_use_internal_errors($previousLibxmlSetting);
}
if ( !is_string($clean) || trim($clean) === '' )
{
w2Log("SVG upload rejected: not a valid SVG file");
return false;
}
return $clean;
}
/**
* Content types (as detected from the file content) which a file with the given extension may have.
* Without this, the content of any accepted type could be stored (and processed by ImageMagick) under
* any accepted extension, e.g. a PDF as "x.png". Extensions without an entry are not restricted further.
*/
function uploadTypesForExt($ext)
{
$types = array(
'bmp' => array('image/bmp', 'image/x-ms-bmp'),
'gif' => array('image/gif'),
'heic' => array('image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence'),
'heif' => array('image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence'),
'jpg' => array('image/jpeg', 'image/pjpeg'),
'jpeg' => array('image/jpeg', 'image/pjpeg'),
'pdf' => array('application/pdf'),
'png' => array('image/png'),
'webp' => array('image/webp'),
);
// (no extension: null, which can't be an array key)
return $ext === null ? null : ($types[$ext] ?? null);
}
function uploadTypeMatchesExt($type, $ext)
{
$allowed = uploadTypesForExt($ext);
return $allowed === null || in_array($type, $allowed, true);
}
/**
* Whether uploaded images can be shrunk, rotated and converted: needs the PHP extension imagick
* (which not every installation has), and IMAGE_PROCESSING_ENABLED. Otherwise, such uploads are
* stored as they are.
*/
function imageProcessingAvailable()
{
return IMAGE_PROCESSING_ENABLED && extension_loaded('imagick') && class_exists('Imagick');
}
/**
* Prefix for the file name given to ImageMagick, which forces the format instead of guessing it from
* the content of the file ("png:/path/file.png")
*/
function imageMagickFormatPrefix($ext)
{
return ($ext === 'jpg' || $ext === 'jpeg') ? 'jpeg:' : $ext . ':';
}
/**
* Whether renaming an uploaded file keeps its type, i.e. the new extension is the same as the old one (or
* one that stands for the same content, like jpg and jpeg). The content was checked against the old
* extension when it was uploaded (an SVG was sanitized), so it must not get another one by renaming.
*/
function renameKeepsFileType($oldName, $newName)
{
$oldExt = getFileExt($oldName);
$newExt = getFileExt($newName);
if ( $oldExt === $newExt )
{
return true;
}
$oldTypes = uploadTypesForExt($oldExt);
return $oldTypes !== null && $oldTypes === uploadTypesForExt($newExt);
}
function hasValidUploadExt($fileName)
{
return !isHiddenFile($fileName) && in_array(getFileExt($fileName), validUploadExts(), true);
}
function pageURL($page)
{
$encoded = str_replace("%2F", "/", str_replace("%23", "#", rawurlencode(sanitizeFilename($page))));
// with VIEW (e.g. '?action=view&page=') the page name is a query value, otherwise it is the PATH_INFO
return SELF . (VIEW !== '' ? VIEW : "/") . $encoded;
}
function pageLink($page, $title, $attributes="")
{
return "<a href=\"" . htmlspecialchars(pageURL($page), ENT_QUOTES) ."\"$attributes>$title</a>";
}
/**
* Replace the tags (with their attributes) and the code blocks and inline code in HTML from the Markdown
* parser by placeholders, so that the text between them can be searched and changed without damaging them.
* The placeholders contain a random value, so that text of a page cannot imitate one.
*
* return array 'html': the HTML with placeholders; 'restore': function which puts the original back
*/
function maskTagsAndCode($html)
{
$nonce = bin2hex(random_bytes(8));
$originals = array();
$mask = function($m) use (&$originals, $nonce)
{
$originals[] = $m[0];
return "@@W2" . $nonce . (count($originals) - 1) . "@@";
};
// (all other "<" in the text are escaped by the parser; quotes of attribute values may contain ">")
$html = preg_replace_callback('/<(pre|code)\b.*?<\/\1>/s', $mask, $html);
$html = preg_replace_callback('/<(?:[^>"\']|"[^"]*"|\'[^\']*\')*>/', $mask, $html);
return array(
'html' => $html,
'restore' => function($text) use (&$originals, $nonce)
{
return preg_replace_callback('/@@W2' . preg_quote($nonce, '/') . '(\d+)@@/', fn($m) => $originals[(int)$m[1]], $text);
}
);
}
function toHTMLID($noid)
{ // in HTML5, only spaces aren't allowed
return h(str_replace(" ", "-", html_entity_decode(strip_tags($noid), ENT_QUOTES | ENT_HTML5, W2_CHARSET)));
}
/**
* Neutralize URLs with potentially dangerous schemes (like javascript: or
* data:) in Markdown links and images; relative URLs are left untouched.
*/
function filterURL($url)
{
// browsers ignore whitespace and control characters within the scheme
$normalized = preg_replace('/[\x00-\x20\x7F]+/', '', html_entity_decode($url, ENT_QUOTES | ENT_HTML5, W2_CHARSET));
if (preg_match('/^([a-z][a-z0-9+.\-]*):/i', $normalized, $matches) &&
!in_array(strtolower($matches[1]), array('http', 'https', 'mailto', 'ftp', 'ftps', 'tel'), true))
{
return '#';
}
return $url;
}
/**
* Replace lines consisting only of an embed ("![[Page name]]") with the Markdown source of that page,
* recursively. Embeds inside fenced code blocks are left alone. Anything which can't be embedded (an
* invalid or missing page, a page already being embedded, or exceeding the limits INCLUDE_MAX_DEPTH
* and INCLUDE_MAX_SIZE) is replaced by a visible marker instead.
*
* $stack names of the pages currently being embedded (to detect cycles)
* $budget bytes still available for embedded text; shared by all embeds of a rendering
*/
function expandIncludes($text, array $stack = array(), &$budget = null)
{
$budget ??= INCLUDE_MAX_SIZE;
$maxDepth = INCLUDE_MAX_DEPTH;
$fence = null;
$lines = preg_split('/(\r\n|\n|\r)/', $text);
foreach ($lines as $i => $line)
{
if ( preg_match('/^ {0,3}(`{3,}|~{3,})/', $line, $m) )
{
if ( $fence === null )
{
$fence = $m[1];
}
else if ( $m[1][0] === $fence[0] && strlen($m[1]) >= strlen($fence) && trim($line) === $m[1] )
{
$fence = null;
}
continue;
}
if ( $fence !== null || !preg_match('/^!\[\[([^\[\]|#]+)\]\][ \t]*$/', $line, $m) )
{
continue;
}
$page = sanitizeFilename(trim($m[1]));
$failure = null;
if ( !isValidPageName($page) || !isExistingPage($page) )
{
$failure = "not found";
}
else if ( in_array($page, $stack, true) )
{
$failure = "cycle";
}
else if ( count($stack) >= $maxDepth )
{
$failure = "too deeply nested";
}
else
{
$included = (string)pageStore()->read($page);
$budget -= strlen($included);
if ( $budget < 0 )
{
$failure = "too large";
}
else
{
$lines[$i] = "\n" . expandIncludes($included, array_merge($stack, array($page)), $budget) . "\n";
}
}
if ( $failure !== null )
{
$lines[$i] = "*[include failed (" . $failure . "): `" . str_replace('`', "'", $page) . "`]*";
}
}
return implode("\n", $lines);
}
function toHTML($inText)
{
$inText = expandIncludes($inText);
$parser = new MarkdownParser;
$parser->no_markup = true;
$parser->url_filter_func = 'filterURL';
$outHTML = $parser->transform($inText);
// page links are looked for in the text only: not in tags (like the URL or title of a link) or in code
$masked = maskTagsAndCode($outHTML);
$outHTML = $masked['html'];
if ( AUTOLINK_PAGE_TITLES )
{
$pagenames = getAllPageNames();
uasort($pagenames, "descLengthSort");
foreach ( $pagenames as $pageName )
{
// match pageName, but only if it isn't inside another word or inside braces (as in "[$pageName]").
$outHTML = preg_replace("/(?<![\[a-zA-Z])".preg_quote($pageName, '/')."(?![\]a-zA-Z])/i", "[[".pregReplacementQuote($pageName)."]]", $outHTML);
}
}
preg_match_all(
"/\[\[(.*?)\]\]/",
$outHTML,
$matches,
PREG_PATTERN_ORDER
);
for ($i = 0; $i < count($matches[0]); $i++)
{
$fullLinkText = $matches[1][$i];
$linkTitleSplit = explode('|', $fullLinkText);
$linkedPage = $linkTitleSplit[0]; // split away potential link text
$linkText = (count($linkTitleSplit) > 1) ? $linkTitleSplit[1] : $linkedPage;
$pagePart = explode('#', $linkedPage)[0]; // split away a potential anchor part
$exists = isExistingPage(sanitizeFilename($pagePart));
$outHTML = str_replace("[[$fullLinkText]]",
pageLink($linkedPage, $linkText, ($exists? "" : " class=\"noexist\"")), $outHTML);
}
$outHTML = $masked['restore']($outHTML);
// add an anchor in all title tags (h1/2/3/4):
preg_match_all(
"/<h([1-4])>(.*?)<\/h\\1>/",
$outHTML,
$matches,
PREG_PATTERN_ORDER
);
for ($i = 0; $i < count($matches[0]); $i++)
{
$prefix = "<h".$matches[1][$i].">";
$caption = $matches[2][$i];
$suffix = substr_replace($prefix, "/", 1, 0);
$outHTML = str_replace("$prefix$caption$suffix",
"$prefix<a id=\"".toHTMLID($caption)."\">$caption</a>$suffix", $outHTML);
}
return rewriteUploadLinks($outHTML);
}
/**
* URL of a static file of the wiki (path relative to the W2 folder, e.g. "wiki.js" or "w2-icons/home.svg"),
* with a version parameter taken from the modification time of the file. A changed file therefore gets
* a new URL, which allows serving the files with long cache lifetimes. Files that don't exist get no parameter.
*/
function assetURL($path)
{
return versionedURL(BASE_URI . "/" . $path, __DIR__ . "/" . $path);
}
/**
* URL of an uploaded file (name without folder): file.php, which serves it. Versioned like assetURL(), as uploads
* can be overwritten.
*/
function uploadURL($name)
{
return versionedURL(BASE_URI . "/file.php?name=" . rawurlencode($name), uploadPath($name));
}
function versionedURL($url, $file)
{
$mtime = is_file($file) ? filemtime($file) : false;
return $mtime === false ? $url : $url . (str_contains($url, '?') ? '&' : '?') . "v=" . $mtime;
}
/**
* Point the links and images in rendered Markdown which refer to uploaded files (BASE_URI/UPLOAD_URL/name, see
* uploadUrlPrefix()) to file.php, which serves them (see uploadURL()). A query is dropped, a fragment (like the page of
* a PDF file, "#page=2") is kept.
*/
function rewriteUploadLinks($html)
{
return preg_replace_callback('/\b(src|href)="' . preg_quote(h(uploadUrlPrefix()), '/') . '([^"?#\/]+)(?:\?[^"#]*)?(#[^"]*)?"/',
function($m)
{
$name = rawurldecode(html_entity_decode($m[2], ENT_QUOTES | ENT_HTML5, W2_CHARSET));
if ( $name === '' || $name[0] === '.' || str_contains($name, "\0") )
{
return $m[0];
}
return $m[1] . '="' . h(uploadURL($name)) . ($m[3] ?? '') . '"';
}, $html);
}
function humanFilesize($bytes, $decimals = 2) {
$sz = 'BKMGTP';
$factor = floor((strlen($bytes) - 1) / 3);
return sprintf("%.".($factor==0?0:$decimals)."f", $bytes / pow(1024, $factor)) . @$sz[$factor];
}
/**
* The headings of a table which can be sorted by some of its columns, for the template table-head.
*
* @param string $action the action which shows the table (the headings are links to it)
* @param string $sortBy the column which the table is sorted by
* @param array<int, array{0: string, 1: string|null}> $columns the label of each column, and what to sort by to
* sort by it (or null for a column which can't be sorted by)
* return W2\Html
*/
function sortableHeadings($action, $sortBy, $columns)
{
$headings = array();
foreach ($columns as list($label, $key))
{
$headings[] = array('label' => $label, 'current' => ($key !== null && $key === $sortBy),
'href' => ($key === null || $key === $sortBy) ? null : SELF . "?action=" . $action . "&sortBy=" . $key);
}
return templates()->render('table-head', array('columns' => $headings));
}
/**
* An icon of the folder w2-icons, as an SVG in the page which shows the shape of the file (`<use>`), so that it is
* painted by the colors of the page: the files draw with currentColor, and index.css sets the color of the icons,
* in the light and the dark color scheme (a few have colors of their own, like the red cross of the close icon).
*
* @param string $name name of the file without ".svg", e.g. "edit"
* @param string $label what the icon means, as plain text (it is the tooltip, and what screen readers say)
* @param string $class more class names, e.g. "rightaligned"
* return W2\Html
*/
function iconSvg($name, $label, $class = '')
{
$text = \W2\Html::escape($label);
return \W2\Html::raw('<svg class="icon icon-' . $name . ($class !== '' ? ' ' . $class : '') . '" viewBox="0 0 16 16" role="img" aria-label="'
. $text . '"><title>' . $text . '</title><use href="' . \W2\Html::escape(assetURL("w2-icons/" . $name . ".svg")) . '#i"/></svg>');
}
/** Name of the cookie with the color scheme which the visitor chose (not set for "auto") */
const THEME_COOKIE = 'w2_theme';
/** The color schemes to choose from: "auto" follows the setting of the system, the others force one */
const THEME_MODES = array('auto', 'light', 'dark');
/** Is this one of THEME_MODES? */
function isThemeMode($mode)
{
return is_string($mode) && in_array($mode, THEME_MODES, true);
}
/** The color scheme after this one when the button in the toolbar is pressed: the system's, light, dark, and again */
function nextThemeMode($mode)
{
// (a value which is none of them counts as the system's)
$index = array_search($mode, THEME_MODES, true);
return THEME_MODES[((int)$index + 1) % count(THEME_MODES)];
}
/** The color scheme for the value of the cookie: "auto" for a missing or invalid value */
function themeModeFrom($cookieValue)
{
return isThemeMode($cookieValue) ? $cookieValue : 'auto';
}
/**
* The address to go back to after choosing a color scheme: the address given if it is in the wiki (the script
* $self, followed by a path or a query), else the script itself. Nothing which leads to another site is accepted.
*/
function themeReturnUrl($url, $self)
{
if ( !is_string($url) || !str_starts_with($url, $self) || preg_match('/[\x00-\x20\x7f\\\\]/', $url) )
{
return $self;
}
$next = substr($url, strlen($self), 1);
return ($next === '' || $next === '?' || $next === '/' || $next === '#') ? $url : $self;
}
/**
* The icons to do something with a page (edit, delete, rename, show the pages which link to it), except for