1<?php
2// ht.php -- HotCRP HTML helper functions
3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE.
4
5class Ht {
6
7    public static $img_base = "";
8    public static $default_button_class = "";
9    private static $_script_open = "<script";
10    private static $_controlid = 0;
11    private static $_lastcontrolid = 0;
12    private static $_stash = "";
13    private static $_stash_inscript = false;
14    private static $_stash_map = [];
15    private static $_control_classes = [];
16    const ATTR_SKIP = 1;
17    const ATTR_BOOL = 2;
18    const ATTR_BOOLTEXT = 3;
19    const ATTR_NOEMPTY = 4;
20    private static $_attr_type = array("accept-charset" => self::ATTR_SKIP,
21                                       "action" => self::ATTR_SKIP,
22                                       "checked" => self::ATTR_BOOL,
23                                       "class" => self::ATTR_NOEMPTY,
24                                       "disabled" => self::ATTR_BOOL,
25                                       "enctype" => self::ATTR_SKIP,
26                                       "formnovalidate" => self::ATTR_BOOL,
27                                       "method" => self::ATTR_SKIP,
28                                       "multiple" => self::ATTR_BOOL,
29                                       "novalidate" => self::ATTR_BOOL,
30                                       "optionstyles" => self::ATTR_SKIP,
31                                       "spellcheck" => self::ATTR_BOOLTEXT,
32                                       "readonly" => self::ATTR_BOOL,
33                                       "type" => self::ATTR_SKIP);
34
35    static function extra($js) {
36        $x = "";
37        if ($js)
38            foreach ($js as $k => $v) {
39                $t = get(self::$_attr_type, $k);
40                if ($v === null
41                    || $t === self::ATTR_SKIP
42                    || ($v === false && $t !== self::ATTR_BOOLTEXT)
43                    || ($v === "" && $t === self::ATTR_NOEMPTY))
44                    /* nothing */;
45                else if ($t === self::ATTR_BOOL)
46                    $x .= ($v ? " $k" : "");
47                else if ($t === self::ATTR_BOOLTEXT && is_bool($v))
48                    $x .= " $k=\"" . ($v ? "true" : "false") . "\"";
49                else
50                    $x .= " $k=\"" . str_replace("\"", "&quot;", $v) . "\"";
51            }
52        return $x;
53    }
54
55    static function set_script_nonce($nonce) {
56        if ((string) $nonce === "")
57            self::$_script_open = '<script';
58        else
59            self::$_script_open = '<script nonce="' . htmlspecialchars($nonce) . '"';
60    }
61
62    static function script($script) {
63        return self::$_script_open . '>' . $script . '</script>';
64    }
65
66    static function script_file($src, $js = null) {
67        if ($js && get($js, "crossorigin") && !preg_match(',\A([a-z]+:)?//,', $src))
68            unset($js["crossorigin"]);
69        return self::$_script_open . ' src="' . htmlspecialchars($src) . '"' . self::extra($js) . '></script>';
70    }
71
72    static function stylesheet_file($src) {
73        return "<link rel=\"stylesheet\" type=\"text/css\" href=\""
74            . htmlspecialchars($src) . "\" />";
75    }
76
77    static function form($action, $extra = null) {
78        if (is_array($action)) {
79            $extra = $action;
80            $action = get($extra, "action", "");
81        }
82
83        // GET method requires special handling: extract params from URL
84        // and render as hidden inputs
85        $suffix = ">";
86        $method = get($extra, "method") ? : "post";
87        if ($method === "get"
88            && ($qpos = strpos($action, "?")) !== false) {
89            $pos = $qpos + 1;
90            while ($pos < strlen($action)
91                   && preg_match('{\G([^#=&;]*)=([^#&;]*)([#&;]|\z)}', $action, $m, 0, $pos)) {
92                $suffix .= self::hidden(urldecode($m[1]), urldecode($m[2]));
93                $pos += strlen($m[0]);
94                if ($m[3] === "#") {
95                    --$pos;
96                    break;
97                }
98            }
99            $action = substr($action, 0, $qpos) . (string) substr($action, $pos);
100        }
101
102        $x = '<form method="' . $method . '" action="' . $action . '"';
103        $enctype = get($extra, "enctype");
104        if (!$enctype && $method !== "get")
105            $enctype = "multipart/form-data";
106        if ($enctype)
107            $x .= ' enctype="' . $enctype . '"';
108        return $x . ' accept-charset="UTF-8"' . self::extra($extra) . $suffix;
109    }
110
111    static function form_div($action, $extra = null) {
112        $div = "<div";
113        if (($x = get($extra, "divclass"))) {
114            $div .= ' class="' . $x . '"';
115            unset($extra["divclass"]);
116        }
117        if (($x = get($extra, "divstyle"))) {
118            $div .= ' style="' . $x . '"';
119            unset($extra["divstyle"]);
120        }
121        $div .= '>';
122        if (strcasecmp(get_s($extra, "method"), "get") == 0
123            && ($qpos = strpos($action, "?")) !== false) {
124            if (($hpos = strpos($action, "#", $qpos + 1)) === false)
125                $hpos = strlen($action);
126            foreach (preg_split('/(?:&amp;|&)/', substr($action, $qpos + 1, $hpos - $qpos - 1)) as $m)
127                if (($eqpos = strpos($m, "=")) !== false)
128                    $div .= '<input type="hidden" name="' . substr($m, 0, $eqpos) . '" value="' . urldecode(substr($m, $eqpos + 1)) . '" />';
129            $action = substr($action, 0, $qpos) . substr($action, $hpos);
130        }
131        return self::form($action, $extra) . $div;
132    }
133
134    static function hidden($name, $value = "", $extra = null) {
135        return '<input type="hidden" name="' . htmlspecialchars($name)
136            . '" value="' . htmlspecialchars($value) . '"'
137            . self::extra($extra) . ' />';
138    }
139
140    static function select($name, $opt, $selected = null, $js = null) {
141        if (is_array($selected) && $js === null)
142            list($js, $selected) = array($selected, null);
143        $disabled = get($js, "disabled");
144        if (is_array($disabled))
145            unset($js["disabled"]);
146        if ($selected === null || !isset($opt[$selected]))
147            $selected = key($opt);
148        $x = '<select name="' . $name . '"' . self::extra($js);
149        if (!isset($js["data-default-value"]))
150            $x .= ' data-default-value="' . htmlspecialchars($selected) . '"';
151        $x .= '>';
152        $optionstyles = get($js, "optionstyles", null);
153        $optgroup = "";
154        foreach ($opt as $value => $info) {
155            if (is_array($info) && isset($info[0]) && $info[0] === "optgroup")
156                $info = (object) array("type" => "optgroup", "label" => get($info, 1));
157            else if (is_array($info))
158                $info = (object) $info;
159            else if (is_scalar($info)) {
160                $info = (object) array("label" => $info);
161                if (is_array($disabled) && isset($disabled[$value]))
162                    $info->disabled = $disabled[$value];
163                if ($optionstyles && isset($optionstyles[$value]))
164                    $info->style = $optionstyles[$value];
165            }
166            if (isset($info->value))
167                $value = $info->value;
168
169            if ($info === null)
170                $x .= '<option label=" " disabled="disabled"></option>';
171            else if (isset($info->type) && $info->type === "optgroup") {
172                $x .= $optgroup;
173                if ($info->label) {
174                    $x .= '<optgroup label="' . htmlspecialchars($info->label) . '">';
175                    $optgroup = "</optgroup>";
176                } else
177                    $optgroup = "";
178            } else {
179                $x .= '<option';
180                if (get($info, "id"))
181                    $x .= ' id="' . $info->id . '"';
182                $x .= ' value="' . htmlspecialchars($value) . '"';
183                if (strcmp($value, $selected) == 0)
184                    $x .= ' selected="selected"';
185                if (get($info, "disabled"))
186                    $x .= ' disabled="disabled"';
187                if (get($info, "class"))
188                    $x .= ' class="' . $info->class . '"';
189                if (get($info, "style"))
190                    $x .= ' style="' . htmlspecialchars($info->style) . '"';
191                $x .= '>' . $info->label . '</option>';
192            }
193        }
194        return $x . $optgroup . "</select>";
195    }
196
197    static function checkbox($name, $value = 1, $checked = false, $js = null) {
198        if (is_array($value)) {
199            $js = $value;
200            $value = 1;
201        } else if (is_array($checked)) {
202            $js = $checked;
203            $checked = false;
204        }
205        $js = $js ? : array();
206        if (!get($js, "id"))
207            $js["id"] = "htctl" . ++self::$_controlid;
208        self::$_lastcontrolid = $js["id"];
209        if (isset($js["data-default-checked"]) || isset($js["data-default-value"])) {
210            $dc = get($js, "data-default-checked");
211            if ($dc === null)
212                $dc = get($js, "data-default-value");
213            $dc = $dc ? "1" : "";
214            if (!!$checked === !!$dc)
215                $dc = null;
216            $js["data-default-checked"] = $dc;
217        }
218        $t = '<input type="checkbox"'; /* NB see Ht::radio */
219        if ($name)
220            $t .= " name=\"$name\" value=\"" . htmlspecialchars($value) . "\"";
221        if ($checked)
222            $t .= " checked=\"checked\"";
223        return $t . self::extra($js) . " />";
224    }
225
226    static function radio($name, $value = 1, $checked = false, $js = null) {
227        $t = self::checkbox($name, $value, $checked, $js);
228        return '<input type="radio"' . substr($t, 22);
229    }
230
231    static function label($html, $id = null, $js = null) {
232        if ($js && isset($js["for"])) {
233            $id = $js["for"];
234            unset($js["for"]);
235        } else if ($id === null || $id === true)
236            $id = self::$_lastcontrolid;
237        return '<label' . ($id ? ' for="' . $id . '"' : '')
238            . self::extra($js) . '>' . $html . "</label>";
239    }
240
241    static function button($html, $js = null) {
242        if ($js === null && is_array($html)) {
243            $js = $html;
244            $html = null;
245        } else if ($js === null)
246            $js = array();
247        if (!isset($js["class"]) && self::$default_button_class)
248            $js["class"] = self::$default_button_class;
249        $type = isset($js["type"]) ? $js["type"] : "button";
250        if ($type === "button" || preg_match("_[<>]_", $html) || isset($js["value"])) {
251            if (!isset($js["value"]))
252                $js["value"] = 1;
253            return "<button type=\"$type\"" . self::extra($js)
254                . ">" . $html . "</button>";
255        } else {
256            $js["value"] = $html;
257            return "<input type=\"$type\"" . self::extra($js) . " />";
258        }
259    }
260
261    static function submit($name, $html = null, $js = null) {
262        if ($js === null && is_array($html)) {
263            $js = $html;
264            $html = null;
265        } else if ($js === null)
266            $js = array();
267        $js["type"] = "submit";
268        if ($html === null)
269            $html = $name;
270        else if ((string) $name !== "")
271            $js["name"] = $name;
272        return self::button($html, $js);
273    }
274
275    static function hidden_default_submit($name, $value = null, $js = null) {
276        if ($js === null && is_array($value)) {
277            $js = $value;
278            $value = null;
279        } else if ($js === null)
280            $js = array();
281        $js["class"] = trim(get_s($js, "class") . " hidden");
282        return self::submit($name, $value, $js);
283    }
284
285    private static function apply_placeholder(&$value, &$js) {
286        if ($value === null || $value === get($js, "placeholder"))
287            $value = "";
288        if (($default = get($js, "data-default-value")) !== null
289            && $value === $default)
290            unset($js["data-default-value"]);
291    }
292
293    static function entry($name, $value, $js = null) {
294        $js = $js ? $js : array();
295        self::apply_placeholder($value, $js);
296        $type = get($js, "type") ? : "text";
297        return '<input type="' . $type . '" name="' . $name . '" value="'
298            . htmlspecialchars($value) . '"' . self::extra($js) . ' />';
299    }
300
301    static function password($name, $value, $js = null) {
302        $js = $js ? $js : array();
303        $js["type"] = "password";
304        return self::entry($name, $value, $js);
305    }
306
307    static function textarea($name, $value, $js = null) {
308        $js = $js ? $js : array();
309        self::apply_placeholder($value, $js);
310        return '<textarea name="' . $name . '"' . self::extra($js)
311            . '>' . htmlspecialchars($value) . '</textarea>';
312    }
313
314    static function actions($actions, $js = array(), $extra_text = "") {
315        if (empty($actions))
316            return "";
317        $actions = array_values($actions);
318        $js = $js ? : array();
319        if (!isset($js["class"]))
320            $js["class"] = "aab";
321        $t = "<div" . self::extra($js) . ">";
322        foreach ($actions as $i => $a) {
323            if ($a === "")
324                continue;
325            $t .= '<div class="aabut';
326            if ($i + 1 < count($actions) && $actions[$i + 1] === "")
327                $t .= ' aabutsp';
328            $t .= '">';
329            if (is_array($a)) {
330                $t .= $a[0];
331                if (count($a) > 1)
332                    $t .= '<div class="hint">' . $a[1] . '</div>';
333            } else
334                $t .= $a;
335            $t .= '</div>';
336        }
337        return $t . $extra_text . "</div>\n";
338    }
339
340    static function pre($html) {
341        if (is_array($html))
342            $text = join("\n", $html);
343        return "<pre>" . $html . "</pre>";
344    }
345
346    static function pre_text($text) {
347        if (is_array($text)
348            && array_keys($text) === range(0, count($text) - 1))
349            $text = join("\n", $text);
350        else if (is_array($text) || is_object($text))
351            $text = var_export($text, true);
352        return "<pre>" . htmlspecialchars($text) . "</pre>";
353    }
354
355    static function pre_text_wrap($text) {
356        if (is_array($text) && !is_associative_array($text)
357            && array_reduce($text, function ($x, $s) { return $x && is_string($s); }, true))
358            $text = join("\n", $text);
359        else if (is_array($text) || is_object($text))
360            $text = var_export($text, true);
361        return "<pre style=\"white-space:pre-wrap\">" . htmlspecialchars($text) . "</pre>";
362    }
363
364    static function pre_export($x) {
365        return "<pre style=\"white-space:pre-wrap\">" . htmlspecialchars(var_export($x, true)) . "</pre>";
366    }
367
368    static function img($src, $alt, $js = null) {
369        if (is_string($js))
370            $js = array("class" => $js);
371        if (self::$img_base && !preg_match(',\A(?:https?:/|/),i', $src))
372            $src = self::$img_base . $src;
373        return "<img src=\"" . $src . "\" alt=\"" . htmlspecialchars($alt) . "\""
374            . self::extra($js) . " />";
375    }
376
377    static private function make_link($html, $href, $js) {
378        if ($js === null)
379            $js = [];
380        if (!isset($js["href"]))
381            $js["href"] = isset($href) ? $href : "";
382        if (isset($js["onclick"]) && !preg_match('/(?:^return|;)/', $js["onclick"]))
383            $js["onclick"] = "return " . $js["onclick"];
384        if (isset($js["onclick"])
385            && (!isset($js["class"]) || !preg_match('/(?:\A|\s)(?:ui|btn|tla)(?=\s|\z)/', $js["class"])))
386            error_log(caller_landmark(2) . ": JS Ht::link lacks class");
387        return "<a" . self::extra($js) . ">" . $html . "</a>";
388    }
389
390    static function link($html, $href, $js = null) {
391        if ($js === null && is_array($href))
392            return self::make_link($html, null, $href);
393        else
394            return self::make_link($html, $href, $js);
395    }
396
397    static function link_urls($html) {
398        return preg_replace('@((?:https?|ftp)://(?:[^\s<>"&]|&amp;)*[^\s<>"().,:;&])(["().,:;]*)(?=[\s<>&]|\z)@s',
399                            '<a href="$1" rel="noreferrer">$1</a>$2', $html);
400    }
401
402    static function format0($html_text) {
403        $html_text = self::link_urls(Text::single_line_paragraphs($html_text));
404        return preg_replace('/(?:\r\n?){2,}|\n{2,}/', "</p><p>", "<p>$html_text</p>");
405    }
406
407    static function check_stash($uniqueid) {
408        return get(self::$_stash_map, $uniqueid, false);
409    }
410
411    static function mark_stash($uniqueid) {
412        $marked = get(self::$_stash_map, $uniqueid);
413        self::$_stash_map[$uniqueid] = true;
414        return !$marked;
415    }
416
417    static function stash_html($html, $uniqueid = null) {
418        if ($html !== null && $html !== false && $html !== ""
419            && (!$uniqueid || self::mark_stash($uniqueid))) {
420            if (self::$_stash_inscript)
421                self::$_stash .= "</script>";
422            self::$_stash .= $html;
423            self::$_stash_inscript = false;
424        }
425    }
426
427    static function stash_script($js, $uniqueid = null) {
428        if ($js !== null && $js !== false && $js !== ""
429            && (!$uniqueid || self::mark_stash($uniqueid))) {
430            if (!self::$_stash_inscript)
431                self::$_stash .= self::$_script_open . ">";
432            else if (($c = self::$_stash[strlen(self::$_stash) - 1]) !== "}"
433                     && $c !== "{" && $c !== ";")
434                self::$_stash .= ";";
435            self::$_stash .= $js;
436            self::$_stash_inscript = true;
437        }
438    }
439
440    static function unstash() {
441        $stash = self::$_stash;
442        if (self::$_stash_inscript)
443            $stash .= "</script>";
444        self::$_stash = "";
445        self::$_stash_inscript = false;
446        return $stash;
447    }
448
449    static function unstash_script($js) {
450        self::stash_script($js);
451        return self::unstash();
452    }
453
454    static function take_stash() {
455        return self::unstash();
456    }
457
458
459    static function xmsg($type, $content) {
460        if (is_int($type))
461            $type = $type >= 2 ? "error" : ($type > 0 ? "warning" : "info");
462        if (substr($type, 0, 1) === "x")
463            $type = substr($type, 1);
464        if ($type === "merror")
465            $type = "error";
466        if (is_array($content)) {
467            $content = join("", array_map(function ($x) {
468                if (str_starts_with($x, "<p") || str_starts_with($x, "<div"))
469                    return $x;
470                else
471                    return "<p>{$x}</p>";
472            }, $content));
473        }
474        if ($content === "")
475            return "";
476        return '<div class="msg msg-' . $type . '">' . $content . '</div>';
477    }
478
479
480    static function control_class($name, $rest = false) {
481        if (isset(self::$_control_classes[$name])) {
482            $c = self::$_control_classes[$name];
483            if ($rest && $c && $c[0] !== " ")
484                $rest .= " ";
485            return $rest ? $rest . $c : $c;
486        } else {
487            return $rest;
488        }
489    }
490    static function set_control_class($name, $class) {
491        self::$_control_classes[$name] = $class ? " " . $class : "";
492    }
493    static function error_at($name) {
494        self::$_control_classes[$name] = " has-error";
495    }
496}
497