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("\"", """, $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('/(?:&|&)/', 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<>"&]|&)*[^\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