Repository navigation
Expand file tree
/
Copy pathdashboard_convert.php
More file actions
1789 lines (1597 loc) · 63.5 KB
/
Copy pathdashboard_convert.php
File metadata and controls
1789 lines (1597 loc) · 63.5 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
/*
All Emoncms code is released under the GNU Affero General Public License.
See COPYRIGHT.txt and LICENSE.txt.
---------------------------------------------------------------------
Emoncms - open source energy visualisation
Part of the OpenEnergyMonitor project:
http://openenergymonitor.org
*/
/*
Converts stored dashboard HTML into the JSON document described in
notes/SCHEMA.md. Reading only, it returns a document and does not write
anything.
require_once "Modules/dashboard/dashboard_convert.php";
$result = dashboard_convert($html);
$result['document'] the JSON document, or null if nothing could be read
$result['warnings'] what was dropped or changed, also inside the document
The content is parsed once and everything kept is taken from the parsed tree.
Nothing is repaired with a regular expression, and no string that has not been
walked is carried through to the output.
Stored content produces a lot of libxml complaints and parses correctly all the
same, see the parse error section of SCHEMA.md, so the error count is read but
never used to reject a dashboard.
*/
defined('EMONCMS_EXEC') or die('Restricted access');
require_once dirname(__FILE__) . "/widget_registry.php";
// Converters for the old text and container widgets, see
// notes/TEXT-IMAGE-PANEL.md. Not called by the converter or the renderer.
require_once dirname(__FILE__) . "/dashboard_convert_text.php";
require_once dirname(__FILE__) . "/dashboard_convert_panel.php";
// Replaces the old widgets of a document with the new ones. Called on save
// and by tools/migrate.php.
require_once dirname(__FILE__) . "/dashboard_migrate.php";
define('DASHBOARD_CONVERTER_VERSION', 1);
// Version of the document the converter writes and the renderer draws. A
// version 1 document has no widget ids and is upgraded on load, see
// dashboard_upgrade_document in dashboard_migrate.php.
define('DASHBOARD_DOCUMENT_VERSION', 2);
/*
The allowlists.
These are functions rather than variables because this file is required from
inside a class method in dashboard_model.php. A variable assigned at the top
level of an included file takes the scope of whatever included it, so as
globals they were silently empty when the converter ran from the model.
*/
// Elements allowed in the html of a text or container widget.
function dashboard_convert_allowed_elements()
{
return [
'a', 'b', 'strong', 'i', 'em', 'u', 'sub', 'sup', 'br', 'p', 'div', 'span',
'center', 'font', 'small', 'h1', 'h2', 'h3', 'h4', 'h5',
'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'img'
];
}
// Elements allowed in a text widget. Styling is set with options, so only
// emphasis, line breaks, links and subscript are kept. sub is included
// because units are often written with it.
function dashboard_convert_inline_elements()
{
// The editor prints them in this order, see content_problem in designer.js.
return ['b', 'i', 'u', 'sub', 'a', 'br'];
}
// Elements removed with everything inside them. Anything else that is not
// allowed is unwrapped instead, so the text inside it survives.
function dashboard_convert_stripped_elements()
{
return [
'script', 'style', 'meta', 'title', 'link', 'object', 'embed', 'iframe',
'svg', 'form', 'input', 'button', 'select', 'textarea', 'canvas', 'applet',
'base', 'frame', 'frameset', 'noscript', 'template',
'xmp', 'noembed', 'noframes', 'plaintext'
];
}
// Attributes allowed per element, on top of style which any of them may carry.
function dashboard_convert_allowed_attributes()
{
return [
'a' => ['href', 'target', 'title', 'rel'],
'img' => ['src', 'alt', 'width', 'height', 'referrerpolicy'],
'font' => ['color', 'face', 'size'],
'table' => ['border', 'cellpadding', 'cellspacing'],
'td' => ['colspan', 'rowspan', 'align'],
'th' => ['colspan', 'rowspan', 'align']
];
}
// Style properties allowed, both on a widget box and inside its html. Taken
// from what stored dashboards use, see the style property counts in the census.
// None of them can fetch or run anything: a value carrying url(), expression()
// or a position declaration is dropped whatever the property is, see
// dashboard_convert_style_value_allowed.
function dashboard_convert_allowed_styles()
{
return [
// Text
'color', 'font', 'font-size', 'font-family', 'font-weight', 'font-style',
'letter-spacing', 'line-height', 'text-align', 'text-decoration',
'text-transform', 'vertical-align', 'white-space',
// Background, border and the rest of the paint
'background', 'background-color', 'border', 'border-top', 'border-right',
'border-bottom', 'border-left', 'border-color', 'border-style',
'border-width', 'border-radius', 'border-collapse', 'border-spacing',
'box-shadow', 'opacity',
// Box
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'width', 'height', 'max-width', 'min-width', 'max-height', 'min-height',
'display', 'visibility', 'overflow', 'float', 'table-layout',
'align-items', 'justify-content', 'flex-wrap',
// Rotation only, see dashboard_convert_style_rotate_only
'transform'
];
}
// Style properties of a widget box that the designer writes and the renderer
// puts back, so they are dropped without a warning. The margin longhands are
// here with the shorthand: the renderer writes margin: 0 on every box, and a
// margin-top written after it would win and move the box off the geometry the
// document gives it. Inside the html of a widget they are kept.
function dashboard_convert_box_styles()
{
return ['position', 'top', 'left', 'width', 'height',
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left'
];
}
// Attributes added by browser extensions to the page the editor saved.
function dashboard_convert_extension_attributes()
{
return [
'bis_skin_checked', '_msttexthash', '_msthash', 'wfd-id',
'data-darkreader-inline-color', 'data-dashlane-frameid',
'data-ruffle-polyfilled', 'data-ol-has-click-handler',
'__gchrome_childframeremotetoken'
];
}
/**
* Converts page html to a document.
*
* Widget ids are numbered from the array index. Stored html carries the
* designer's counter, which collides within a dashboard. The editor no longer
* posts html, see notes/EDITOR.md, so this is the migration only.
*
* @param string $html
* @return array document and warnings
*/
function dashboard_convert($html)
{
$warnings = [];
if (trim($html) === '') {
return dashboard_convert_result([], $warnings);
}
$root = dashboard_convert_parse($html);
if ($root === null) {
dashboard_convert_warn($warnings, null, 'unparsable', '');
return ['document' => null, 'warnings' => $warnings];
}
$registry = widget_registry();
$widgets = [];
dashboard_convert_boxes($root, $widgets, $registry, $warnings, true);
return dashboard_convert_result($widgets, $warnings);
}
// Reads the widget boxes of a page. $page is true for the page itself, false
// for an element being looked through, see dashboard_convert_holds_box.
function dashboard_convert_boxes($parent, &$widgets, $registry, &$warnings, $page)
{
foreach ($parent->childNodes as $node) {
if ($node->nodeType === XML_TEXT_NODE) {
// Only reported for the page. Text beside a box inside a wrapper is
// the wrapper's own, and is not a stray line someone left behind.
if ($page && trim($node->textContent) !== '') {
dashboard_convert_warn(
$warnings,
null,
'text_outside_widget',
dashboard_convert_snippet($node->textContent)
);
}
continue;
}
if ($node->nodeType !== XML_ELEMENT_NODE) {
continue;
}
$widget = dashboard_convert_widget($node, count($widgets), $registry, $warnings);
if ($widget !== null) {
$widgets[] = $widget;
continue;
}
// Not a box. A stored page is sometimes wrapped in a tag that was
// opened and never closed, a stray <b> in the corpus, and everything
// after it parses as its content. The boxes are ordinary ones with
// their geometry and options intact, so they are read rather than lost
// with the wrapper. A box is never looked for inside a widget, which is
// where a nested widget lives and is dropped on purpose.
if (dashboard_convert_holds_box($node, $registry)) {
dashboard_convert_boxes($node, $widgets, $registry, $warnings, false);
}
}
}
// True when a box sits somewhere inside this element, so there is a reason to
// look through it. Stripped elements are not looked into: what is inside a
// textarea is text on the page today, not a widget, and the migration does not
// draw what a browser does not.
function dashboard_convert_holds_box($node, $registry)
{
if (in_array(strtolower($node->nodeName), dashboard_convert_stripped_elements())) {
return false;
}
foreach ($node->childNodes as $child) {
if ($child->nodeType !== XML_ELEMENT_NODE) {
continue;
}
$class = $child->hasAttribute('class') ? trim($child->getAttribute('class')) : '';
if ($class !== '' && isset($registry[$class])) {
return true;
}
if (dashboard_convert_holds_box($child, $registry)) {
return true;
}
}
return false;
}
function dashboard_convert_result($widgets, $warnings)
{
$next_id = dashboard_convert_ids($widgets);
$document = [
'version' => DASHBOARD_DOCUMENT_VERSION,
'next_id' => $next_id,
'widgets' => $widgets,
'meta' => [
'converted_at' => gmdate('Y-m-d\TH:i:s\Z'),
'converter' => DASHBOARD_CONVERTER_VERSION,
'warnings' => $warnings
]
];
return ['document' => $document, 'warnings' => $warnings];
}
// Numbers every widget from its index, which is what the renderer drew a
// document with no ids as, see the widget ids section of SCHEMA.md. Returns
// the counter to store.
function dashboard_convert_ids(&$widgets)
{
foreach ($widgets as $index => $widget) {
$widgets[$index] = ['id' => $index + 1] + $widget;
}
return count($widgets) + 1;
}
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
// Null bytes are removed before parsing, as the HTML5 parser in libxml 2.14
// and later ignores them. Earlier libxml (2.9 on Ubuntu 24.04) drops the rest
// of the document after a null byte in an attribute value, including any
// widgets that follow.
function dashboard_convert_parse($html)
{
$doc = new DOMDocument();
libxml_use_internal_errors(true);
libxml_clear_errors();
$ok = $doc->loadHTML(
'<div>' . dashboard_convert_to_entities(str_replace("\0", "", $html)) . '</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors(false);
if (!$ok || !$doc->documentElement) {
return null;
}
return $doc->documentElement;
}
// libxml reads bytes as latin1 unless it is told otherwise, so non ASCII text
// is turned into numeric entities before parsing and back again after.
function dashboard_convert_to_entities($html)
{
return mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8');
}
function dashboard_convert_from_entities($html)
{
return mb_decode_numericentity($html, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8');
}
// ---------------------------------------------------------------------------
// Widgets
// ---------------------------------------------------------------------------
function dashboard_convert_widget($node, $index, $registry, &$warnings)
{
$class = $node->hasAttribute('class') ? trim($node->getAttribute('class')) : '';
if ($class === '') {
dashboard_convert_warn(
$warnings,
$index,
'widget_without_type',
strtolower($node->nodeName)
);
return null;
}
// A widget type is one token because it is the class attribute of the box.
// More than one is hand written markup rather than a widget the designer
// wrote, and the renderer will not draw a type carrying a space, so it is
// dropped here rather than written into a document that cannot be drawn.
if (preg_match('/\s/', $class)) {
dashboard_convert_warn(
$warnings,
$index,
'widget_type_not_one_token',
preg_replace('/\s+/', ' ', $class)
);
return null;
}
$type = $class;
$known = isset($registry[$type]);
// Read as written and settled by dashboard_convert_ids once every box on
// the page has been read.
$widget = [
'id' => $node->hasAttribute('id') ? $node->getAttribute('id') : '',
'type' => $type
];
$style = $node->hasAttribute('style') ? $node->getAttribute('style') : '';
$declarations = dashboard_convert_parse_style($style);
$widget += dashboard_convert_geometry($declarations, $index, $warnings);
if (
isset($declarations['position'])
&& strtolower(trim($declarations['position'])) === 'fixed'
) {
dashboard_convert_warn($warnings, $index, 'position_fixed_dropped', $type);
}
$widget['options'] = dashboard_convert_options($node, $type, $known, $index, $warnings);
if ($known) {
$config = dashboard_convert_config($node, $type, $index, $warnings);
if ($config !== null) {
$widget['config'] = $config;
}
}
if (!$known) {
// Kept as a placeholder, with its geometry and its box styling but
// without its attributes, see the unknown widget types section of
// SCHEMA.md.
$widget['unknown'] = true;
dashboard_convert_warn($warnings, $index, 'widget_type_unknown', $type);
}
$holds_html = dashboard_convert_holds_html($type, $known, $registry);
$holds_text = dashboard_convert_holds_text($type, $known, $registry);
if ($holds_html) {
$html = dashboard_convert_html($node, $index, $registry, $warnings);
if ($html !== '') {
$widget['html'] = $html;
}
} elseif ($holds_text) {
$text = dashboard_convert_text($node, $index, $registry, $warnings);
if ($text !== '') {
$widget['text'] = $text;
}
} else {
dashboard_convert_check_discarded($node, $type, $index, $registry, $warnings);
}
// Author written box styling. A deployed data widget has its box style
// written by the render script at draw time, so what is stored is
// generated and is dropped without a warning. Text boxes, containers and
// placeholders keep theirs.
if ($holds_html || !$known) {
$box = dashboard_convert_styles($declarations, $index, $warnings, true);
if (count($box)) {
$widget['style'] = $box;
}
}
return $widget;
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
// A widget whose settings do not fit in attributes holds them in one nested
// config, which travels on the page as a json attribute and is stored as its
// own field in the document. The chart of a graph widget is the first of
// these, see the inline config section of SCHEMA.md.
//
// Returns the config to store, or null when there is none to keep.
function dashboard_convert_config($node, $type, $index, &$warnings)
{
$contract = widget_registry_config($type);
if ($contract === false) {
return null;
}
if (!$node->hasAttribute('config')) {
return null;
}
$raw = trim($node->getAttribute('config'));
if ($raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
dashboard_convert_warn(
$warnings,
$index,
'config_unreadable',
dashboard_convert_snippet($raw)
);
return null;
}
return dashboard_convert_config_valid($decoded, $type, $index, $warnings);
}
// Checks a decoded config against what the widget declares. Run on the way in
// and again on the way out, the same as options, so a document that reached
// the column some other way cannot put anything on the page that the
// declaration would have rejected.
//
// @return array|null
function dashboard_convert_config_valid($decoded, $type, $index, &$warnings)
{
$contract = widget_registry_config($type);
if ($contract === false || !is_array($decoded)) {
return null;
}
$config = [];
foreach ($decoded as $block => $value) {
if (!is_string($block) || !isset($contract[$block])) {
dashboard_convert_warn(
$warnings,
$index,
'config_block_unknown',
dashboard_convert_snippet((string) $block)
);
continue;
}
if (!is_array($value)) {
dashboard_convert_warn($warnings, $index, 'config_block_unreadable', $block);
continue;
}
// A block is either one set of entries, such as the state of a graph,
// or a list of them, such as its feeds.
if (dashboard_convert_is_list($value)) {
$list = [];
foreach ($value as $entry) {
if (!is_array($entry) || dashboard_convert_is_list($entry)) {
dashboard_convert_warn($warnings, $index, 'config_entry_unreadable', $block);
continue;
}
$kept = dashboard_convert_config_entries($entry, $contract[$block], $block, $index, $warnings);
if (count($kept)) {
$list[] = $kept;
}
}
if (count($list)) {
$config[$block] = $list;
}
} else {
$kept = dashboard_convert_config_entries($value, $contract[$block], $block, $index, $warnings);
if (count($kept)) {
$config[$block] = $kept;
}
}
}
return count($config) ? $config : null;
}
// One set of config entries. Values are stored as strings, the same as option
// values and for the same reason: the render scripts compare them as strings.
// A json true or false is stored as 1 or 0, which is what a boolean option
// holds and what the widget lists coerce anyway.
function dashboard_convert_config_entries($entries, $declared, $block, $index, &$warnings)
{
$kept = [];
foreach ($entries as $name => $value) {
if (!is_string($name) || !isset($declared[$name])) {
dashboard_convert_warn(
$warnings,
$index,
'config_entry_unknown',
$block . '.' . dashboard_convert_snippet((string) $name)
);
continue;
}
if (is_array($value) || $value === null) {
dashboard_convert_warn(
$warnings,
$index,
'config_value_invalid',
$block . '.' . $name
);
continue;
}
if (is_bool($value)) {
$value = $value ? '1' : '0';
}
$value = (string) $value;
// An empty entry is kept unchecked, the same as an empty option.
if ($value !== '' && !dashboard_convert_option_valid($declared[$name], $value)) {
dashboard_convert_warn(
$warnings,
$index,
'config_value_dropped',
$block . '.' . $name . '=' . dashboard_convert_snippet($value)
);
continue;
}
$kept[$name] = $value;
}
return $kept;
}
// True for a json array rather than a json object. json_decode gives both as
// php arrays, and the keys are what tells them apart.
function dashboard_convert_is_list($value)
{
if (!is_array($value)) {
return false;
}
if (count($value) === 0) {
return true;
}
return array_keys($value) === range(0, count($value) - 1);
}
// A widget may hold html when the registry says it has an html option, which
// covers paragraph, heading and heading-center. The containers declare no
// options at all and hold hand built tables, so they are included by name.
function dashboard_convert_holds_html($type, $known, $registry)
{
if (substr($type, 0, 10) === 'Container-') {
return true;
}
if (!$known) {
return false;
}
foreach ($registry[$type]['options'] as $option) {
if ($option['type'] === 'html') {
return true;
}
}
return false;
}
// A widget holds text when the registry gives it an option of type text.
// Only the text widget does. Text is checked against
// dashboard_convert_inline_elements and stored in its own field.
function dashboard_convert_holds_text($type, $known, $registry)
{
if (!$known) {
return false;
}
foreach ($registry[$type]['options'] as $option) {
if ($option['type'] === 'text') {
return true;
}
}
return false;
}
// The element holding the widget content. The text widget render script
// draws a wrapper inside the box so a rotation turns the text and not the box.
// The wrapper is generated and not stored. See text_wrapper in
// widget/text/text_render.js.
function dashboard_convert_text_body($node)
{
foreach ($node->childNodes as $child) {
if ($child->nodeType !== XML_ELEMENT_NODE) {
continue;
}
if (strtolower($child->nodeName) !== 'div') {
continue;
}
$class = $child->hasAttribute('class') ? trim($child->getAttribute('class')) : '';
if ($class === 'text-content') {
return $child;
}
}
return $node;
}
// The children of a data widget are all generated: the canvas the render
// script draws on, the iframe a chart widget built, the tooltip divs that dial,
// bar and thermometer inject, and the last reading left in the box. None of it
// is worth a warning. A widget nested inside one is worth a warning, because
// it is something the author put there and it is being dropped.
function dashboard_convert_check_discarded($node, $type, $index, $registry, &$warnings)
{
$stack = [$node];
while (count($stack)) {
$current = array_pop($stack);
foreach ($current->childNodes as $child) {
if ($child->nodeType !== XML_ELEMENT_NODE) {
continue;
}
$class = $child->hasAttribute('class') ? trim($child->getAttribute('class')) : '';
if ($class !== '' && isset($registry[$class])) {
dashboard_convert_warn(
$warnings,
$index,
'nested_widget_dropped',
"$class inside $type"
);
continue;
}
if (strtolower($child->nodeName) === 'iframe' && !dashboard_convert_draws_iframe($type, $registry)) {
dashboard_convert_warn(
$warnings,
$index,
'iframe_dropped',
dashboard_convert_snippet($child->getAttribute('src'))
);
}
$stack[] = $child;
}
}
}
// The graph widget drew in an iframe before it drew in the page, and the four
// retired vis types are declared as iframe widgets, see widget_registry.php.
function dashboard_convert_draws_iframe($type, $registry)
{
if (!isset($registry[$type])) {
return false;
}
return $registry[$type]['module'] === 'graph' || !empty($registry[$type]['iframe']);
}
// ---------------------------------------------------------------------------
// Geometry
// ---------------------------------------------------------------------------
// The designer writes top, left, width and height onto the box, and records
// whether width and height were set in per cent, see scan and draw in
// Views/js/designer.js.
function dashboard_convert_geometry($declarations, $index, &$warnings)
{
$geometry = [
'x' => dashboard_convert_length($declarations, 'left'),
'y' => dashboard_convert_length($declarations, 'top'),
'w' => dashboard_convert_length($declarations, 'width'),
'h' => dashboard_convert_length($declarations, 'height'),
'wunit' => dashboard_convert_unit($declarations, 'width'),
'hunit' => dashboard_convert_unit($declarations, 'height')
];
foreach (['left', 'top', 'width', 'height'] as $property) {
if (!isset($declarations[$property])) {
dashboard_convert_warn($warnings, $index, 'geometry_missing', $property);
}
}
if ($geometry['w'] < 0) {
$geometry['w'] = 0;
}
if ($geometry['h'] < 0) {
$geometry['h'] = 0;
}
return $geometry;
}
function dashboard_convert_length($declarations, $property)
{
if (!isset($declarations[$property])) {
return 0;
}
if (!preg_match('/-?\d+(\.\d+)?/', $declarations[$property], $match)) {
return 0;
}
return (int) round((float) $match[0]);
}
function dashboard_convert_unit($declarations, $property)
{
if (!isset($declarations[$property])) {
return 'px';
}
return strpos($declarations[$property], '%') === false ? 'px' : 'pc';
}
// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------
function dashboard_convert_options($node, $type, $known, $index, &$warnings)
{
$options = [];
$broken = dashboard_convert_style_broke_out($node);
$takes_config = $known && widget_registry_config($type) !== false;
foreach ($node->attributes as $attribute) {
$name = $attribute->nodeName;
$value = $attribute->nodeValue;
if ($name === 'id' || $name === 'class' || $name === 'style') {
continue;
}
// Not an option. It carries the nested config of a widget that
// declares one, read by dashboard_convert_config.
if ($name === 'config' && $takes_config) {
continue;
}
$artefact = dashboard_convert_artefact($name, $type, $known, $broken, $value);
if ($artefact !== false) {
// Never authored, so the warning says what it was rather than
// reading as lost data.
dashboard_convert_warn($warnings, $index, $artefact, $name);
continue;
}
// Nothing declares this widget, so there is nothing to say which of its
// attributes are options and which would act on the page. The name on
// its own does not settle it: onmouseover is shaped like an option
// name. They are dropped rather than guessed at. The content column
// still holds the html they came from.
if (!$known) {
dashboard_convert_warn(
$warnings,
$index,
'unknown_widget_option_dropped',
$name . '=' . dashboard_convert_snippet($value)
);
continue;
}
$option = widget_registry_option($type, $name);
if ($option === false) {
dashboard_convert_warn(
$warnings,
$index,
'option_unknown_dropped',
$name . '=' . dashboard_convert_snippet($value)
);
continue;
}
// An empty option is kept. Absent and empty are not the same to the
// render scripts. feedvalue only falls back to its units when both
// prepend and append are absent, so an author who set one of them and
// left the other empty gets the word undefined printed beside the
// reading once the empty one stops being written.
if ($value !== '' && !dashboard_convert_option_valid($option, $value)) {
$text = dashboard_convert_option_without_tags($option, $value);
if ($text === false) {
dashboard_convert_warn(
$warnings,
$index,
'option_value_dropped',
$name . '=' . dashboard_convert_snippet($value)
);
continue;
}
dashboard_convert_warn(
$warnings,
$index,
'option_value_tags_stripped',
$name . '=' . dashboard_convert_snippet($value)
);
$value = $text;
}
// Stored under the name the registry declares, so the renderer and the
// editor agree on one spelling.
$options[$option['name']] = (string) $value;
}
return $options;
}
// Free text that fails only because it holds a tag keeps its words. The tag
// cannot go back on the page, see the option values section of SCHEMA.md, but
// dropping the option takes the author's label with it, and feedvalue prints
// the word undefined in its place when append is set and prepend is not.
//
// Returns the text to store, or false when there is nothing worth keeping.
function dashboard_convert_option_without_tags($option, $value)
{
if ($option['type'] !== 'value' && $option['type'] !== 'dropbox_other') {
return false;
}
if (strpos($value, '<') === false && strpos($value, '>') === false) {
return false;
}
// A br is a line break the author wrote, so it leaves a space behind. The
// spacing either side of a label is kept, only runs of it are collapsed.
$text = preg_replace('/<br\s*\/?>/i', ' ', $value);
$text = strip_tags($text);
$text = preg_replace('/\s+/u', ' ', $text);
if ($text === null || trim($text) === '') {
return false;
}
return dashboard_convert_option_valid($option, $text) ? $text : false;
}
function dashboard_convert_option_valid($option, $value)
{
if (strlen($value) > 4096) {
return false;
}
if (preg_match('/[\x00-\x08\x0b\x0c\x0e-\x1f]/', $value)) {
return false;
}
switch ($option['type']) {
case 'feedid':
case 'feedid_realtime':
// Either a feed id or a tag:name association, see render_feeds
// in Views/js/render.js.
return preg_match('/^\d+$/', $value)
|| preg_match('/^[^\x00-\x1f<>"\']{1,128}$/', $value);
case 'colour_picker':
// none is no colour at all, which the designer writes for a
// background that shows the dashboard through.
return preg_match('/^#?[0-9a-fA-F]{3}$/', $value)
|| preg_match('/^#?[0-9a-fA-F]{6}$/', $value)
|| strtolower($value) === 'none';
case 'boolean':
return $value === '0' || $value === '1';
case 'dropbox':
// A dynamic list is filled from the database per user, so there is
// nothing here to check it against.
if ($option['dynamic']) {
return preg_match('/^[^\x00-\x1f<>"\']{1,128}$/', $value);
}
if ($option['values'] === null) {
return true;
}
return in_array((string) $value, $option['values'], true);
case 'dropbox_other':
case 'value':
// Free text an author types. A render script puts several of these
// into the page with .html(), so a value holding a tag would be
// parsed as one, see the option values section of SCHEMA.md.
// Angle brackets are the only way to open a tag: an entity in an
// attribute arrives at .html() already decoded and is written back
// as text. Quotes are kept because the renderer escapes them and
// the curl widget sends a json payload through one of these.
return preg_match('/^[^<>]{1,512}$/u', $value) === 1;
case 'number':
// An integer within the declared range.
if (!preg_match('/^-?\d+$/', $value)) {
return false;
}
$number = (int) $value;
if ($option['min'] !== null && $number < $option['min']) {
return false;
}
if ($option['max'] !== null && $number > $option['max']) {
return false;
}
return true;
case 'url':
// A link, checked with the same rules as an href in widget html.
return dashboard_convert_url_allowed($value, 'href');
case 'image_url':
// Fetched by the browser, so a url on this site must name a static
// image, see dashboard_convert_url_own_site.
return dashboard_convert_url_allowed($value, 'src');
case 'text':
// The text of a widget is the content of its box, not an
// attribute. It is checked against dashboard_convert_inline_elements
// and kept in its own field.
return false;
case 'html':
// The html of a widget is the content of its box, never an
// attribute. The designer writes it with .html(), the document
// keeps it in its own field, and no dashboard in the census
// carries an html attribute. One that turns up was not authored
// and is not put back on the page.
return false;
default:
return true;
}
}
// True when the style attribute of this element lost its quoting and spilled
// the rest of the style out as stray attributes. A property name keeps its
// colon, and the punctuation of the declaration arrives as its own attribute,
// so either is enough to recognise the element. See the decode note in
// SCHEMA.md.
function dashboard_convert_style_broke_out($node)
{
foreach ($node->attributes as $attribute) {
if (preg_match('/[:;()\/]|^[0-9]/', $attribute->nodeName)) {
return true;
}
}
return false;
}
// Attributes in stored content that were never options. Returns the warning
// code, or false if the attribute is a real option.
//
// $broken says the style of this element broke out, in which case its values
// are sitting alongside as valueless attributes. A word list would be needed
// to name them one by one, so they are recognised by where they are instead.
function dashboard_convert_artefact($name, $type, $known, $broken, $value)
{
if (in_array($name, dashboard_convert_extension_attributes())) {
return 'browser_extension_attribute';
}
if (preg_match('/[:;()\/]|^[0-9]/', $name)) {
return 'broken_style_attribute';
}
if ($broken && $value === '' && ($known ? !widget_registry_option($type, $name) : true)) {
return 'broken_style_attribute';
}
// The Other box of a dropbox_other option is a second select carrying the
// same class as the real inputs, so the designer saves its id as well.
if (
$known && substr($name, -9) === '_dropdown'
&& widget_registry_option($type, substr($name, 0, -9))
) {
return 'designer_artefact_attribute';
}
return false;
}
// ---------------------------------------------------------------------------
// Html of text and container widgets
// ---------------------------------------------------------------------------
function dashboard_convert_html(
$node,
$index,
$registry,
&$warnings,
$allowed = null,