1<?php
2// conference.php -- HotCRP central helper class (singleton)
3// Copyright (c) 2006-2018 Eddie Kohler; see LICENSE.
4
5class Track {
6    const VIEW = 0;
7    const VIEWPDF = 1;
8    const VIEWREV = 2;
9    const VIEWREVID = 3;
10    const ASSREV = 4;
11    const UNASSREV = 5;
12    const VIEWTRACKER = 6;
13    const ADMIN = 7;
14    const VIEWREVOVERRIDE = 8;
15    const HIDDENTAG = 9;
16    const VIEWALLREV = 10;
17
18    const BITS_VIEW = 0x1;    // 1 << VIEW
19    const BITS_REVIEW = 0x30; // (1 << ASSREV) | (1 << UNASSREV)
20    const BITS_ADMIN = 0x80;  // 1 << ADMIN
21
22    static public $map = [
23        "view" => 0, "viewpdf" => 1, "viewrev" => 2, "viewrevid" => 3,
24        "assrev" => 4, "unassrev" => 5, "viewtracker" => 6, "admin" => 7,
25        "viewrevover" => 8, "hiddentag" => 9, "viewallrev" => 10
26    ];
27    static public $zero = [null, null, null, null, null, null, null, null, null, null, null];
28    static function match_perm(Contact $user, $perm) {
29        if ($perm) {
30            $has_tag = $user->has_tag(substr($perm, 1));
31            return $perm[0] === "-" ? !$has_tag : $has_tag;
32        } else
33            return true;
34    }
35    static function permission_required($perm) {
36        return $perm === self::ADMIN || $perm === self::VIEWREVOVERRIDE
37            || $perm === self::HIDDENTAG;
38    }
39}
40
41class ResponseRound {
42    public $name;
43    public $number;
44    public $open;
45    public $done;
46    public $grace;
47    public $words;
48    public $search;
49    function time_allowed($with_grace) {
50        global $Now;
51        if ($this->open === null || $this->open <= 0 || $this->open > $Now)
52            return false;
53        $t = $this->done;
54        if ($t !== null && $t > 0 && $with_grace && $this->grace)
55            $t += $this->grace;
56        return $t === null || $t <= 0 || $t >= $Now;
57    }
58    function instructions(Conf $conf) {
59        $m = false;
60        if ($this->number)
61            $m = $conf->message_html("resp_instrux_$this->number", ["wordlimit" => $this->words]);
62        if ($m === false)
63            $m = $conf->message_html("resp_instrux", ["wordlimit" => $this->words]);
64        return $m;
65    }
66}
67
68class Conf {
69    public $dblink = null;
70
71    public $settings;
72    private $settingTexts;
73    public $sversion;
74    private $_pc_seeall_cache = null;
75    private $_pc_see_pdf = false;
76
77    public $dbname;
78    public $dsn = null;
79
80    public $short_name;
81    public $long_name;
82    public $default_format;
83    public $download_prefix;
84    public $au_seerev;
85    public $tag_au_seerev;
86    public $any_response_open;
87    public $tag_seeall;
88    public $sort_by_last;
89    public $opt;
90    public $opt_override = null;
91    private $_opt_timestamp = null;
92    public $paper_opts;
93
94    public $headerPrinted = false;
95    private $_save_logs = false;
96    public $_session_handler;
97
98    private $usertimeId = 1;
99
100    private $rounds = null;
101    private $_defined_rounds = null;
102    private $_round_settings = null;
103    private $_resp_rounds = null;
104    private $tracks = null;
105    private $_taginfo = null;
106    private $_track_tags = null;
107    private $_track_sensitivity = 0;
108    private $_decisions = null;
109    private $_topic_map = null;
110    private $_topic_order_map = null;
111    private $_topic_separator_cache = null;
112    private $_topic_abbrev_matcher = null;
113    private $_pc_members_cache = null;
114    private $_pc_tags_cache = null;
115    private $_pc_members_and_admins_cache = null;
116    private $_pc_members_fully_loaded = false;
117    private $_user_cache = null;
118    private $_user_cache_missing = null;
119    private $_site_contact;
120    private $_review_form_cache = null;
121    private $_abbrev_matcher = null;
122    private $_date_format_initialized = false;
123    private $_formatspec_cache = [];
124    private $_docstore = false;
125    private $_defined_formulas = null;
126    private $_emoji_codes = null;
127    private $_s3_document = false;
128    private $_ims = null;
129    private $_format_info = null;
130    private $_updating_autosearch_tags = false;
131    private $_cdb = false;
132
133    private $_formula_functions = null;
134    private $_search_keyword_base = null;
135    private $_search_keyword_factories = null;
136    private $_assignment_parsers = null;
137    private $_api_map = null;
138    private $_list_action_map = null;
139    private $_list_action_renderers = null;
140    private $_list_action_factories = null;
141    private $_paper_column_map = null;
142    private $_paper_column_factories = null;
143    private $_option_type_map = null;
144    private $_option_type_factories = null;
145    private $_hook_map = null;
146    private $_hook_factories = null;
147    public $_file_filters = null; // maintained externally
148    public $_setting_info = null; // maintained externally
149    private $_mail_keyword_map = null;
150    private $_mail_keyword_factories = null;
151
152    public $paper = null; // current paper row
153    private $_active_list = false;
154
155    static public $g;
156    static public $no_invalidate_caches = false;
157    static public $next_xt_subposition = 0;
158    static private $xt_require_resolved = [];
159
160    const BLIND_NEVER = 0;
161    const BLIND_OPTIONAL = 1;
162    const BLIND_ALWAYS = 2;
163    const BLIND_UNTILREVIEW = 3;
164
165    const SEEDEC_ADMIN = 0;
166    const SEEDEC_REV = 1;
167    const SEEDEC_ALL = 2;
168    const SEEDEC_NCREV = 3;
169
170    const AUSEEREV_NO = 0;
171    const AUSEEREV_UNLESSINCOMPLETE = 1;
172    const AUSEEREV_YES = 2;
173    const AUSEEREV_TAGS = 3;
174
175    const PCSEEREV_IFCOMPLETE = 0;
176    const PCSEEREV_YES = 1;
177    const PCSEEREV_UNLESSINCOMPLETE = 3;
178    const PCSEEREV_UNLESSANYINCOMPLETE = 4;
179
180    static public $review_deadlines = array("pcrev_soft", "pcrev_hard", "extrev_soft", "extrev_hard");
181
182    static public $hoturl_defaults = null;
183
184    function __construct($options, $make_dsn) {
185        // unpack dsn, connect to database, load current settings
186        if ($make_dsn && ($this->dsn = Dbl::make_dsn($options)))
187            list($this->dblink, $options["dbName"]) = Dbl::connect_dsn($this->dsn);
188        if (!isset($options["confid"]))
189            $options["confid"] = get($options, "dbName");
190        $this->opt = $options;
191        $this->dbname = $options["dbName"];
192        $this->paper_opts = new PaperOptionList($this);
193        if ($this->dblink && !Dbl::$default_dblink) {
194            Dbl::set_default_dblink($this->dblink);
195            Dbl::set_error_handler(array($this, "query_error_handler"));
196        }
197        if ($this->dblink) {
198            Dbl::$landmark_sanitizer = "/^(?:Dbl::|Conf::q|Conf::fetch|call_user_func)/";
199            $this->load_settings();
200        } else
201            $this->crosscheck_options();
202    }
203
204
205    //
206    // Initialization functions
207    //
208
209    function load_settings() {
210        global $Now;
211
212        // load settings from database
213        $this->settings = array();
214        $this->settingTexts = array();
215        foreach ($this->opt_override ? : [] as $k => $v) {
216            if ($v === null)
217                unset($this->opt[$k]);
218            else
219                $this->opt[$k] = $v;
220        }
221        $this->opt_override = [];
222
223        $result = $this->q_raw("select name, value, data from Settings");
224        while ($result && ($row = $result->fetch_row())) {
225            $this->settings[$row[0]] = (int) $row[1];
226            if ($row[2] !== null)
227                $this->settingTexts[$row[0]] = $row[2];
228            if (substr($row[0], 0, 4) == "opt.") {
229                $okey = substr($row[0], 4);
230                $this->opt_override[$okey] = get($this->opt, $okey);
231                $this->opt[$okey] = ($row[2] === null ? (int) $row[1] : $row[2]);
232            }
233        }
234        Dbl::free($result);
235
236        // update schema
237        $this->sversion = $this->settings["allowPaperOption"];
238        if ($this->sversion < 199) {
239            require_once("updateschema.php");
240            $old_nerrors = Dbl::$nerrors;
241            updateSchema($this);
242            Dbl::$nerrors = $old_nerrors;
243        }
244        if ($this->sversion < 95)
245            self::msg_error("Warning: The database could not be upgraded to the current version; expect errors. A system administrator must solve this problem.");
246
247        // invalidate all caches after loading from backup
248        if (isset($this->settings["frombackup"])
249            && $this->invalidate_caches()) {
250            $this->qe_raw("delete from Settings where name='frombackup' and value=" . $this->settings["frombackup"]);
251            unset($this->settings["frombackup"]);
252        }
253
254        // update options
255        if (isset($this->opt["ldapLogin"]) && !$this->opt["ldapLogin"])
256            unset($this->opt["ldapLogin"]);
257        if (isset($this->opt["httpAuthLogin"]) && !$this->opt["httpAuthLogin"])
258            unset($this->opt["httpAuthLogin"]);
259
260        // set conferenceKey
261        if (!isset($this->opt["conferenceKey"])) {
262            if (!isset($this->settingTexts["conf_key"])
263                && ($key = random_bytes(32)) !== false)
264                $this->__save_setting("conf_key", 1, $key);
265            $this->opt["conferenceKey"] = get($this->settingTexts, "conf_key", "");
266        }
267
268        // set capability key
269        if (!get($this->settings, "cap_key")
270            && !get($this->opt, "disableCapabilities")
271            && !(($key = random_bytes(16)) !== false
272                 && ($key = base64_encode($key))
273                 && $this->__save_setting("cap_key", 1, $key)))
274            $this->opt["disableCapabilities"] = true;
275
276        // GC old capabilities
277        if (get($this->settings, "__capability_gc", 0) < $Now - 86400) {
278            foreach (array($this->dblink, $this->contactdb()) as $db)
279                if ($db)
280                    Dbl::ql($db, "delete from Capability where timeExpires>0 and timeExpires<$Now");
281            $this->q_raw("insert into Settings (name, value) values ('__capability_gc', $Now) on duplicate key update value=values(value)");
282            $this->settings["__capability_gc"] = $Now;
283        }
284
285        $this->crosscheck_settings();
286        $this->crosscheck_options();
287    }
288
289    private function crosscheck_settings() {
290        global $Now;
291
292        // enforce invariants
293        foreach (array("pcrev_any", "extrev_view") as $x)
294            if (!isset($this->settings[$x]))
295                $this->settings[$x] = 0;
296        if (!isset($this->settings["sub_blind"]))
297            $this->settings["sub_blind"] = self::BLIND_ALWAYS;
298        if (!isset($this->settings["rev_blind"]))
299            $this->settings["rev_blind"] = self::BLIND_ALWAYS;
300        if (!isset($this->settings["seedec"])) {
301            if (get($this->settings, "au_seedec"))
302                $this->settings["seedec"] = self::SEEDEC_ALL;
303            else if (get($this->settings, "rev_seedec"))
304                $this->settings["seedec"] = self::SEEDEC_REV;
305        }
306        if (get($this->settings, "pc_seeallrev") == 2) {
307            $this->settings["pc_seeblindrev"] = 1;
308            $this->settings["pc_seeallrev"] = self::PCSEEREV_YES;
309        }
310        if (($sub_update = get($this->settings, "sub_update", -1)) > 0
311            && ($sub_reg = get($this->settings, "sub_reg", -1)) <= 0) {
312            $this->settings["sub_reg"] = $sub_update;
313            $this->settings["__sub_reg"] = $sub_reg;
314        }
315
316        // rounds
317        $this->crosscheck_round_settings();
318
319        // S3 settings
320        foreach (array("s3_bucket", "s3_key", "s3_secret") as $k)
321            if (!get($this->settingTexts, $k) && ($x = get($this->opt, $k)))
322                $this->settingTexts[$k] = $x;
323        if (!get($this->settingTexts, "s3_key")
324            || !get($this->settingTexts, "s3_secret")
325            || !get($this->settingTexts, "s3_bucket"))
326            unset($this->settingTexts["s3_key"], $this->settingTexts["s3_secret"],
327                  $this->settingTexts["s3_bucket"]);
328        if (get($this->opt, "dbNoPapers") && !get($this->opt, "docstore")
329            && !get($this->opt, "filestore") && !get($this->settingTexts, "s3_bucket"))
330            unset($this->opt["dbNoPapers"]);
331        if ($this->_s3_document
332            && (!isset($this->settingTexts["s3_bucket"])
333                || !$this->_s3_document->check_key_secret_bucket($this->settingTexts["s3_key"], $this->settingTexts["s3_secret"], $this->settingTexts["s3_bucket"])))
334            $this->_s3_document = false;
335
336        // tracks settings
337        $this->tracks = $this->_track_tags = null;
338        $this->_track_sensitivity = 0;
339        if (($j = get($this->settingTexts, "tracks")))
340            $this->crosscheck_track_settings($j);
341
342        // clear caches
343        $this->_decisions = null;
344        $this->_pc_seeall_cache = null;
345        $this->_defined_rounds = null;
346        $this->_resp_rounds = null;
347        // digested settings
348        $this->_pc_see_pdf = true;
349        if (get($this->settings, "sub_freeze", 0) <= 0
350            && ($so = get($this->settings, "sub_open", 0)) > 0
351            && $so < $Now
352            && ($ss = get($this->settings, "sub_sub", 0)) > 0
353            && $ss > $Now
354            && (get($this->settings, "pc_seeallpdf", 0) <= 0
355                || !$this->can_pc_see_all_submissions()))
356            $this->_pc_see_pdf = false;
357
358        $this->au_seerev = get($this->settings, "au_seerev", 0);
359        $this->tag_au_seerev = null;
360        if ($this->au_seerev == self::AUSEEREV_TAGS)
361            $this->tag_au_seerev = explode(" ", get_s($this->settingTexts, "tag_au_seerev"));
362        $this->any_response_open = get($this->settings, "resp_active", 0) > 0
363            && $this->time_author_respond_all_rounds();
364        $this->tag_seeall = get($this->settings, "tag_seeall", 0) > 0;
365    }
366
367    private function crosscheck_round_settings() {
368        $this->rounds = [""];
369        if (isset($this->settingTexts["tag_rounds"])) {
370            foreach (explode(" ", $this->settingTexts["tag_rounds"]) as $r)
371                if ($r != "")
372                    $this->rounds[] = $r;
373        }
374        $this->_round_settings = null;
375        if (isset($this->settingTexts["round_settings"])) {
376            $this->_round_settings = json_decode($this->settingTexts["round_settings"]);
377            $max_rs = [];
378            foreach ($this->_round_settings as $rs) {
379                if ($rs && isset($rs->pc_seeallrev)
380                    && self::pcseerev_compare($rs->pc_seeallrev, get($max_rs, "pc_seeallrev", 0)) > 0)
381                    $max_rs["pc_seeallrev"] = $rs->pc_seeallrev;
382                if ($rs && isset($rs->extrev_view)
383                    && $rs->extrev_view > get($max_rs, "extrev_view", 0))
384                    $max_rs["extrev_view"] = $rs->extrev_view;
385            }
386            $this->_round_settings["max"] = (object) $max_rs;
387        }
388
389        // review times
390        foreach ($this->rounds as $i => $rname) {
391            $suf = $i ? "_$i" : "";
392            if (!isset($this->settings["extrev_soft$suf"]) && isset($this->settings["pcrev_soft$suf"]))
393                $this->settings["extrev_soft$suf"] = $this->settings["pcrev_soft$suf"];
394            if (!isset($this->settings["extrev_hard$suf"]) && isset($this->settings["pcrev_hard$suf"]))
395                $this->settings["extrev_hard$suf"] = $this->settings["pcrev_hard$suf"];
396        }
397    }
398
399    private function crosscheck_track_settings($j) {
400        if (is_string($j) && !($j = json_decode($j)))
401            return;
402        $this->tracks = [];
403        $default_track = Track::$zero;
404        $this->_track_tags = [];
405        foreach ((array) $j as $k => $v) {
406            if ($k !== "_")
407                $this->_track_tags[] = $k;
408            if (!isset($v->viewpdf) && isset($v->view))
409                $v->viewpdf = $v->view;
410            $t = Track::$zero;
411            foreach (Track::$map as $tname => $idx)
412                if (isset($v->$tname)) {
413                    $t[$idx] = $v->$tname;
414                    $this->_track_sensitivity |= 1 << $idx;
415                }
416            if ($k === "_")
417                $default_track = $t;
418            else
419                $this->tracks[$k] = $t;
420        }
421        $this->tracks["_"] = $default_track;
422    }
423
424    function crosscheck_options() {
425        global $ConfSitePATH;
426
427        // set longName, downloadPrefix, etc.
428        $confid = $this->opt["confid"];
429        if ((!isset($this->opt["longName"]) || $this->opt["longName"] == "")
430            && (!isset($this->opt["shortName"]) || $this->opt["shortName"] == "")) {
431            $this->opt["shortNameDefaulted"] = true;
432            $this->opt["longName"] = $this->opt["shortName"] = $confid;
433        } else if (!isset($this->opt["longName"]) || $this->opt["longName"] == "")
434            $this->opt["longName"] = $this->opt["shortName"];
435        else if (!isset($this->opt["shortName"]) || $this->opt["shortName"] == "")
436            $this->opt["shortName"] = $this->opt["longName"];
437        if (!isset($this->opt["downloadPrefix"]) || $this->opt["downloadPrefix"] == "")
438            $this->opt["downloadPrefix"] = $confid . "-";
439        $this->short_name = $this->opt["shortName"];
440        $this->long_name = $this->opt["longName"];
441
442        // expand ${confid}, ${confshortname}
443        foreach (array("sessionName", "downloadPrefix", "conferenceSite",
444                       "paperSite", "defaultPaperSite", "contactName",
445                       "contactEmail", "docstore") as $k)
446            if (isset($this->opt[$k]) && is_string($this->opt[$k])
447                && strpos($this->opt[$k], "$") !== false) {
448                $this->opt[$k] = preg_replace(',\$\{confid\}|\$confid\b,', $confid, $this->opt[$k]);
449                $this->opt[$k] = preg_replace(',\$\{confshortname\}|\$confshortname\b,', $this->short_name, $this->opt[$k]);
450            }
451        $this->download_prefix = $this->opt["downloadPrefix"];
452
453        foreach (array("emailFrom", "emailSender", "emailCc", "emailReplyTo") as $k)
454            if (isset($this->opt[$k]) && is_string($this->opt[$k])
455                && strpos($this->opt[$k], "$") !== false) {
456                $this->opt[$k] = preg_replace(',\$\{confid\}|\$confid\b,', $confid, $this->opt[$k]);
457                if (strpos($this->opt[$k], "confshortname") !== false) {
458                    $v = rfc2822_words_quote($this->short_name);
459                    if ($v[0] === "\"" && strpos($this->opt[$k], "\"") !== false)
460                        $v = substr($v, 1, strlen($v) - 2);
461                    $this->opt[$k] = preg_replace(',\$\{confshortname\}|\$confshortname\b,', $v, $this->opt[$k]);
462                }
463            }
464
465        // remove final slash from $Opt["paperSite"]
466        if (!isset($this->opt["paperSite"]) || $this->opt["paperSite"] == "")
467            $this->opt["paperSite"] = Navigation::site_absolute();
468        if ($this->opt["paperSite"] == "" && isset($this->opt["defaultPaperSite"]))
469            $this->opt["paperSite"] = $this->opt["defaultPaperSite"];
470        $this->opt["paperSite"] = preg_replace('|/+\z|', "", $this->opt["paperSite"]);
471
472        // option name updates (backwards compatibility)
473        foreach (array("assetsURL" => "assetsUrl",
474                       "jqueryURL" => "jqueryUrl", "jqueryCDN" => "jqueryCdn",
475                       "disableCSV" => "disableCsv") as $kold => $knew)
476            if (isset($this->opt[$kold]) && !isset($this->opt[$knew]))
477                $this->opt[$knew] = $this->opt[$kold];
478
479        // set assetsUrl and scriptAssetsUrl
480        if (!isset($this->opt["scriptAssetsUrl"]) && isset($_SERVER["HTTP_USER_AGENT"])
481            && strpos($_SERVER["HTTP_USER_AGENT"], "MSIE") !== false)
482            $this->opt["scriptAssetsUrl"] = Navigation::siteurl();
483        if (!isset($this->opt["assetsUrl"]))
484            $this->opt["assetsUrl"] = Navigation::siteurl();
485        if ($this->opt["assetsUrl"] !== "" && !str_ends_with($this->opt["assetsUrl"], "/"))
486            $this->opt["assetsUrl"] .= "/";
487        if (!isset($this->opt["scriptAssetsUrl"]))
488            $this->opt["scriptAssetsUrl"] = $this->opt["assetsUrl"];
489        Ht::$img_base = $this->opt["assetsUrl"] . "images/";
490        Ht::$default_button_class = "btn";
491
492        // set docstore
493        if (get($this->opt, "docstore") === true)
494            $this->opt["docstore"] = "docs";
495        else if (!get($this->opt, "docstore") && get($this->opt, "filestore")) { // backwards compat
496            $this->opt["docstore"] = $this->opt["filestore"];
497            if ($this->opt["docstore"] === true)
498                $this->opt["docstore"] = "filestore";
499            $this->opt["docstoreSubdir"] = get($this->opt, "filestoreSubdir");
500        }
501        if (get($this->opt, "docstore") && $this->opt["docstore"][0] !== "/")
502            $this->opt["docstore"] = $ConfSitePATH . "/" . $this->opt["docstore"];
503        $this->_docstore = false;
504        if (($dpath = get($this->opt, "docstore"))) {
505            if (strpos($dpath, "%") !== false)
506                $this->_docstore = $dpath;
507            else {
508                if ($dpath[strlen($dpath) - 1] === "/")
509                    $dpath = substr($dpath, 0, strlen($dpath) - 1);
510                $use_subdir = get($this->opt, "docstoreSubdir");
511                if ($use_subdir && ($use_subdir === true || $use_subdir > 0))
512                    $dpath .= "/%" . ($use_subdir === true ? 2 : $use_subdir) . "h";
513                $this->_docstore = $dpath . "/%h%x";
514            }
515        }
516
517        // handle timezone
518        if (function_exists("date_default_timezone_set")) {
519            if (isset($this->opt["timezone"])) {
520                if (!date_default_timezone_set($this->opt["timezone"])) {
521                    self::msg_error("Timezone option “" . htmlspecialchars($this->opt["timezone"]) . "” is invalid; falling back to “America/New_York”.");
522                    date_default_timezone_set("America/New_York");
523                }
524            } else if (!ini_get("date.timezone") && !getenv("TZ"))
525                date_default_timezone_set("America/New_York");
526        }
527        $this->_date_format_initialized = false;
528
529        // set safePasswords
530        if (!get($this->opt, "safePasswords")
531            || (is_int($this->opt["safePasswords"]) && $this->opt["safePasswords"] < 1))
532            $this->opt["safePasswords"] = 0;
533        else if ($this->opt["safePasswords"] === true)
534            $this->opt["safePasswords"] = 1;
535        if (!isset($this->opt["contactdb_safePasswords"]))
536            $this->opt["contactdb_safePasswords"] = $this->opt["safePasswords"];
537
538        // set defaultFormat
539        $this->default_format = (int) get($this->opt, "defaultFormat");
540        $this->_format_info = null;
541
542        // other caches
543        $sort_by_last = !!get($this->opt, "sortByLastName");
544        if (!$this->sort_by_last != !$sort_by_last)
545            $this->invalidate_caches("pc");
546        $this->sort_by_last = $sort_by_last;
547
548        $this->_api_map = null;
549        $this->_list_action_map = $this->_list_action_renderers = $this->_list_action_factories = null;
550        $this->_file_filters = null;
551        $this->_site_contact = null;
552    }
553
554    function has_setting($name) {
555        return isset($this->settings[$name]);
556    }
557
558    function setting($name, $defval = null) {
559        return get($this->settings, $name, $defval);
560    }
561
562    function setting_data($name, $defval = false) {
563        return get($this->settingTexts, $name, $defval);
564    }
565
566    function setting_json($name, $defval = false) {
567        $x = get($this->settingTexts, $name, $defval);
568        return is_string($x) ? json_decode($x) : $x;
569    }
570
571    function opt($name, $defval = null) {
572        return get($this->opt, $name, $defval);
573    }
574
575    function set_opt($name, $value) {
576        global $Opt;
577        $Opt[$name] = $this->opt[$name] = $value;
578    }
579
580    function opt_timestamp() {
581        if ($this->_opt_timestamp === null) {
582            $this->_opt_timestamp = 1;
583            foreach (get($this->opt, "loaded", []) as $fn)
584                $this->_opt_timestamp = max($this->_opt_timestamp, +@filemtime($fn));
585        }
586        return $this->_opt_timestamp;
587    }
588
589
590    static function pcseerev_compare($sr1, $sr2) {
591        if ($sr1 == $sr2)
592            return 0;
593        else if ($sr1 == self::PCSEEREV_YES || $sr2 == self::PCSEEREV_YES)
594            return $sr1 == self::PCSEEREV_YES ? 1 : -1;
595        else
596            return $sr1 > $sr2 ? 1 : -1;
597    }
598
599
600    // database
601
602    function q(/* $qstr, ... */) {
603        return Dbl::do_query_on($this->dblink, func_get_args(), 0);
604    }
605    function q_raw(/* $qstr */) {
606        return Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_RAW);
607    }
608    function q_apply(/* $qstr, $args */) {
609        return Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_APPLY);
610    }
611
612    function ql(/* $qstr, ... */) {
613        return Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_LOG);
614    }
615    function ql_raw(/* $qstr */) {
616        return Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_RAW | Dbl::F_LOG);
617    }
618    function ql_apply(/* $qstr, $args */) {
619        return Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_APPLY | Dbl::F_LOG);
620    }
621
622    function qe(/* $qstr, ... */) {
623        return Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_ERROR);
624    }
625    function qe_raw(/* $qstr */) {
626        return Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_RAW | Dbl::F_ERROR);
627    }
628    function qe_apply(/* $qstr, $args */) {
629        return Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_APPLY | Dbl::F_ERROR);
630    }
631
632    function fetch_rows(/* $qstr, ... */) {
633        return Dbl::fetch_rows(Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_ERROR));
634    }
635    function fetch_first_row(/* $qstr, ... */) {
636        return Dbl::fetch_first_row(Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_ERROR));
637    }
638    function fetch_first_object(/* $qstr, ... */) {
639        return Dbl::fetch_first_object(Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_ERROR));
640    }
641    function fetch_value(/* $qstr, ... */) {
642        return Dbl::fetch_value(Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_ERROR));
643    }
644    function fetch_ivalue(/* $qstr, ... */) {
645        return Dbl::fetch_ivalue(Dbl::do_query_on($this->dblink, func_get_args(), Dbl::F_ERROR));
646    }
647
648    function db_error_html($getdb = true, $while = "") {
649        $text = "<p>Database error";
650        if ($while)
651            $text .= " $while";
652        if ($getdb)
653            $text .= ": " . htmlspecialchars($this->dblink->error);
654        return $text . "</p>";
655    }
656
657    function db_error_text($getdb = true, $while = "") {
658        $text = "Database error";
659        if ($while)
660            $text .= " $while";
661        if ($getdb)
662            $text .= ": " . $this->dblink->error;
663        return $text;
664    }
665
666    function query_error_handler($dblink, $query) {
667        $landmark = caller_landmark(1, "/^(?:Dbl::|Conf::q|call_user_func)/");
668        if (PHP_SAPI == "cli")
669            fwrite(STDERR, "$landmark: database error: $dblink->error in $query\n");
670        else {
671            error_log("$landmark: database error: $dblink->error in $query");
672            self::msg_error("<p>" . htmlspecialchars($landmark) . ": database error: " . htmlspecialchars($this->dblink->error) . " in " . Ht::pre_text_wrap($query) . "</p>");
673        }
674    }
675
676
677    // name
678
679    function full_name() {
680        if ($this->short_name && $this->short_name != $this->long_name)
681            return $this->long_name . " (" . $this->short_name . ")";
682        else
683            return $this->long_name;
684    }
685
686
687    function format_spec($dtype) {
688        if (!isset($this->_formatspec_cache[$dtype])) {
689            $o = $this->paper_opts->get($dtype);
690            $spec = $o ? $o->format_spec() : null;
691            $this->_formatspec_cache[$dtype] = $spec ? : new FormatSpec;
692        }
693        return $this->_formatspec_cache[$dtype];
694    }
695
696    function docstore() {
697        return $this->_docstore;
698    }
699
700    function s3_docstore() {
701        global $Now;
702        if ($this->_s3_document === false) {
703            if ($this->setting_data("s3_bucket")) {
704                $opts = ["key" => $this->setting_data("s3_key"),
705                         "secret" => $this->setting_data("s3_secret"),
706                         "bucket" => $this->setting_data("s3_bucket"),
707                         "scope" => $this->setting_data("__s3_scope"),
708                         "signing_key" => $this->setting_data("__s3_signing_key")];
709                $this->_s3_document = S3Document::make($opts);
710                list($scope, $signing_key) = $this->_s3_document->scope_and_signing_key($Now);
711                if ($opts["scope"] !== $scope || $opts["signing_key"] !== $signing_key) {
712                    $this->__save_setting("__s3_scope", 1, $scope);
713                    $this->__save_setting("__s3_signing_key", 1, $signing_key);
714                }
715            } else
716                $this->_s3_document = null;
717        }
718        return $this->_s3_document;
719    }
720
721
722    function _add_emoji_code($val, $key) {
723        if (is_string($val) && str_starts_with($key, ":") && str_ends_with($key, ":")) {
724            $this->_emoji_codes[$key] = $val;
725            return true;
726        } else
727            return false;
728    }
729    function emoji_code_map() {
730        global $ConfSitePATH;
731        if ($this->_emoji_codes === null) {
732            $this->_emoji_codes = json_decode(file_get_contents("$ConfSitePATH/etc/emojicodes.json"), true);
733            if (($olist = $this->opt("emojiCodes")))
734                expand_json_includes_callback($olist, [$this, "_add_emoji_code"]);
735        }
736        return $this->_emoji_codes;
737    }
738
739
740    static function xt_priority($xt) {
741        return $xt ? get($xt, "priority", 0) : -PHP_INT_MAX;
742    }
743    static function xt_priority_compare($xta, $xtb) {
744        $ap = self::xt_priority($xta);
745        $bp = self::xt_priority($xtb);
746        if ($ap == $bp) {
747            $ap = $xta ? get($xta, "__subposition", 0) : -PHP_INT_MAX;
748            $bp = $xtb ? get($xtb, "__subposition", 0) : -PHP_INT_MAX;
749        }
750        return $ap < $bp ? 1 : ($ap == $bp ? 0 : -1);
751    }
752    static function xt_position_compare($xta, $xtb) {
753        $ap = get($xta, "position", 0);
754        $bp = get($xtb, "position", 0);
755        if ($ap == $bp) {
756            $ap = get($xta, "__subposition", 0);
757            $bp = get($xtb, "__subposition", 0);
758        }
759        return $ap < $bp ? -1 : ($ap == $bp ? 0 : 1);
760    }
761    static function xt_add(&$a, $name, $xt) {
762        $a[$name][] = $xt;
763        if (($syn = get($xt, "synonym")))
764            foreach (is_string($syn) ? [$syn] : $syn as $synname)
765                $a[$synname][] = $xt;
766        return true;
767    }
768    static private function xt_combine($xt1, $xt2) {
769        foreach (get_object_vars($xt2) as $k => $v)
770            if (!property_exists($xt1, $k)
771                && $k !== "match"
772                && $k !== "expand_callback")
773                $xt1->$k = $v;
774    }
775    static function xt_enabled($xt) {
776        return $xt && (!isset($xt->disabled) || !$xt->disabled);
777    }
778    static function xt_disabled($xt) {
779        return !$xt || (isset($xt->disabled) && $xt->disabled);
780    }
781    static function xt_resolve_require($xt) {
782        if ($xt
783            && isset($xt->require)
784            && !isset(self::$xt_require_resolved[$xt->require])) {
785            foreach (expand_includes($xt->require, ["autoload" => true]) as $f)
786                require_once($f);
787            self::$xt_require_resolved[$xt->require] = true;
788        }
789        return $xt && (!isset($xt->disabled) || !$xt->disabled) ? $xt : null;
790    }
791    function xt_check($expr, $xt, Contact $user = null) {
792        $es = is_array($expr) ? $expr : [$expr];
793        foreach ($es as $e) {
794            $not = false;
795            if (is_string($e) && ($not = str_starts_with($e, "!")))
796                $e = substr($e, 1);
797            if (!is_string($e))
798                $b = $e;
799            else if ($e === "chair")
800                $b = !$user || $user->privChair;
801            else if ($e === "manager")
802                $b = !$user || $user->is_manager();
803            else if ($e === "pc")
804                $b = !$user || $user->isPC;
805            else if ($e === "reviewer")
806                $b = !$user || $user->is_reviewer();
807            else if ($e === "view_review")
808                $b = !$user || $user->can_view_some_review();
809            else if ($e === "lead" || $e === "shepherd")
810                $b = $this->has_any_lead_or_shepherd();
811            else if (strpos($e, "::") !== false) {
812                self::xt_resolve_require($xt);
813                $b = call_user_func($e, $xt, $user, $this);
814            } else {
815                // check if setting exists
816                if (str_starts_with($e, "opt."))
817                    $b = !!$this->opt(substr($e, 4));
818                else if (str_starts_with($e, "setting."))
819                    $b = !!$this->setting(substr($e, 8));
820                else
821                    $b = !!$this->setting($e);
822            }
823            if ($not ? $b : !$b)
824                return false;
825        }
826        return true;
827    }
828    function xt_allowed($xt, Contact $user = null) {
829        return $xt && (!isset($xt->allow_if)
830                       || $this->xt_check($xt->allow_if, $xt, $user));
831    }
832    static function xt_allow_list($xt) {
833        if ($xt && isset($xt->allow_if))
834            return is_array($xt->allow_if) ? $xt->allow_if : [$xt->allow_if];
835        else
836            return [];
837    }
838    function xt_search_name($map, $name, $checkf, $found = null) {
839        foreach (get($map, $name, []) as $xt)
840            if (self::xt_priority_compare($xt, $found) <= 0
841                && call_user_func($checkf, $xt))
842                $found = $xt;
843        return $found;
844    }
845    function xt_search_factories($factories, $name, $checkf, $found,
846                                 Contact $user = null, $reflags = "") {
847        $this->xt_user = $user;
848        $this->_xt_factory_match = false;
849        $this->_xt_factory_error = null;
850        $xts = [];
851        foreach ($factories as $fxt) {
852            if (self::xt_priority_compare($fxt, $found) >= 0)
853                break;
854            if ($fxt->match === ".*")
855                $m = [$name];
856            else if (!preg_match("\1\\A(?:{$fxt->match})\\z\1{$reflags}", $name, $m))
857                continue;
858            if (!call_user_func($checkf, $fxt))
859                continue;
860            self::xt_resolve_require($fxt);
861            if (isset($fxt->expand_callback))
862                $r = call_user_func($fxt->expand_callback, $name, $this, $fxt, $m);
863            else
864                $r = (object) ["name" => $name, "match_data" => $m];
865            if (is_object($r))
866                $r = [$r];
867            foreach ($r ? : [] as $xt) {
868                self::xt_combine($xt, $fxt);
869                $prio = self::xt_priority_compare($xt, $found);
870                if ($prio <= 0 && call_user_func($checkf, $xt)) {
871                    if ($prio < 0)
872                        $xts = [];
873                    $xts[] = $found = $xt;
874                }
875            }
876        }
877        $this->xt_user = null;
878        return $xts;
879    }
880    function xt_factory_mark_matched() {
881        $this->_xt_factory_match = true;
882    }
883    function xt_factory_error($message) {
884        $this->_xt_factory_error[] = $message;
885    }
886    function xt_factory_matched() {
887        return $this->_xt_factory_match;
888    }
889    function xt_factory_errors() {
890        return $this->_xt_factory_error;
891    }
892
893
894    function _add_search_keyword_json($kwj) {
895        if (isset($kwj->name) && is_string($kwj->name))
896            return self::xt_add($this->_search_keyword_base, $kwj->name, $kwj);
897        else if (is_string($kwj->match) && is_string($kwj->expand_callback)) {
898            $this->_search_keyword_factories[] = $kwj;
899            return true;
900        } else
901            return false;
902    }
903    private function make_search_keyword_map() {
904        $this->_search_keyword_base = $this->_search_keyword_factories = [];
905        expand_json_includes_callback(["etc/searchkeywords.json"], [$this, "_add_search_keyword_json"]);
906        if (($olist = $this->opt("searchKeywords")))
907            expand_json_includes_callback($olist, [$this, "_add_search_keyword_json"]);
908        usort($this->_search_keyword_factories, "Conf::xt_priority_compare");
909    }
910    function search_keyword($keyword, Contact $user = null) {
911        if ($this->_search_keyword_base === null)
912            $this->make_search_keyword_map();
913        $checkf = function ($xt) use ($user) { return $this->xt_allowed($xt, $user); };
914        $uf = $this->xt_search_name($this->_search_keyword_base, $keyword, $checkf);
915        if (($expansions = $this->xt_search_factories($this->_search_keyword_factories, $keyword, $checkf, $uf, $user)))
916            $uf = $expansions[0];
917        return self::xt_resolve_require($uf);
918    }
919
920
921    function _add_assignment_parser_json($uf) {
922        if (isset($uf->name) && is_string($uf->name))
923            return self::xt_add($this->_assignment_parsers, $uf->name, $uf);
924        return false;
925    }
926    function assignment_parser($keyword, Contact $user = null) {
927        require_once("assignmentset.php");
928        if ($this->_assignment_parsers === null) {
929            $this->_assignment_parsers = [];
930            expand_json_includes_callback(["etc/assignmentparsers.json"], [$this, "_add_assignment_parser_json"]);
931            if (($olist = $this->opt("assignmentParsers")))
932                expand_json_includes_callback($olist, [$this, "_add_assignment_parser_json"]);
933        }
934        $checkf = function ($xt) use ($user) { return $this->xt_allowed($xt, $user); };
935        $uf = $this->xt_search_name($this->_assignment_parsers, $keyword, $checkf);
936        $uf = self::xt_resolve_require($uf);
937        if ($uf && !isset($uf->__parser)) {
938            $p = $uf->parser_class;
939            $uf->__parser = new $p($this, $uf);
940        }
941        return $uf ? $uf->__parser : null;
942    }
943
944
945    function _add_formula_function_json($fj) {
946        if (isset($fj->name) && is_string($fj->name))
947            return self::xt_add($this->_formula_functions, $fj->name, $fj);
948        return false;
949    }
950    function formula_function($fname, Contact $user) {
951        if ($this->_formula_functions === null) {
952            $this->_formula_functions = [];
953            expand_json_includes_callback(["etc/formulafunctions.json"], [$this, "_add_formula_function_json"]);
954            if (($olist = $this->opt("formulaFunctions")))
955                expand_json_includes_callback($olist, [$this, "_add_formula_function_json"]);
956        }
957        $checkf = function ($xt) use ($user) { return $this->xt_allowed($xt, $user); };
958        $uf = $this->xt_search_name($this->_formula_functions, $fname, $checkf);
959        return self::xt_resolve_require($uf);
960    }
961
962
963    function named_formulas() {
964        if ($this->_defined_formulas === null) {
965            $this->_defined_formulas = [];
966            if ($this->setting("formulas")) {
967                $result = $this->q("select * from Formula order by lower(name)");
968                while ($result && ($f = Formula::fetch($this, $result)))
969                    $this->_defined_formulas[$f->formulaId] = $f;
970                Dbl::free($result);
971            }
972        }
973        return $this->_defined_formulas;
974    }
975
976    function invalidate_named_formulas() {
977        $this->_defined_formulas = null;
978    }
979
980    function find_named_formula($text) {
981        return $this->abbrev_matcher()->find1($text, self::FSRCH_FORMULA);
982    }
983
984    function viewable_named_formulas(Contact $user, $author_only = false) {
985        return array_filter($this->named_formulas(), function ($f) use ($user, $author_only) {
986            return $user->can_view_formula($f, $author_only);
987        });
988    }
989
990
991    function decision_map() {
992        if ($this->_decisions === null) {
993            $dmap = array();
994            if (($j = get($this->settingTexts, "outcome_map"))
995                && ($j = json_decode($j, true))
996                && is_array($j))
997                $dmap = $j;
998            $dmap[0] = "Unspecified";
999            $this->_decisions = $dmap;
1000            uksort($this->_decisions, function ($ka, $kb) use ($dmap) {
1001                if ($ka == 0 || $kb == 0)
1002                    return $ka == 0 ? -1 : 1;
1003                else if (($ka > 0) !== ($kb > 0))
1004                    return $ka > 0 ? 1 : -1;
1005                else
1006                    return strcasecmp($dmap[$ka], $dmap[$kb]);
1007            });
1008        }
1009        return $this->_decisions;
1010    }
1011
1012    function decision_name($dnum) {
1013        if ($this->_decisions === null)
1014            $this->decision_map();
1015        if (($dname = get($this->_decisions, $dnum)))
1016            return $dname;
1017        else
1018            return false;
1019    }
1020
1021    static function decision_name_error($dname) {
1022        $dname = simplify_whitespace($dname);
1023        if ((string) $dname === "")
1024            return "Empty decision name.";
1025        else if (preg_match(',\A(?:yes|no|any|none|unknown|unspecified)\z,i', $dname))
1026            return "Decision name “{$dname}” is reserved.";
1027        else
1028            return false;
1029    }
1030
1031
1032
1033    function topic_map() {
1034        if ($this->_topic_map === null) {
1035            $this->_topic_map = $tx = [];
1036            $result = $this->qe_raw("select topicId, topicName from TopicArea");
1037            while (($row = edb_row($result))) {
1038                if (preg_match('{\A(?:None of |Others?(?: |\z))}', $row[1]))
1039                    $tx[(int) $row[0]] = $row[1];
1040                else
1041                    $this->_topic_map[(int) $row[0]] = $row[1];
1042            }
1043            Dbl::free($result);
1044            asort($this->_topic_map, SORT_NATURAL | SORT_FLAG_CASE);
1045            if (!empty($tx)) {
1046                asort($tx, SORT_NATURAL | SORT_FLAG_CASE);
1047                foreach ($tx as $tid => $tname)
1048                    $this->_topic_map[$tid] = $tname;
1049            }
1050        }
1051        return $this->_topic_map;
1052    }
1053
1054    function topic_order_map() {
1055        if ($this->_topic_order_map === null) {
1056            $this->_topic_order_map = [];
1057            foreach ($this->topic_map() as $tid => $tname)
1058                $this->_topic_order_map[$tid] = count($this->_topic_order_map);
1059        }
1060        return $this->_topic_order_map;
1061    }
1062
1063    function topic_abbrev_matcher() {
1064        if ($this->_topic_abbrev_matcher === null) {
1065            $this->_topic_abbrev_matcher = new AbbreviationMatcher;
1066            foreach ($this->topic_map() as $tid => $tname)
1067                $this->_topic_abbrev_matcher->add($tname, $tid);
1068        }
1069        return $this->_topic_abbrev_matcher;
1070    }
1071
1072    function has_topics() {
1073        return get($this->settings, "has_topics", 0) !== 0;
1074    }
1075
1076    function topic_count() {
1077        return count($this->topic_map());
1078    }
1079
1080    function topic_separator() {
1081        if ($this->_topic_separator_cache === null) {
1082            $this->_topic_separator_cache = ", ";
1083            foreach ($this->topic_map() as $tname)
1084                if (strpos($tname, ",") !== false) {
1085                    $this->_topic_separator_cache = "; ";
1086                    break;
1087                }
1088        }
1089        return $this->_topic_separator_cache;
1090    }
1091
1092    function invalidate_topics() {
1093        $this->_topic_map = $this->_topic_order_map = null;
1094        $this->_topic_separator_cache = $this->_topic_abbrev_matcher = null;
1095    }
1096
1097
1098    const FSRCH_OPTION = 1;
1099    const FSRCH_REVIEW = 2;
1100    const FSRCH_FORMULA = 4;
1101
1102    function abbrev_matcher() {
1103        if (!$this->_abbrev_matcher) {
1104            $this->_abbrev_matcher = new AbbreviationMatcher;
1105            $this->_abbrev_matcher->add("paper", $this->paper_opts->get(DTYPE_SUBMISSION), self::FSRCH_OPTION, 1);
1106            $this->_abbrev_matcher->add("submission", $this->paper_opts->get(DTYPE_SUBMISSION), self::FSRCH_OPTION, 1);
1107            if ($this->has_any_accepted()) {
1108                $ol = $this->paper_opts->option_list();
1109                $this->_abbrev_matcher->add("final", $this->paper_opts->get(DTYPE_FINAL), self::FSRCH_OPTION, 1);
1110            } else
1111                $ol = $this->paper_opts->nonfinal_option_list();
1112            // XXX exposes invisible paper options, review fields
1113            foreach ($ol as $o) {
1114                $this->_abbrev_matcher->add($o->name, $o, self::FSRCH_OPTION);
1115                $this->_abbrev_matcher->add("opt" . $o->id, $o, self::FSRCH_OPTION, 1);
1116            }
1117            foreach ($this->all_review_fields() as $f)
1118                if ($f->displayed)
1119                    $this->_abbrev_matcher->add($f->name, $f, self::FSRCH_REVIEW);
1120            foreach ($this->named_formulas() as $f)
1121                if ($f->name)
1122                    $this->_abbrev_matcher->add($f->name, $f, self::FSRCH_FORMULA);
1123        }
1124        return $this->_abbrev_matcher;
1125    }
1126
1127    function find_all_fields($text, $tflags = 0) {
1128        return $this->abbrev_matcher()->find_all($text, $tflags);
1129    }
1130
1131
1132    function review_form_json() {
1133        $x = get($this->settingTexts, "review_form");
1134        if (is_string($x))
1135            $x = $this->settingTexts["review_form"] = json_decode($x);
1136        return is_object($x) ? $x : null;
1137    }
1138
1139    function review_form() {
1140        if (!$this->_review_form_cache)
1141            $this->_review_form_cache = new ReviewForm($this->review_form_json(), $this);
1142        return $this->_review_form_cache;
1143    }
1144
1145    function all_review_fields() {
1146        return $this->review_form()->all_fields();
1147    }
1148
1149    function review_field($fid) {
1150        return $this->review_form()->field($fid);
1151    }
1152
1153    function find_review_field($text) {
1154        return $this->abbrev_matcher()->find1($text, self::FSRCH_REVIEW);
1155    }
1156
1157
1158
1159    function tags() {
1160        if (!$this->_taginfo)
1161            $this->_taginfo = TagMap::make($this);
1162        return $this->_taginfo;
1163    }
1164
1165
1166
1167    function has_tracks() {
1168        return $this->tracks !== null;
1169    }
1170
1171    function has_track_tags() {
1172        return $this->_track_tags !== null;
1173    }
1174
1175    function track_tags() {
1176        return $this->_track_tags ? $this->_track_tags : array();
1177    }
1178
1179    function permissive_track_tag_for(Contact $user, $perm) {
1180        foreach ($this->tracks ? : [] as $t => $tr)
1181            if (Track::match_perm($user, $tr[$perm]))
1182                return $t;
1183        return null;
1184    }
1185
1186    function check_tracks(PaperInfo $prow, Contact $contact, $ttype) {
1187        $unmatched = true;
1188        if ($this->tracks) {
1189            foreach ($this->tracks as $t => $tr)
1190                if ($t === "_" ? $unmatched : $prow->has_tag($t)) {
1191                    $unmatched = false;
1192                    if (Track::match_perm($contact, $tr[$ttype]))
1193                        return true;
1194                }
1195        }
1196        return $unmatched;
1197    }
1198
1199    function check_required_tracks(PaperInfo $prow, Contact $contact, $ttype) {
1200        if ($this->_track_sensitivity & (1 << $ttype)) {
1201            $unmatched = true;
1202            foreach ($this->tracks as $t => $tr)
1203                if ($t === "_" ? $unmatched : $prow->has_tag($t)) {
1204                    $unmatched = false;
1205                    if ($tr[$ttype] && Track::match_perm($contact, $tr[$ttype]))
1206                        return true;
1207                }
1208        }
1209        return false;
1210    }
1211
1212    function check_admin_tracks(PaperInfo $prow, Contact $contact) {
1213        return $this->check_required_tracks($prow, $contact, Track::ADMIN);
1214    }
1215
1216    function check_default_track(Contact $contact, $ttype) {
1217        return !$this->tracks || Track::match_perm($contact, $this->tracks["_"][$ttype]);
1218    }
1219
1220    function check_any_tracks(Contact $contact, $ttype) {
1221        if ($this->tracks)
1222            foreach ($this->tracks as $t => $tr)
1223                if (($ttype === Track::VIEW
1224                     || Track::match_perm($contact, $tr[Track::VIEW]))
1225                    && Track::match_perm($contact, $tr[$ttype]))
1226                    return true;
1227        return !$this->tracks;
1228    }
1229
1230    function check_any_admin_tracks(Contact $contact) {
1231        if ($this->_track_sensitivity & Track::BITS_ADMIN)
1232            foreach ($this->tracks as $t => $tr)
1233                if ($tr[Track::ADMIN] && Track::match_perm($contact, $tr[Track::ADMIN]))
1234                    return true;
1235        return false;
1236    }
1237
1238    function check_all_tracks(Contact $contact, $ttype) {
1239        if ($this->tracks)
1240            foreach ($this->tracks as $t => $tr)
1241                if (!(($ttype === Track::VIEW
1242                       || Track::match_perm($contact, $tr[Track::VIEW]))
1243                      && Track::match_perm($contact, $tr[$ttype])))
1244                    return false;
1245        return true;
1246    }
1247
1248    function check_track_sensitivity($ttype) {
1249        return ($this->_track_sensitivity & (1 << $ttype)) !== 0;
1250    }
1251
1252    function check_track_view_sensitivity() {
1253        return ($this->_track_sensitivity & Track::BITS_VIEW) !== 0;
1254    }
1255
1256    function check_track_review_sensitivity() {
1257        return ($this->_track_sensitivity & Track::BITS_REVIEW) !== 0;
1258    }
1259
1260    function track_permission($tag, $ttype) {
1261        if ($this->tracks)
1262            foreach ($this->tracks as $t => $tr)
1263                if (strcasecmp($t, $tag) == 0)
1264                    return $tr[$ttype];
1265        return null;
1266    }
1267
1268    function dangerous_track_mask(Contact $user) {
1269        $m = 0;
1270        if ($this->tracks) {
1271            foreach ($this->tracks as $t => $tr)
1272                foreach ($tr as $i => $perm)
1273                    if ($perm && $perm[0] === "-"
1274                        && !Track::match_perm($user, $perm))
1275                        $m |= 1 << $i;
1276        }
1277        return $m;
1278    }
1279
1280
1281    function has_rounds() {
1282        return count($this->rounds) > 1;
1283    }
1284
1285    function round_list() {
1286        return $this->rounds;
1287    }
1288
1289    function round0_defined() {
1290        return isset($this->defined_round_list()[0]);
1291    }
1292
1293    function defined_round_list() {
1294        if ($this->_defined_rounds === null) {
1295            $r = $dl = [];
1296            foreach ($this->rounds as $i => $rname)
1297                if (!$i || $rname !== ";") {
1298                    foreach (self::$review_deadlines as $rd)
1299                        if (($dl[$i] = get($this->settings, $rd . ($i ? "_$i" : ""))))
1300                            break;
1301                    $i && ($r[$i] = $rname);
1302                }
1303            if (!$dl[0]) {
1304                $result = $this->qe("select exists (select * from PaperReview where reviewRound=0)");
1305                if (!$result || !$result->num_rows)
1306                    unset($dl[0]);
1307                Dbl::free($result);
1308            }
1309            array_key_exists(0, $dl) && ($r[0] = "unnamed");
1310            uasort($r, function ($a, $b) use ($dl) {
1311                $adl = get($dl, $a);
1312                $bdl = get($dl, $b);
1313                if ($adl && $bdl && $adl != $bdl)
1314                    return $adl < $bdl ? -1 : 1;
1315                else if (!$adl != !$bdl)
1316                    return $adl ? -1 : 1;
1317                else
1318                    return strcmp($a !== "unnamed" ? $a : "",
1319                                  $b !== "unnamed" ? $b : "");
1320            });
1321            $this->_defined_rounds = $r;
1322        }
1323        return $this->_defined_rounds;
1324    }
1325
1326    function round_name($roundno) {
1327        if ($roundno > 0) {
1328            if (($rname = get($this->rounds, $roundno)) && $rname !== ";")
1329                return $rname;
1330            error_log($this->dbname . ": round #$roundno undefined");
1331        }
1332        return "";
1333    }
1334
1335    function round_suffix($roundno) {
1336        if ($roundno > 0) {
1337            if (($rname = get($this->rounds, $roundno)) && $rname !== ";")
1338                return "_$rname";
1339        }
1340        return "";
1341    }
1342
1343    static function round_name_error($rname) {
1344        if ((string) $rname === "")
1345            return "Empty round name.";
1346        else if (!preg_match('/\A[a-zA-Z][a-zA-Z0-9]*\z/', $rname))
1347            return "Round names must start with a letter and contain only letters and numbers.";
1348        else if (preg_match('/\A(?:none|any|all|default|unnamed|.*response|pri(?:mary)|sec(?:ondary)|opt(?:ional)|pc(?:review)|ext(?:ernal)|meta(?:review))\z/i', $rname))
1349            return "Round name $rname is reserved.";
1350        else
1351            return false;
1352    }
1353
1354    function sanitize_round_name($rname) {
1355        if ($rname === null)
1356            return $this->assignment_round_name(false);
1357        else if ($rname === "" || preg_match('/\A(?:\(none\)|none|unnamed)\z/i', $rname))
1358            return "";
1359        else if (self::round_name_error($rname))
1360            return false;
1361        else
1362            return $rname;
1363    }
1364
1365    function assignment_round_name($external) {
1366        if ($external && ($x = get($this->settingTexts, "extrev_roundtag")) !== null)
1367            return $x;
1368        else
1369            return (string) get($this->settingTexts, "rev_roundtag");
1370    }
1371
1372    function assignment_round($external) {
1373        return $this->round_number($this->assignment_round_name($external), false);
1374    }
1375
1376    function round_number($rname, $add) {
1377        if (!$rname || !strcasecmp($rname, "none") || !strcasecmp($rname, "unnamed"))
1378            return 0;
1379        for ($i = 1; $i != count($this->rounds); ++$i)
1380            if (!strcasecmp($this->rounds[$i], $rname))
1381                return $i;
1382        if ($add && !self::round_name_error($rname)) {
1383            $rtext = $this->setting_data("tag_rounds", "");
1384            $rtext = ($rtext ? "$rtext$rname " : " $rname ");
1385            $this->__save_setting("tag_rounds", 1, $rtext);
1386            $this->crosscheck_round_settings();
1387            return $this->round_number($rname, false);
1388        } else
1389            return false;
1390    }
1391
1392    function round_selector_options($isexternal) {
1393        $opt = $arounds = [];
1394        if (($isexternal === null || $isexternal === false)
1395            && ($r = $this->assignment_round_name(false)) !== null)
1396            $arounds[$r === "" ? "unnamed" : $r] = true;
1397        if (($isexternal === null || $isexternal === true)
1398            && ($r = $this->assignment_round_name(true)) !== null)
1399            $arounds[$r === "" ? "unnamed" : $r] = true;
1400        if (isset($arounds["unnamed"]))
1401            $opt["unnamed"] = "unnamed";
1402        foreach ($this->defined_round_list() as $rname)
1403            $opt[$rname] = $rname;
1404        foreach (array_keys($arounds) as $r)
1405            $opt[$r] = $r;
1406        return $opt;
1407    }
1408
1409    function round_setting($name, $round, $defval = null) {
1410        if ($this->_round_settings !== null
1411            && $round !== null
1412            && isset($this->_round_settings[$round])
1413            && isset($this->_round_settings[$round]->$name))
1414            return $this->_round_settings[$round]->$name;
1415        else
1416            return get($this->settings, $name, $defval);
1417    }
1418
1419
1420
1421    function resp_rounds() {
1422        if ($this->_resp_rounds === null) {
1423            $this->_resp_rounds = [];
1424            $x = get($this->settingTexts, "resp_rounds", "1");
1425            foreach (explode(" ", $x) as $i => $rname) {
1426                $r = new ResponseRound;
1427                $r->number = $i;
1428                $r->name = $rname;
1429                $isuf = $i ? "_$i" : "";
1430                $r->open = get($this->settings, "resp_open$isuf");
1431                $r->done = get($this->settings, "resp_done$isuf");
1432                $r->grace = get($this->settings, "resp_grace$isuf");
1433                $r->words = get($this->settings, "resp_words$isuf", 500);
1434                if (($s = get($this->settingTexts, "resp_search$isuf")))
1435                    $r->search = new PaperSearch($this->site_contact(), $s);
1436                $this->_resp_rounds[] = $r;
1437            }
1438        }
1439        return $this->_resp_rounds;
1440    }
1441
1442    function resp_round_name($rnum) {
1443        $rrd = get($this->resp_rounds(), $rnum);
1444        return $rrd ? $rrd->name : "1";
1445    }
1446
1447    function resp_round_text($rnum) {
1448        $rname = $this->resp_round_name($rnum);
1449        return $rname == "1" ? "" : $rname;
1450    }
1451
1452    static function resp_round_name_error($rname) {
1453        if ((string) $rname === "")
1454            return "Empty round name.";
1455        else if (!strcasecmp($rname, "none") || !strcasecmp($rname, "any")
1456                 || stri_ends_with($rname, "response"))
1457            return "Round name “{$rname}” is reserved.";
1458        else if (!preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $rname))
1459            return "Round names must start with a letter and contain letters and numbers.";
1460        else
1461            return false;
1462    }
1463
1464    function resp_round_number($rname) {
1465        if (!$rname || $rname === 1 || $rname === "1" || $rname === true
1466            || !strcasecmp($rname, "none"))
1467            return 0;
1468        foreach ($this->resp_rounds() as $rrd)
1469            if (!strcasecmp($rname, $rrd->name))
1470                return $rrd->number;
1471        return false;
1472    }
1473
1474
1475    function format_info($format) {
1476        if ($this->_format_info === null) {
1477            $this->_format_info = [];
1478            if (!isset($this->opt["formatInfo"]))
1479                /* OK */;
1480            else if (is_array($this->opt["formatInfo"]))
1481                $this->_format_info = $this->opt["formatInfo"];
1482            else if (is_string($this->opt["formatInfo"]))
1483                $this->_format_info = json_decode($this->opt["formatInfo"], true);
1484            foreach ($this->_format_info as $format => &$fi)
1485                $fi = new TextFormat($format, $fi);
1486        }
1487        if ($format === null)
1488            $format = $this->default_format;
1489        return get($this->_format_info, $format);
1490    }
1491
1492    function check_format($format, $text = null) {
1493        if ($format === null)
1494            $format = $this->default_format;
1495        if ($format && $text !== null && ($f = $this->format_info($format))
1496            && $f->simple_regex && preg_match($f->simple_regex, $text))
1497            $format = 0;
1498        return $format;
1499    }
1500
1501
1502    function saved_searches() {
1503        $ss = [];
1504        foreach ($this->settingTexts as $k => $v)
1505            if (substr($k, 0, 3) === "ss:" && ($v = json_decode($v)))
1506                $ss[substr($k, 3)] = $v;
1507        return $ss;
1508    }
1509
1510
1511    // users
1512
1513    function external_login() {
1514        return isset($this->opt["ldapLogin"]) || isset($this->opt["httpAuthLogin"]);
1515    }
1516
1517    function site_contact() {
1518        if (!$this->_site_contact) {
1519            $args = [
1520                "fullName" => $this->opt("contactName"),
1521                "email" => $this->opt("contactEmail"),
1522                "isChair" => true, "isPC" => true, "is_site_contact" => true,
1523                "contactTags" => null
1524            ];
1525            if (!$args["email"] || $args["email"] === "you@example.com") {
1526                $result = $this->ql("select firstName, lastName, email from ContactInfo where roles!=0 and (roles&" . (Contact::ROLE_CHAIR | Contact::ROLE_ADMIN) . ")!=0 order by (roles&" . Contact::ROLE_CHAIR . ") desc limit 1");
1527                if ($result && ($row = $result->fetch_object())) {
1528                    $this->set_opt("defaultSiteContact", true);
1529                    $this->set_opt("contactName", Text::name_text($row));
1530                    $this->set_opt("contactEmail", $row->email);
1531                    unset($args["fullName"]);
1532                    $args["email"] = $row->email;
1533                    $args["firstName"] = $row->firstName;
1534                    $args["lastName"] = $row->lastName;
1535                }
1536                Dbl::free($result);
1537            }
1538            $this->_site_contact = new Contact((object) $args, $this);
1539        }
1540        return $this->_site_contact;
1541    }
1542
1543    function user_by_id($id) {
1544        $result = $this->qe("select * from ContactInfo where contactId=?", $id);
1545        $acct = Contact::fetch($result, $this);
1546        Dbl::free($result);
1547        return $acct;
1548    }
1549
1550    function cached_user_by_id($id, $missing = false) {
1551        global $Me;
1552        if ($id && $Me && $Me->contactId == $id)
1553            return $Me;
1554        else if (isset($this->_pc_members_and_admins_cache[$id]))
1555            return $this->_pc_members_and_admins_cache[$id];
1556        else if (isset($this->_user_cache[$id]))
1557            return $this->_user_cache[$id];
1558        else if ($missing) {
1559            $this->_user_cache_missing[$id] = true;
1560            return null;
1561        } else
1562            return $this->user_by_id($id);
1563    }
1564
1565    function load_missing_cached_users() {
1566        $n = 0;
1567        if ($this->_user_cache_missing) {
1568            $result = $this->qe("select " . $this->_cached_user_query() . " from ContactInfo where contactId?a", array_keys($this->_user_cache_missing));
1569            while ($result && ($u = Contact::fetch($result, $this))) {
1570                $this->_user_cache[$u->contactId] = $u;
1571                ++$n;
1572            }
1573            Dbl::free($result);
1574            $this->_user_cache_missing = null;
1575        }
1576        return $n > 0;
1577    }
1578
1579    function user_by_email($email) {
1580        $acct = null;
1581        if (($email = trim((string) $email)) !== "") {
1582            $result = $this->qe("select * from ContactInfo where email=?", $email);
1583            $acct = Contact::fetch($result, $this);
1584            Dbl::free($result);
1585        }
1586        return $acct;
1587    }
1588
1589    function user_id_by_email($email) {
1590        $result = $this->qe("select contactId from ContactInfo where email=?", trim($email));
1591        $row = edb_row($result);
1592        Dbl::free($result);
1593        return $row ? (int) $row[0] : false;
1594    }
1595
1596    function cached_user_by_email($email) {
1597        global $Me;
1598        if ($email && $Me && strcasecmp($Me->email, $email) == 0)
1599            return $Me;
1600        else if (($u = $this->pc_member_by_email($email)))
1601            return $u;
1602        else
1603            return $this->user_by_email($email);
1604    }
1605
1606    private function _cached_user_query() {
1607        if ($this->_pc_members_fully_loaded)
1608            return "*";
1609        else
1610            return "contactId, firstName, lastName, unaccentedName, affiliation, email, roles, contactTags, disabled";
1611    }
1612
1613    function pc_members() {
1614        if ($this->_pc_members_cache === null) {
1615            $pc = array();
1616            $result = $this->q("select " . $this->_cached_user_query() . " from ContactInfo where roles!=0 and (roles&" . Contact::ROLE_PCLIKE . ")!=0");
1617            $by_name_text = array();
1618            $this->_pc_tags_cache = ["pc" => "pc"];
1619            while ($result && ($row = Contact::fetch($result, $this))) {
1620                $pc[$row->contactId] = $row;
1621                if ($row->firstName || $row->lastName) {
1622                    $name_text = Text::name_text($row);
1623                    $row2 = get($by_name_text, $name_text);
1624                    if ($row2) {
1625                        $pc1 = ($row->roles & Contact::ROLE_PC) != 0;
1626                        $pc2 = ($row2->roles & Contact::ROLE_PC) != 0;
1627                        if (!$pc1 || $pc2)
1628                            $row->nameAmbiguous = true;
1629                        if (!$pc2 || $pc1)
1630                            $row2->nameAmbiguous = true;
1631                        if (!$pc2)
1632                            $by_name_text[$name_text] = $row;
1633                    } else
1634                        $by_name_text[$name_text] = $row;
1635                }
1636                if ($row->contactTags)
1637                    foreach (explode(" ", $row->contactTags) as $t) {
1638                        list($tag, $value) = TagInfo::unpack($t);
1639                        if ($tag)
1640                            $this->_pc_tags_cache[strtolower($tag)] = $tag;
1641                    }
1642            }
1643            Dbl::free($result);
1644            uasort($pc, "Contact::compare");
1645            $this->_pc_members_and_admins_cache = $pc;
1646            $pc = array_filter($pc, function ($p) { return ($p->roles & Contact::ROLE_PC) != 0; });
1647            $order = 0;
1648            foreach ($pc as $row) {
1649                $row->sort_position = $order;
1650                ++$order;
1651            }
1652            $this->_pc_members_cache = $pc;
1653            ksort($this->_pc_tags_cache);
1654        }
1655        return $this->_pc_members_cache;
1656    }
1657
1658    function pc_members_and_admins() {
1659        if ($this->_pc_members_and_admins_cache === null)
1660            $this->pc_members();
1661        return $this->_pc_members_and_admins_cache;
1662    }
1663
1664    function full_pc_members() {
1665        if (!$this->_pc_members_fully_loaded) {
1666            if ($this->_pc_members_cache !== null) {
1667                $result = $this->q("select * from ContactInfo where roles!=0 and (roles&" . Contact::ROLE_PCLIKE . ")!=0");
1668                while ($result && ($row = $result->fetch_object())) {
1669                    if (($pc = get($this->_pc_members_and_admins_cache, $row->contactId)))
1670                        $pc->merge_secondary_properties($row);
1671                }
1672                Dbl::free($result);
1673            }
1674            $this->_user_cache = null;
1675            $this->_pc_members_fully_loaded = true;
1676        }
1677        return $this->pc_members();
1678    }
1679
1680    function pc_member_by_id($cid) {
1681        return get($this->pc_members(), $cid);
1682    }
1683
1684    function pc_member_by_email($email) {
1685        foreach ($this->pc_members() as $p)
1686            if (strcasecmp($p->email, $email) == 0)
1687                return $p;
1688        return null;
1689    }
1690
1691    function pc_tags() {
1692        if ($this->_pc_tags_cache === null)
1693            $this->pc_members();
1694        return $this->_pc_tags_cache;
1695    }
1696
1697    function pc_tag_exists($tag) {
1698        if ($this->_pc_tags_cache === null)
1699            $this->pc_members();
1700        return isset($this->_pc_tags_cache[strtolower($tag)]);
1701    }
1702
1703    function pc_completion_map() {
1704        $map = $bylevel = [];
1705        foreach ($this->pc_members_and_admins() as $pc)
1706            if (!$pc->disabled) {
1707                foreach ($pc->completion_items() as $k => $level) {
1708                    if (!isset($bylevel[$k])
1709                        || $bylevel[$k] < $level
1710                        || get($map, $k) === $pc) {
1711                        $map[$k] = $pc;
1712                        $bylevel[$k] = $level;
1713                    } else
1714                        unset($map[$k]);
1715                }
1716            }
1717        return $map;
1718    }
1719
1720
1721    // contactdb
1722
1723    function contactdb() {
1724        if ($this->_cdb === false) {
1725            $this->_cdb = null;
1726            if (($dsn = $this->opt("contactdb_dsn")))
1727                list($this->_cdb, $dbname) = Dbl::connect_dsn($dsn);
1728        }
1729        return $this->_cdb;
1730    }
1731
1732    private function contactdb_user_by_key($key, $value) {
1733        if (($cdb = $this->contactdb())) {
1734            $q = "select ContactInfo.*, roles, activity_at";
1735            $qv = [];
1736            if (($confid = $this->opt("contactdb_confid"))) {
1737                $q .= ", ? confid from ContactInfo left join Roles on (Roles.contactDbId=ContactInfo.contactDbId and Roles.confid=?)";
1738                array_push($qv, $confid, $confid);
1739            } else {
1740                $q .= ", Conferences.confid from ContactInfo left join Conferences on (Conferences.`dbname`=?) left join Roles on (Roles.contactDbId=ContactInfo.contactDbId and Roles.confid=Conferences.confid)";
1741                $qv[] = $this->dbname;
1742            }
1743            $qv[] = $value;
1744            $result = Dbl::ql_apply($cdb, "$q where ContactInfo.$key=?", $qv);
1745            $acct = Contact::fetch($result, $this);
1746            Dbl::free($result);
1747            return $acct;
1748        } else
1749            return null;
1750    }
1751
1752    function contactdb_user_by_email($email) {
1753        return $this->contactdb_user_by_key("email", $email);
1754    }
1755
1756    function contactdb_user_by_id($id) {
1757        return $this->contactdb_user_by_key("contactDbId", $id);
1758    }
1759
1760
1761    // session data
1762
1763    function session($name, $defval = null) {
1764        if (isset($_SESSION[$this->dsn])
1765            && isset($_SESSION[$this->dsn][$name]))
1766            return $_SESSION[$this->dsn][$name];
1767        else
1768            return $defval;
1769    }
1770
1771    function save_session($name, $value) {
1772        if ($value !== null) {
1773            if (empty($_SESSION))
1774                ensure_session();
1775            $_SESSION[$this->dsn][$name] = $value;
1776        } else if (isset($_SESSION[$this->dsn])) {
1777            unset($_SESSION[$this->dsn][$name]);
1778            if (empty($_SESSION[$this->dsn]))
1779                unset($_SESSION[$this->dsn]);
1780        }
1781    }
1782
1783    function capability_text($prow, $capType) {
1784        // A capability has the following representation (. is concatenation):
1785        //    capFormat . paperId . capType . hashPrefix
1786        // capFormat -- Character denoting format (currently 0).
1787        // paperId -- Decimal representation of paper number.
1788        // capType -- Capability type (e.g. "a" for author view).
1789        // To create hashPrefix, calculate a SHA-1 hash of:
1790        //    capFormat . paperId . capType . paperCapVersion . capKey
1791        // where paperCapVersion is a decimal representation of the paper's
1792        // capability version (usually 0, but could allow conference admins
1793        // to disable old capabilities paper-by-paper), and capKey
1794        // is a random string specific to the conference, stored in Settings
1795        // under cap_key (created in load_settings).  Then hashPrefix
1796        // is the base-64 encoding of the first 8 bytes of this hash, except
1797        // that "+" is re-encoded as "-", "/" is re-encoded as "_", and
1798        // trailing "="s are removed.
1799        //
1800        // Any user who knows the conference's cap_key can construct any
1801        // capability for any paper.  Longer term, one might set each paper's
1802        // capVersion to a random value; but the only way to get cap_key is
1803        // database access, which would give you all the capVersions anyway.
1804
1805        if (!isset($this->settingTexts["cap_key"]))
1806            return false;
1807        $start = "0" . $prow->paperId . $capType;
1808        $hash = sha1($start . $prow->capVersion . $this->settingTexts["cap_key"], true);
1809        $suffix = str_replace(array("+", "/", "="), array("-", "_", ""),
1810                              base64_encode(substr($hash, 0, 8)));
1811        return $start . $suffix;
1812    }
1813
1814
1815    // update the 'papersub' setting: are there any submitted papers?
1816    function update_papersub_setting($adding) {
1817        if ($this->setting("no_papersub", 0) > 0 ? $adding >= 0 : $adding <= 0) {
1818            $this->qe("delete from Settings where name='no_papersub'");
1819            $this->qe("insert into Settings (name, value) select 'no_papersub', 1 from dual where exists (select * from Paper where timeSubmitted>0) = 0");
1820            $this->settings["no_papersub"] = $this->fetch_ivalue("select value from Settings where name='no_papersub'");
1821        }
1822    }
1823
1824    function update_paperacc_setting($adding) {
1825        if ($this->setting("paperacc", 0) <= 0 ? $adding >= 0 : $adding <= 0) {
1826            $this->qe_raw("insert into Settings (name, value) select 'paperacc', exists (select * from Paper where outcome>0) on duplicate key update value=values(value)");
1827            $this->settings["paperacc"] = $this->fetch_ivalue("select value from Settings where name='paperacc'");
1828        }
1829    }
1830
1831    function update_rev_tokens_setting($adding) {
1832        if ($this->setting("rev_tokens", 0) === -1)
1833            $adding = 0;
1834        if ($this->setting("rev_tokens", 0) <= 0 ? $adding >= 0 : $adding <= 0) {
1835            $this->qe_raw("insert into Settings (name, value) select 'rev_tokens', exists (select * from PaperReview where reviewToken!=0) on duplicate key update value=values(value)");
1836            $this->settings["rev_tokens"] = $this->fetch_ivalue("select value from Settings where name='rev_tokens'");
1837        }
1838    }
1839
1840    function update_paperlead_setting($adding) {
1841        if ($this->setting("paperlead", 0) <= 0 ? $adding >= 0 : $adding <= 0) {
1842            $this->qe_raw("insert into Settings (name, value) select 'paperlead', exists (select * from Paper where leadContactId>0 or shepherdContactId>0) on duplicate key update value=values(value)");
1843            $this->settings["paperlead"] = $this->fetch_ivalue("select value from Settings where name='paperlead'");
1844        }
1845    }
1846
1847    function update_papermanager_setting($adding) {
1848        if ($this->setting("papermanager", 0) <= 0 ? $adding >= 0 : $adding <= 0) {
1849            $this->qe_raw("insert into Settings (name, value) select 'papermanager', exists (select * from Paper where managerContactId>0) on duplicate key update value=values(value)");
1850            $this->settings["papermanager"] = $this->fetch_ivalue("select value from Settings where name='papermanager'");
1851        }
1852    }
1853
1854    function update_metareviews_setting($adding) {
1855        if ($this->setting("metareviews", 0) <= 0 ? $adding >= 0 : $adding <= 0) {
1856            $this->qe_raw("insert into Settings (name, value) select 'metareviews', exists (select * from PaperReview where reviewType=" . REVIEW_META . ") on duplicate key update value=values(value)");
1857            $this->settings["metareviews"] = $this->fetch_ivalue("select value from Settings where name='metareviews'");
1858        }
1859    }
1860
1861    function update_autosearch_tags($paper = null) {
1862        if ((!$this->setting("tag_autosearch") && !$this->opt("definedTags"))
1863            || !$this->tags()->has_autosearch
1864            || $this->_updating_autosearch_tags)
1865            return;
1866        $csv = ["paper,tag"];
1867        if (!$paper) {
1868            foreach ($this->tags()->filter("autosearch") as $dt) {
1869                $csv[] = CsvGenerator::quote("#{$dt->tag}") . "," . CsvGenerator::quote("{$dt->tag}#clear");
1870                $csv[] = CsvGenerator::quote($dt->autosearch) . "," . CsvGenerator::quote($dt->tag);
1871            }
1872        } else {
1873            if (is_object($paper))
1874                $paper = $paper->paperId;
1875            $rowset = $this->paper_set(null, ["paperId" => $paper]);
1876            foreach ($this->tags()->filter("autosearch") as $dt) {
1877                $search = new PaperSearch($this->site_contact(), ["q" => $dt->autosearch, "t" => "all"]);
1878                foreach ($rowset as $prow) {
1879                    $want = $search->test($prow);
1880                    if ($prow->has_tag($dt->tag) !== $want)
1881                        $csv[] = "{$prow->paperId}," . CsvGenerator::quote($dt->tag . ($want ? "" : "#clear"));
1882                }
1883            }
1884        }
1885        $this->_update_autosearch_tags_csv($csv);
1886    }
1887
1888    function _update_autosearch_tags_csv($csv) {
1889        if (count($csv) > 1) {
1890            $this->_updating_autosearch_tags = true;
1891            $aset = new AssignmentSet($this->site_contact(), true);
1892            $aset->set_search_type("all");
1893            $aset->parse($csv);
1894            $aset->execute();
1895            $this->_updating_autosearch_tags = false;
1896        }
1897    }
1898
1899
1900    static private $invariant_row = null;
1901
1902    private function invariantq($q, $args = []) {
1903        $result = $this->ql_apply($q, $args);
1904        if ($result) {
1905            self::$invariant_row = $result->fetch_row();
1906            $result->close();
1907            return !!self::$invariant_row;
1908        } else
1909            return null;
1910    }
1911
1912    function check_invariants() {
1913        $any = $this->invariantq("select paperId from Paper where timeSubmitted>0 limit 1");
1914        if ($any !== !get($this->settings, "no_papersub"))
1915            trigger_error("$this->dbname invariant error: no_papersub");
1916
1917        $any = $this->invariantq("select paperId from Paper where outcome>0 and timeSubmitted>0 limit 1");
1918        if ($any !== !!get($this->settings, "paperacc"))
1919            trigger_error("$this->dbname invariant error: paperacc");
1920
1921        $any = $this->invariantq("select reviewId from PaperReview where reviewToken!=0 limit 1");
1922        if ($any !== !!get($this->settings, "rev_tokens"))
1923            trigger_error("$this->dbname invariant error: rev_tokens");
1924
1925        $any = $this->invariantq("select paperId from Paper where leadContactId>0 or shepherdContactId>0 limit 1");
1926        if ($any !== !!get($this->settings, "paperlead"))
1927            trigger_error("$this->dbname invariant error: paperlead");
1928
1929        $any = $this->invariantq("select paperId from Paper where managerContactId>0 limit 1");
1930        if ($any !== !!get($this->settings, "papermanager"))
1931            trigger_error("$this->dbname invariant error: papermanager");
1932
1933        $any = $this->invariantq("select paperId from PaperReview where reviewType=" . REVIEW_META . " limit 1");
1934        if ($any !== !!get($this->settings, "metareviews"))
1935            trigger_error("$this->dbname invariant error: metareviews");
1936
1937        // no empty text options
1938        $text_options = array();
1939        foreach ($this->paper_opts->option_list() as $ox)
1940            if ($ox->type === "text")
1941                $text_options[] = $ox->id;
1942        if (count($text_options)) {
1943            $any = $this->invariantq("select paperId from PaperOption where optionId?a and data='' limit 1", [$text_options]);
1944            if ($any)
1945                trigger_error("$this->dbname invariant error: text option with empty text");
1946        }
1947
1948        // no funky PaperConflict entries
1949        $any = $this->invariantq("select paperId from PaperConflict where conflictType<=0 limit 1");
1950        if ($any)
1951            trigger_error("$this->dbname invariant error: PaperConflict with zero conflictType");
1952
1953        // reviewNeedsSubmit is defined correctly
1954        $any = $this->invariantq("select r.paperId, r.reviewId from PaperReview r
1955            left join (select paperId, requestedBy, count(reviewId) ct, count(reviewSubmitted) cs
1956                       from PaperReview where reviewType<" . REVIEW_SECONDARY . "
1957                       group by paperId, requestedBy) q
1958                on (q.paperId=r.paperId and q.requestedBy=r.contactId)
1959            where r.reviewType=" . REVIEW_SECONDARY . " and reviewSubmitted is null
1960            and if(coalesce(q.ct,0)=0,1,if(q.cs=0,-1,0))!=r.reviewNeedsSubmit
1961            limit 1");
1962        if ($any)
1963            trigger_error("$this->dbname invariant error: bad reviewNeedsSubmit for review #" . self::$invariant_row[0] . "/" . self::$invariant_row[1]);
1964
1965        // anonymous users are disabled
1966        $any = $this->invariantq("select email from ContactInfo where email regexp '^anonymous[0-9]*\$' and not disabled limit 1");
1967        if ($any)
1968            trigger_error("$this->dbname invariant error: anonymous user is not disabled");
1969
1970        // paper denormalizations match
1971        $any = $this->invariantq("select p.paperId from Paper p join PaperStorage ps on (ps.paperStorageId=p.paperStorageId) where p.finalPaperStorageId<=0 and p.paperStorageId>1 and (p.sha1!=ps.sha1 or p.size!=ps.size or p.mimetype!=ps.mimetype or p.timestamp!=ps.timestamp) limit 1");
1972        if ($any)
1973            trigger_error("$this->dbname invariant error: bad Paper denormalization, paper #" . self::$invariant_row[0]);
1974        $any = $this->invariantq("select p.paperId from Paper p join PaperStorage ps on (ps.paperStorageId=p.finalPaperStorageId) where p.finalPaperStorageId>1 and (p.sha1 != ps.sha1 or p.size!=ps.size or p.mimetype!=ps.mimetype or p.timestamp!=ps.timestamp) limit 1");
1975        if ($any)
1976            trigger_error("$this->dbname invariant error: bad Paper final denormalization, paper #" . self::$invariant_row[0]);
1977
1978        // filterType is never zero
1979        $any = $this->invariantq("select paperStorageId from PaperStorage where filterType=0 limit 1");
1980        if ($any)
1981            trigger_error("$this->dbname invariant error: bad PaperStorage filterType, id #" . self::$invariant_row[0]);
1982
1983        // has_colontag is defined
1984        $any = $this->invariantq("select tag from PaperTag where tag like '%:' limit 1");
1985        if ($any && !$this->setting("has_colontag"))
1986            trigger_error("$this->dbname invariant error: has tag " . self::$invariant_row[0] . " but no has_colontag");
1987
1988        // has_topics is defined
1989        $any = $this->invariantq("select topicId from TopicArea limit 1");
1990        if (!$any !== !$this->setting("has_topics"))
1991            trigger_error("$this->dbname invariant error: has_topics setting incorrect");
1992
1993        $this->check_document_inactive_invariants();
1994    }
1995
1996    function check_document_inactive_invariants() {
1997        $result = $this->ql("select paperStorageId, finalPaperStorageId from Paper");
1998        $pids = [];
1999        while ($result && ($row = $result->fetch_row())) {
2000            if ($row[0] > 1)
2001                $pids[] = (int) $row[0];
2002            if ($row[1] > 1)
2003                $pids[] = (int) $row[1];
2004        }
2005        sort($pids);
2006        $any = $this->invariantq("select s.paperId, s.paperStorageId from PaperStorage s where s.paperStorageId?a and s.inactive limit 1", [$pids]);
2007        if ($any)
2008            trigger_error("$this->dbname invariant error: paper " . self::$invariant_row[0] . " document " . self::$invariant_row[1] . " is inappropriately inactive");
2009
2010        $oids = [];
2011        $nonempty_oids = [];
2012        foreach ($this->paper_opts->full_option_list() as $o)
2013            if ($o->has_document()) {
2014                $oids[] = $o->id;
2015                if (!$o->allow_empty_document())
2016                    $nonempty_oids[] = $o->id;
2017            }
2018
2019        if (!empty($oids)) {
2020            $any = $this->invariantq("select o.paperId, o.optionId, s.paperStorageId from PaperOption o join PaperStorage s on (s.paperStorageId=o.value and s.inactive and s.paperStorageId>1) where o.optionId?a limit 1", [$oids]);
2021            if ($any)
2022                trigger_error("$this->dbname invariant error: paper " . self::$invariant_row[0] . " option " . self::$invariant_row[1] . " document " . self::$invariant_row[2] . " is inappropriately inactive");
2023
2024            $any = $this->invariantq("select o.paperId, o.optionId, s.paperId from PaperOption o join PaperStorage s on (s.paperStorageId=o.value and s.paperStorageId>1 and s.paperId!=o.paperId) where o.optionId?a limit 1", [$oids]);
2025            if ($any)
2026                trigger_error("$this->dbname invariant error: paper " . self::$invariant_row[0] . " option " . self::$invariant_row[1] . " document belongs to different paper " . self::$invariant_row[2]);
2027        }
2028
2029        if (!empty($nonempty_oids)) {
2030            $any = $this->invariantq("select o.paperId, o.optionId from PaperOption o where o.optionId?a and o.value<=1 limit 1", [$nonempty_oids]);
2031            if ($any)
2032                trigger_error("$this->dbname invariant error: paper " . self::$invariant_row[0] . " option " . self::$invariant_row[1] . " links to empty document");
2033        }
2034
2035        $any = $this->invariantq("select l.paperId, l.linkId, s.paperStorageId from DocumentLink l join PaperStorage s on (l.documentId=s.paperStorageId and s.inactive) limit 1");
2036        if ($any)
2037            trigger_error("$this->dbname invariant error: paper " . self::$invariant_row[0] . " link " . self::$invariant_row[1] . " document " . self::$invariant_row[2] . " is inappropriately inactive");
2038    }
2039
2040
2041    private function __save_setting($name, $value, $data = null) {
2042        $change = false;
2043        if ($value === null && $data === null) {
2044            if ($this->qe("delete from Settings where name=?", $name)) {
2045                unset($this->settings[$name]);
2046                unset($this->settingTexts[$name]);
2047                $change = true;
2048            }
2049        } else {
2050            $dval = $data;
2051            if (is_array($dval) || is_object($dval))
2052                $dval = json_encode_db($dval);
2053            if ($this->qe("insert into Settings (name, value, data) values (?, ?, ?) on duplicate key update value=values(value), data=values(data)", $name, (int) $value, $dval)) {
2054                $this->settings[$name] = $value;
2055                $this->settingTexts[$name] = $data;
2056                $change = true;
2057            }
2058        }
2059        if ($change && str_starts_with($name, "opt.")) {
2060            $oname = substr($name, 4);
2061            if ($value === null && $data === null)
2062                $this->opt[$oname] = get($this->opt_override, $oname);
2063            else
2064                $this->opt[$oname] = $data === null ? $value : $data;
2065        }
2066        return $change;
2067    }
2068
2069    function save_setting($name, $value, $data = null) {
2070        $change = $this->__save_setting($name, $value, $data);
2071        if ($change) {
2072            $this->crosscheck_settings();
2073            if (str_starts_with($name, "opt."))
2074                $this->crosscheck_options();
2075            if (str_starts_with($name, "tag_") || $name === "tracks")
2076                $this->invalidate_caches(["taginfo" => true, "tracks" => true]);
2077        }
2078        return $change;
2079    }
2080
2081    function update_schema_version($n) {
2082        if (!$n)
2083            $n = $this->fetch_ivalue("select value from Settings where name='allowPaperOption'");
2084        if ($n && $this->ql("update Settings set value=? where name='allowPaperOption'", $n)) {
2085            $this->sversion = $this->settings["allowPaperOption"] = $n;
2086            return true;
2087        } else
2088            return false;
2089    }
2090
2091    function invalidate_caches($caches = null) {
2092        if (!self::$no_invalidate_caches) {
2093            if (is_string($caches))
2094                $caches = [$caches => true];
2095            if (!$caches || isset($caches["pc"]))
2096                $this->_pc_members_cache = $this->_pc_tags_cache = $this->_pc_members_and_admins_cache = $this->_user_cache = null;
2097            if (!$caches || isset($caches["options"])) {
2098                $this->paper_opts->invalidate_option_list();
2099                $this->_formatspec_cache = [];
2100                $this->_abbrev_matcher = null;
2101            }
2102            if (!$caches || isset($caches["rf"])) {
2103                $this->_review_form_cache = $this->_defined_rounds = null;
2104                $this->_abbrev_matcher = null;
2105            }
2106            if (!$caches || isset($caches["taginfo"]) || isset($caches["tracks"]))
2107                $this->_taginfo = null;
2108            if (!$caches || isset($caches["formulas"]))
2109                $this->_formula_functions = null;
2110            if (!$caches || isset($caches["assigners"]))
2111                $this->_assignment_parsers = null;
2112            if (!$caches || isset($caches["tracks"]))
2113                Contact::update_rights();
2114        }
2115    }
2116
2117
2118    // times
2119
2120    private function _dateFormat($type) {
2121        if (!$this->_date_format_initialized) {
2122            if (!isset($this->opt["time24hour"]) && isset($this->opt["time24Hour"]))
2123                $this->opt["time24hour"] = $this->opt["time24Hour"];
2124            if (!isset($this->opt["dateFormatLong"]) && isset($this->opt["dateFormat"]))
2125                $this->opt["dateFormatLong"] = $this->opt["dateFormat"];
2126            if (!isset($this->opt["dateFormat"]))
2127                $this->opt["dateFormat"] = get($this->opt, "time24hour") ? "j M Y H:i:s" : "j M Y g:i:sa";
2128            if (!isset($this->opt["dateFormatLong"]))
2129                $this->opt["dateFormatLong"] = "l " . $this->opt["dateFormat"];
2130            if (!isset($this->opt["dateFormatObscure"]))
2131                $this->opt["dateFormatObscure"] = "j M Y";
2132            if (!isset($this->opt["timestampFormat"]))
2133                $this->opt["timestampFormat"] = $this->opt["dateFormat"];
2134            if (!isset($this->opt["dateFormatSimplifier"]))
2135                $this->opt["dateFormatSimplifier"] = get($this->opt, "time24hour") ? "/:00(?!:)/" : "/:00(?::00|)(?= ?[ap]m)/";
2136            if (!isset($this->opt["dateFormatTimezone"]))
2137                $this->opt["dateFormatTimezone"] = null;
2138            $this->_date_format_initialized = true;
2139        }
2140        if ($type == "timestamp")
2141            return $this->opt["timestampFormat"];
2142        else if ($type == "obscure")
2143            return $this->opt["dateFormatObscure"];
2144        else if ($type)
2145            return $this->opt["dateFormatLong"];
2146        else
2147            return $this->opt["dateFormat"];
2148    }
2149    private function _unparse_timezone($value) {
2150        $z = $this->opt["dateFormatTimezone"];
2151        if ($z === null) {
2152            $z = date("T", $value);
2153            if ($z === "-12")
2154                $z = "AoE";
2155            else if ($z && ($z[0] === "+" || $z[0] === "-"))
2156                $z = "UTC" . $z;
2157        }
2158        return $z;
2159    }
2160
2161    function parseableTime($value, $include_zone) {
2162        $f = $this->_dateFormat(false);
2163        $d = date($f, $value);
2164        if ($this->opt["dateFormatSimplifier"])
2165            $d = preg_replace($this->opt["dateFormatSimplifier"], "", $d);
2166        if ($include_zone && ($z = $this->_unparse_timezone($value)))
2167            $d .= " $z";
2168        return $d;
2169    }
2170    function parse_time($d, $reference = null) {
2171        global $Now;
2172        if ($reference === null)
2173            $reference = $Now;
2174        if (!isset($this->opt["dateFormatTimezoneRemover"])) {
2175            $x = array();
2176            if (function_exists("timezone_abbreviations_list")) {
2177                $mytz = date_default_timezone_get();
2178                foreach (timezone_abbreviations_list() as $tzname => $tzinfo) {
2179                    foreach ($tzinfo as $tz)
2180                        if ($tz["timezone_id"] == $mytz)
2181                            $x[] = preg_quote($tzname);
2182                }
2183            }
2184            if (empty($x)) {
2185                $z = date("T", $reference);
2186                if ($z === "-12")
2187                    $x[] = "AoE";
2188                $x[] = preg_quote($z);
2189            }
2190            $this->opt["dateFormatTimezoneRemover"] =
2191                "/(?:\\s|\\A)(?:" . join("|", $x) . ")(?:\\s|\\z)/i";
2192        }
2193        if ($this->opt["dateFormatTimezoneRemover"])
2194            $d = preg_replace($this->opt["dateFormatTimezoneRemover"], " ", $d);
2195        $d = preg_replace_callback('/\b(utc(?=[-+])|aoe(?=\s|\z))/i', function ($m) {
2196            return strcasecmp($m[1], "aoe") === 0 ? "GMT-1200" : "GMT";
2197        }, $d);
2198        return strtotime($d, $reference);
2199    }
2200
2201    function _printableTime($value, $type, $useradjust, $preadjust = null) {
2202        if ($value <= 0)
2203            return "N/A";
2204        $t = date($this->_dateFormat($type), $value);
2205        if ($this->opt["dateFormatSimplifier"])
2206            $t = preg_replace($this->opt["dateFormatSimplifier"], "", $t);
2207        if ($type !== "obscure" && ($z = $this->_unparse_timezone($value)))
2208            $t .= " $z";
2209        if ($preadjust)
2210            $t .= $preadjust;
2211        if ($useradjust) {
2212            $sp = strpos($useradjust, " ");
2213            $t .= "<$useradjust class=\"usertime hidden\" id=\"usertime$this->usertimeId\"></" . ($sp ? substr($useradjust, 0, $sp) : $useradjust) . ">";
2214            Ht::stash_script("setLocalTime('usertime$this->usertimeId',$value)");
2215            ++$this->usertimeId;
2216        }
2217        return $t;
2218    }
2219    function printableTime($value, $useradjust = false, $preadjust = null) {
2220        return $this->_printableTime($value, true, $useradjust, $preadjust);
2221    }
2222    function obscure_time($timestamp) {
2223        if ($timestamp !== null)
2224            $timestamp = (int) ($timestamp + 0.5);
2225        if ($timestamp > 0) {
2226            $offset = 0;
2227            if (($zone = timezone_open(date_default_timezone_get())))
2228                $offset = $zone->getOffset(new DateTime("@$timestamp"));
2229            $timestamp += 43200 - ($timestamp + $offset) % 86400;
2230        }
2231        return $timestamp;
2232    }
2233    function unparse_time_short($value) {
2234        return $this->_printableTime($value, false, false, null);
2235    }
2236    function unparse_time_full($value) {
2237        return $this->_printableTime($value, "timestamp", false, null);
2238    }
2239    function unparse_time_obscure($value) {
2240        return $this->_printableTime($value, "obscure", false, null);
2241    }
2242    function unparse_time_log($value) {
2243        return date("d/M/Y:H:i:s O", $value);
2244    }
2245
2246    function printableTimeSetting($what, $useradjust = false, $preadjust = null) {
2247        return $this->printableTime(defval($this->settings, $what, 0), $useradjust, $preadjust);
2248    }
2249    function printableDeadlineSetting($what, $useradjust = false, $preadjust = null) {
2250        if (!isset($this->settings[$what]) || $this->settings[$what] <= 0)
2251            return "No deadline";
2252        else
2253            return "Deadline: " . $this->printableTime($this->settings[$what], $useradjust, $preadjust);
2254    }
2255
2256    function settingsAfter($name) {
2257        global $Now;
2258        $t = get($this->settings, $name);
2259        return $t !== null && $t > 0 && $t <= $Now;
2260    }
2261    function deadlinesAfter($name, $grace = null) {
2262        global $Now;
2263        $t = get($this->settings, $name);
2264        if ($t !== null && $t > 0 && $grace && ($g = get($this->settings, $grace)))
2265            $t += $g;
2266        return $t !== null && $t > 0 && $t <= $Now;
2267    }
2268    function deadlinesBetween($name1, $name2, $grace = null) {
2269        // see also ResponseRound::time_allowed
2270        global $Now;
2271        $t = get($this->settings, $name1);
2272        if (($t === null || $t <= 0 || $t > $Now) && $name1)
2273            return false;
2274        $t = get($this->settings, $name2);
2275        if ($t !== null && $t > 0 && $grace && ($g = get($this->settings, $grace)))
2276            $t += $g;
2277        return $t === null || $t <= 0 || $t >= $Now;
2278    }
2279
2280    function timeStartPaper() {
2281        return $this->deadlinesBetween("sub_open", "sub_reg", "sub_grace");
2282    }
2283    function timeUpdatePaper($prow = null) {
2284        return $this->deadlinesBetween("sub_open", "sub_update", "sub_grace")
2285            && (!$prow || $prow->timeSubmitted <= 0 || $this->setting("sub_freeze") <= 0);
2286    }
2287    function timeFinalizePaper($prow = null) {
2288        return $this->deadlinesBetween("sub_open", "sub_sub", "sub_grace")
2289            && (!$prow || $prow->timeSubmitted <= 0 || $this->setting('sub_freeze') <= 0);
2290    }
2291    function collectFinalPapers() {
2292        return $this->setting("final_open") > 0;
2293    }
2294    function time_submit_final_version() {
2295        return $this->deadlinesBetween("final_open", "final_done", "final_grace");
2296    }
2297    function can_some_author_view_review($reviewsOutstanding = false) {
2298        return $this->any_response_open
2299            || ($this->au_seerev > 0
2300                && ($this->au_seerev != self::AUSEEREV_UNLESSINCOMPLETE
2301                    || !$reviewsOutstanding));
2302    }
2303    private function time_author_respond_all_rounds() {
2304        $allowed = [];
2305        foreach ($this->resp_rounds() as $rrd)
2306            if ($rrd->time_allowed(true))
2307                $allowed[$rrd->number] = $rrd->name;
2308        return $allowed;
2309    }
2310    function time_author_respond($round = null) {
2311        if (!$this->any_response_open)
2312            return $round === null ? [] : false;
2313        else if ($round === null)
2314            return $this->time_author_respond_all_rounds();
2315        else {
2316            $rrd = get($this->resp_rounds(), $round);
2317            return $rrd && $rrd->time_allowed(true);
2318        }
2319    }
2320    function can_all_author_view_decision() {
2321        return $this->setting("seedec") == self::SEEDEC_ALL;
2322    }
2323    function can_some_author_view_decision() {
2324        return $this->setting("seedec") == self::SEEDEC_ALL;
2325    }
2326    function time_review_open() {
2327        global $Now;
2328        $rev_open = +get($this->settings, "rev_open");
2329        return 0 < $rev_open && $rev_open <= $Now;
2330    }
2331    function review_deadline($round, $isPC, $hard) {
2332        $dn = ($isPC ? "pcrev_" : "extrev_") . ($hard ? "hard" : "soft");
2333        if ($round === null)
2334            $round = $this->assignment_round(!$isPC);
2335        else if (is_object($round))
2336            $round = $round->reviewRound ? : 0;
2337        if ($round && isset($this->settings["{$dn}_$round"]))
2338            $dn .= "_$round";
2339        return $dn;
2340    }
2341    function missed_review_deadline($round, $isPC, $hard) {
2342        global $Now;
2343        $rev_open = +get($this->settings, "rev_open");
2344        if (!(0 < $rev_open && $rev_open <= $Now))
2345            return "rev_open";
2346        $dn = $this->review_deadline($round, $isPC, $hard);
2347        $dv = +get($this->settings, $dn);
2348        if ($dv > 0 && $dv < $Now)
2349            return $dn;
2350        return false;
2351    }
2352    function time_review($round, $isPC, $hard) {
2353        return !$this->missed_review_deadline($round, $isPC, $hard);
2354    }
2355    function timePCReviewPreferences() {
2356        return $this->can_pc_see_all_submissions() || $this->has_any_submitted();
2357    }
2358    function timePCViewDecision($conflicted) {
2359        $s = $this->setting("seedec");
2360        if ($conflicted)
2361            return $s == self::SEEDEC_ALL || $s == self::SEEDEC_REV;
2362        else
2363            return $s >= self::SEEDEC_REV;
2364    }
2365    function time_reviewer_view_decision() {
2366        return $this->setting("seedec") >= self::SEEDEC_REV;
2367    }
2368    function time_reviewer_view_accepted_authors() {
2369        return $this->setting("seedec") == self::SEEDEC_ALL;
2370    }
2371    function timePCViewPaper($prow, $pdf) {
2372        if ($prow->timeWithdrawn > 0)
2373            return false;
2374        else if ($prow->timeSubmitted > 0)
2375            return !$pdf || $this->_pc_see_pdf;
2376        else
2377            return !$pdf && $this->can_pc_see_all_submissions();
2378    }
2379
2380    function submission_blindness() {
2381        return $this->settings["sub_blind"];
2382    }
2383    function subBlindAlways() {
2384        return $this->settings["sub_blind"] == self::BLIND_ALWAYS;
2385    }
2386    function subBlindNever() {
2387        return $this->settings["sub_blind"] == self::BLIND_NEVER;
2388    }
2389    function subBlindOptional() {
2390        return $this->settings["sub_blind"] == self::BLIND_OPTIONAL;
2391    }
2392    function subBlindUntilReview() {
2393        return $this->settings["sub_blind"] == self::BLIND_UNTILREVIEW;
2394    }
2395
2396    function is_review_blind($rrow) {
2397        $rb = $this->settings["rev_blind"];
2398        if ($rb == self::BLIND_ALWAYS)
2399            return true;
2400        else if ($rb != self::BLIND_OPTIONAL)
2401            return false;
2402        if (is_object($rrow))
2403            $rrow = (bool) $rrow->reviewBlind;
2404        return $rrow === null || $rrow;
2405    }
2406    function review_blindness() {
2407        return $this->settings["rev_blind"];
2408    }
2409    function can_some_external_reviewer_view_comment() {
2410        return $this->settings["extrev_view"] == 2;
2411    }
2412
2413    function has_any_submitted() {
2414        return !get($this->settings, "no_papersub");
2415    }
2416    function has_any_pc_visible_pdf() {
2417        return $this->has_any_submitted() && $this->_pc_see_pdf;
2418    }
2419    function has_any_accepted() {
2420        return !!get($this->settings, "paperacc");
2421    }
2422
2423    function count_submitted_accepted() {
2424        $dlt = max($this->setting("sub_sub"), $this->setting("sub_close"));
2425        $result = $this->qe("select outcome, count(paperId) from Paper where timeSubmitted>0 " . ($dlt ? "or (timeSubmitted=-100 and timeWithdrawn>=$dlt) " : "") . "group by outcome");
2426        $n = $nyes = 0;
2427        while (($row = edb_row($result))) {
2428            $n += $row[1];
2429            if ($row[0] > 0)
2430                $nyes += $row[1];
2431        }
2432        Dbl::free($result);
2433        return [$n, $nyes];
2434    }
2435
2436    function has_any_lead_or_shepherd() {
2437        return !!get($this->settings, "paperlead");
2438    }
2439
2440    function has_any_manager() {
2441        return ($this->_track_sensitivity & Track::BITS_ADMIN)
2442            || !!get($this->settings, "papermanager");
2443    }
2444
2445    function has_any_metareviews() {
2446        return !!get($this->settings, "metareviews");
2447    }
2448
2449    function can_pc_see_all_submissions() {
2450        if ($this->_pc_seeall_cache === null) {
2451            $this->_pc_seeall_cache = get($this->settings, "pc_seeall") ? : 0;
2452            if ($this->_pc_seeall_cache > 0 && !$this->timeFinalizePaper())
2453                $this->_pc_seeall_cache = 0;
2454        }
2455        return $this->_pc_seeall_cache > 0;
2456    }
2457
2458
2459    function set_siteurl($base) {
2460        $old_siteurl = Navigation::siteurl();
2461        $base = Navigation::set_siteurl($base);
2462        if ($this->opt["assetsUrl"] === $old_siteurl) {
2463            $this->opt["assetsUrl"] = $base;
2464            Ht::$img_base = $this->opt["assetsUrl"] . "images/";
2465        }
2466        if ($this->opt["scriptAssetsUrl"] === $old_siteurl)
2467            $this->opt["scriptAssetsUrl"] = $base;
2468    }
2469
2470    const HOTURL_RAW = 1;
2471    const HOTURL_POST = 2;
2472    const HOTURL_ABSOLUTE = 4;
2473    const HOTURL_SITE_RELATIVE = 8;
2474    const HOTURL_NO_DEFAULTS = 16;
2475
2476    function hoturl($page, $options = null, $flags = 0) {
2477        global $Me;
2478        $amp = ($flags & self::HOTURL_RAW ? "&" : "&amp;");
2479        $t = $page . Navigation::php_suffix();
2480        // parse options, separate anchor; see also redirectSelf
2481        $anchor = "";
2482        if (is_array($options)) {
2483            $x = "";
2484            foreach ($options as $k => $v)
2485                if ($v === null || $v === false)
2486                    /* skip */;
2487                else if ($k !== "anchor")
2488                    $x .= ($x === "" ? "" : $amp) . $k . "=" . urlencode($v);
2489                else
2490                    $anchor = "#" . urlencode($v);
2491            $options = $x;
2492        } else if (is_string($options)) {
2493            if (preg_match('/\A(.*?)(#.*)\z/', $options, $m))
2494                list($options, $anchor) = array($m[1], $m[2]);
2495        } else
2496            $options = "";
2497        if ($flags & self::HOTURL_POST)
2498            $options .= ($options === "" ? "" : $amp) . "post=" . post_value();
2499        // append defaults
2500        $are = '/\A(|.*?(?:&|&amp;))';
2501        $zre = '(?:&(?:amp;)?|\z)(.*)\z/';
2502        if (Conf::$hoturl_defaults && !($flags & self::HOTURL_NO_DEFAULTS))
2503            foreach (Conf::$hoturl_defaults as $k => $v)
2504                if (!preg_match($are . preg_quote($k) . '=/', $options))
2505                    $options .= $amp . $k . "=" . $v;
2506        // append forceShow to links to same paper if appropriate
2507        $is_paper_page = preg_match('/\A(?:paper|review|comment|assign)\z/', $page);
2508        if ($is_paper_page && $this->paper
2509            && preg_match($are . 'p=' . $this->paper->paperId . $zre, $options)
2510            && $Me->conf === $this
2511            && $Me->can_administer($this->paper)
2512            && $this->paper->has_conflict($Me)
2513            && !preg_match($are . 'forceShow=/', $options))
2514            $options .= $amp . "forceShow=1";
2515        // create slash-based URLs if appropriate
2516        if ($options) {
2517            if ($page == "review"
2518                && preg_match($are . 'r=(\d+[A-Z]+)' . $zre, $options, $m)) {
2519                $t .= "/" . $m[2];
2520                $options = $m[1] . $m[3];
2521                if (preg_match($are . 'p=\d+' . $zre, $options, $m))
2522                    $options = $m[1] . $m[2];
2523            } else if ($page == "paper"
2524                       && preg_match($are . 'p=(\d+|%\w+%|new)' . $zre, $options, $m)
2525                       && preg_match($are . 'm=(\w+)' . $zre, $m[1] . $m[3], $m2)) {
2526                $t .= "/" . $m[2] . "/" . $m2[2];
2527                $options = $m2[1] . $m2[3];
2528            } else if (($is_paper_page
2529                        && preg_match($are . 'p=(\d+|%\w+%|new)' . $zre, $options, $m))
2530                       || ($page == "profile"
2531                           && preg_match($are . 'u=([^&?]+)' . $zre, $options, $m))
2532                       || ($page == "help"
2533                           && preg_match($are . 't=(\w+)' . $zre, $options, $m))
2534                       || ($page == "settings"
2535                           && preg_match($are . 'group=(\w+)' . $zre, $options, $m))
2536                       || ($page == "graph"
2537                           && preg_match($are . 'g=([^&?]+)' . $zre, $options, $m))
2538                       || ($page == "doc"
2539                           && preg_match($are . 'file=([^&]+)' . $zre, $options, $m))) {
2540                $t .= "/" . str_replace("%2F", "/", $m[2]);
2541                $options = $m[1] . $m[3];
2542            } else if (preg_match($are . '__PATH__=([^&]+)' . $zre, $options, $m)) {
2543                $t .= "/" . urldecode($m[2]);
2544                $options = $m[1] . $m[3];
2545            }
2546            $options = preg_replace('/&(?:amp;)?\z/', "", $options);
2547        }
2548        if ($options && preg_match('/\A&(?:amp;)?(.*)\z/', $options, $m))
2549            $options = $m[1];
2550        if ($options !== "")
2551            $t .= "?" . $options;
2552        if ($anchor !== "")
2553            $t .= $anchor;
2554        if ($flags & self::HOTURL_SITE_RELATIVE)
2555            return $t;
2556        $need_site_path = false;
2557        if ($page === "index") {
2558            $expect = "index" . Navigation::php_suffix();
2559            if (substr($t, 0, strlen($expect)) === $expect
2560                && ($t === $expect || $t[strlen($expect)] === "?" || $t[strlen($expect)] === "#")) {
2561                $need_site_path = true;
2562                $t = substr($t, strlen($expect));
2563            }
2564        }
2565        if (($flags & self::HOTURL_ABSOLUTE) || $this !== Conf::$g)
2566            return $this->opt("paperSite") . "/" . $t;
2567        else {
2568            $siteurl = Navigation::siteurl();
2569            if ($need_site_path && $siteurl === "")
2570                $siteurl = Navigation::site_path();
2571            return $siteurl . $t;
2572        }
2573    }
2574
2575    function hoturl_site_relative($page, $options = null) {
2576        return $this->hoturl($page, $options, self::HOTURL_SITE_RELATIVE);
2577    }
2578
2579    function hoturl_site_relative_raw($page, $options = null) {
2580        return $this->hoturl($page, $options, self::HOTURL_SITE_RELATIVE | self::HOTURL_RAW);
2581    }
2582
2583    function hoturl_post($page, $options = null) {
2584        return $this->hoturl($page, $options, self::HOTURL_POST);
2585    }
2586
2587    function hoturl_raw($page, $options = null) {
2588        return $this->hoturl($page, $options, self::HOTURL_RAW);
2589    }
2590
2591
2592    //
2593    // Paper storage
2594    //
2595
2596    function active_document_ids() {
2597        $q = array("select paperStorageId from Paper where paperStorageId>1",
2598            "select finalPaperStorageId from Paper where finalPaperStorageId>1",
2599            "select paperStorageId from PaperComment where paperStorageId>1");
2600        $document_option_ids = array();
2601        foreach ($this->paper_opts->option_list() as $id => $o)
2602            if ($o->has_document())
2603                $document_option_ids[] = $id;
2604        if (!empty($document_option_ids))
2605            $q[] = "select value from PaperOption where optionId in ("
2606                . join(",", $document_option_ids) . ") and value>1";
2607
2608        $result = $this->qe_raw(join(" UNION ", $q));
2609        $ids = array();
2610        while (($row = edb_row($result)))
2611            $ids[(int) $row[0]] = true;
2612        Dbl::free($result);
2613        ksort($ids);
2614        return array_keys($ids);
2615    }
2616
2617    function document_by_id($did, PaperInfo $prow = null) {
2618        $result = $this->qe("select * from PaperStorage where paperStorageId=?"
2619            . ($prow ? " and paperId={$prow->paperId}" : ""), $did);
2620        $doc = DocumentInfo::fetch($result, $this, $prow);
2621        Dbl::free($result);
2622        return $doc;
2623    }
2624
2625    function download_documents($docs, $attachment) {
2626        if (count($docs) == 1
2627            && $docs[0]->paperStorageId <= 1
2628            && (!isset($docs[0]->content) || $docs[0]->content === "")) {
2629            self::msg_error("Paper #" . $docs[0]->paperId . " hasn’t been uploaded yet.");
2630            return false;
2631        }
2632
2633        foreach ($docs as $doc)
2634            $doc->filename = $doc->export_filename();
2635        $downloadname = false;
2636        if (count($docs) > 1) {
2637            $o = $this->paper_opts->get($docs[0]->documentType);
2638            $name = $o->dtype_name();
2639            if ($docs[0]->documentType <= 0)
2640                $name = pluralize($name);
2641            $downloadname = $this->download_prefix . "$name.zip";
2642        }
2643        $result = Filer::multidownload($docs, $downloadname, $attachment);
2644        if ($result->error) {
2645            self::msg_error($result->error_html);
2646            return false;
2647        } else
2648            return true;
2649    }
2650
2651
2652    //
2653    // Paper search
2654    //
2655
2656    static private function _cvt_numeric_set($optarr) {
2657        $ids = array();
2658        if (is_object($optarr))
2659            $optarr = $optarr->selection();
2660        foreach (mkarray($optarr) as $x)
2661            if (($x = cvtint($x)) > 0)
2662                $ids[] = $x;
2663        return $ids;
2664    }
2665
2666    function query_all_reviewer_preference() {
2667        return "group_concat(contactId,' ',preference,' ',coalesce(expertise,'.'))";
2668    }
2669
2670    private function paperQuery(Contact $contact = null, $options = array()) {
2671        // Options:
2672        //   "paperId" => $pid  Only paperId $pid (if array, any of those)
2673        //   "reviewId" => $rid Only paper reviewed by $rid
2674        //   "commentId" => $c  Only paper where comment is $c
2675        //   "finalized"        Only submitted papers
2676        //   "unsub"            Only unsubmitted papers
2677        //   "accepted"         Only accepted papers
2678        //   "active"           Only nonwithdrawn papers
2679        //   "author"           Only papers authored by $contactId
2680        //   "myReviewRequests" Only reviews requested by $contactId
2681        //   "myReviews"        All reviews authored by $contactId
2682        //   "myOutstandingReviews" All unsubmitted reviews auth by $contactId
2683        //   "myConflicts"      Only conflicted papers
2684        //   "commenterName"    Include commenter names
2685        //   "tags"             Include paperTags
2686        //   "minimal"          Only include minimal paper fields
2687        //   "tagIndex" => $tag Include tagIndex of named tag
2688        //   "tagIndex" => tag array -- include tagIndex, tagIndex1, ...
2689        //   "topics"
2690        //   "options"
2691        //   "scores" => array(fields to score)
2692        //   "assignments"
2693        //   "order" => $sql    $sql is SQL 'order by' clause (or empty)
2694
2695        $contactId = $contact ? $contact->contactId : 0;
2696
2697        // paper selection
2698        $paperset = array();
2699        if (isset($options["paperId"]))
2700            $paperset[] = self::_cvt_numeric_set($options["paperId"]);
2701        if (isset($options["reviewId"])) {
2702            if (is_numeric($options["reviewId"])) {
2703                $result = $this->qe("select paperId from PaperReview where reviewId=?", $options["reviewId"]);
2704                $paperset[] = self::_cvt_numeric_set(edb_first_columns($result));
2705            } else if (preg_match('/^(\d+)([A-Z][A-Z]?)$/i', $options["reviewId"], $m)) {
2706                $result = $this->qe("select paperId from PaperReview where paperId=? and reviewOrdinal=?", $m[1], parseReviewOrdinal($m[2]));
2707                $paperset[] = self::_cvt_numeric_set(edb_first_columns($result));
2708            } else
2709                $paperset[] = array();
2710        }
2711        if (isset($options["commentId"])) {
2712            $result = $this->qe("select paperId from PaperComment where commentId?a", self::_cvt_numeric_set($options["commentId"]));
2713            $paperset[] = self::_cvt_numeric_set(edb_first_columns($result));
2714        }
2715        if (count($paperset) > 1)
2716            $paperset = array(call_user_func_array("array_intersect", $paperset));
2717        $papersel = "";
2718        if (!empty($paperset))
2719            $papersel = "paperId" . sql_in_numeric_set($paperset[0]) . " and ";
2720
2721        // prepare query: basic tables
2722        // * Every table in `$joins` can have at most one row per paperId,
2723        //   except for `PaperReview`.
2724        $where = array();
2725
2726        $joins = array("Paper");
2727
2728        if (get($options, "minimal"))
2729            $cols = ["Paper.paperId, Paper.timeSubmitted, Paper.timeWithdrawn, Paper.outcome, Paper.leadContactId"];
2730        else
2731            $cols = ["Paper.*"];
2732
2733        if ($contact) {
2734            $aujoinwhere = null;
2735            if (get($options, "author")
2736                && ($aujoinwhere = $contact->act_author_view_sql("PaperConflict", true)))
2737                $where[] = $aujoinwhere;
2738            if (get($options, "author") && !$aujoinwhere)
2739                $joins[] = "join PaperConflict on (PaperConflict.paperId=Paper.paperId and PaperConflict.contactId=$contactId and PaperConflict.conflictType>=" . CONFLICT_AUTHOR . ")";
2740            else
2741                $joins[] = "left join PaperConflict on (PaperConflict.paperId=Paper.paperId and PaperConflict.contactId=$contactId)";
2742            $cols[] = "PaperConflict.conflictType";
2743        } else if (get($options, "author"))
2744            $where[] = "false";
2745
2746        // my review
2747        $no_paperreview = $paperreview_is_my_reviews = false;
2748        $reviewjoin = "PaperReview.paperId=Paper.paperId and " . ($contact ? $contact->act_reviewer_sql("PaperReview") : "false");
2749        if (get($options, "myReviews")) {
2750            $joins[] = "join PaperReview on ($reviewjoin)";
2751            $paperreview_is_my_reviews = true;
2752        } else if (get($options, "myOutstandingReviews"))
2753            $joins[] = "join PaperReview on ($reviewjoin and reviewNeedsSubmit!=0)";
2754        else if (get($options, "myReviewRequests"))
2755            $joins[] = "join PaperReview on (PaperReview.paperId=Paper.paperId and requestedBy=" . ($contactId ? : -100) . " and reviewType=" . REVIEW_EXTERNAL . ")";
2756        else
2757            $no_paperreview = true;
2758
2759        // review signatures
2760        if (get($options, "reviewSignatures")
2761            || get($options, "scores")
2762            || get($options, "reviewWordCounts")) {
2763            $cols[] = "(select " . ReviewInfo::review_signature_sql() . " from PaperReview r where r.paperId=Paper.paperId) reviewSignatures";
2764            foreach (get($options, "scores", []) as $fid)
2765                if (($f = $this->review_field($fid)) && $f->main_storage)
2766                    $cols[] = "(select group_concat({$f->main_storage} order by reviewId) from PaperReview where PaperReview.paperId=Paper.paperId) {$fid}Signature";
2767            if (get($options, "reviewWordCounts"))
2768                $cols[] = "(select group_concat(coalesce(reviewWordCount,'.') order by reviewId) from PaperReview where PaperReview.paperId=Paper.paperId) reviewWordCountSignature";
2769        } else if ($contact) {
2770            // need myReviewPermissions
2771            if ($no_paperreview)
2772                $joins[] = "left join PaperReview on ($reviewjoin)";
2773            if ($no_paperreview || $paperreview_is_my_reviews)
2774                $cols[] = PaperInfo::my_review_permissions_sql("PaperReview.") . " myReviewPermissions";
2775            else
2776                $cols[] = "(select " . PaperInfo::my_review_permissions_sql() . " from PaperReview where $reviewjoin group by paperId) myReviewPermissions";
2777        }
2778
2779        // fields
2780        if (get($options, "topics"))
2781            $cols[] = "(select group_concat(topicId) from PaperTopic where PaperTopic.paperId=Paper.paperId) topicIds";
2782
2783        if (get($options, "options")
2784            && (isset($this->settingTexts["options"]) || isset($this->opt["fixedOptions"]))
2785            && $this->paper_opts->count_option_list())
2786            $cols[] = "(select group_concat(PaperOption.optionId, '#', value) from PaperOption where paperId=Paper.paperId) optionIds";
2787        else if (get($options, "options"))
2788            $cols[] = "'' as optionIds";
2789
2790        if (get($options, "tags")
2791            || ($contact && $contact->isPC)
2792            || $this->has_tracks())
2793            $cols[] = "(select group_concat(' ', tag, '#', tagIndex order by tag separator '') from PaperTag where PaperTag.paperId=Paper.paperId) paperTags";
2794        if (get($options, "tagIndex") && !is_array($options["tagIndex"]))
2795            $options["tagIndex"] = array($options["tagIndex"]);
2796        if (get($options, "tagIndex"))
2797            foreach ($options["tagIndex"] as $i => $tag)
2798                $cols[] = "(select tagIndex from PaperTag where PaperTag.paperId=Paper.paperId and PaperTag.tag='" . sqlq($tag) . "') tagIndex" . ($i ? : "");
2799
2800        if (get($options, "reviewerPreference")) {
2801            $joins[] = "left join PaperReviewPreference on (PaperReviewPreference.paperId=Paper.paperId and PaperReviewPreference.contactId=$contactId)";
2802            $cols[] = "coalesce(PaperReviewPreference.preference, 0) as reviewerPreference";
2803            $cols[] = "PaperReviewPreference.expertise as reviewerExpertise";
2804        }
2805
2806        if (get($options, "allReviewerPreference"))
2807            $cols[] = "(select " . $this->query_all_reviewer_preference() . " from PaperReviewPreference where PaperReviewPreference.paperId=Paper.paperId) allReviewerPreference";
2808
2809        if (get($options, "allConflictType"))
2810            // See also SearchQueryInfo::add_allConflictType_column
2811            $cols[] = "(select group_concat(contactId, ' ', conflictType) from PaperConflict where PaperConflict.paperId=Paper.paperId) allConflictType";
2812
2813        if (get($options, "watch") && $contactId) {
2814            $joins[] = "left join PaperWatch on (PaperWatch.paperId=Paper.paperId and PaperWatch.contactId=$contactId)";
2815            $cols[] = "PaperWatch.watch";
2816        }
2817
2818        // conditions
2819        if (!empty($paperset))
2820            $where[] = "Paper.paperId" . sql_in_numeric_set($paperset[0]);
2821        if (get($options, "finalized"))
2822            $where[] = "timeSubmitted>0";
2823        else if (get($options, "unsub"))
2824            $where[] = "timeSubmitted<=0";
2825        if (get($options, "accepted"))
2826            $where[] = "outcome>0";
2827        if (get($options, "undecided"))
2828            $where[] = "outcome=0";
2829        if (get($options, "active")
2830            || get($options, "myReviews")
2831            || get($options, "myReviewRequests"))
2832            $where[] = "timeWithdrawn<=0";
2833        if (get($options, "myLead"))
2834            $where[] = "leadContactId=$contactId";
2835        if (get($options, "unmanaged"))
2836            $where[] = "managerContactId=0";
2837        if (get($options, "myManaged"))
2838            $where[] = "managerContactId=$contactId";
2839        if (get($options, "myWatching") && $contactId) {
2840            // return the papers with explicit or implicit WATCH_REVIEW
2841            // (i.e., author/reviewer/commenter); or explicitly managed
2842            // papers
2843            $owhere = [
2844                "PaperConflict.conflictType>=" . CONFLICT_AUTHOR,
2845                "PaperReview.reviewType>0",
2846                "exists (select * from PaperComment where paperId=Paper.paperId and contactId=$contactId)",
2847                "(PaperWatch.watch&" . Contact::WATCH_REVIEW . ")!=0"
2848            ];
2849            if ($this->has_any_lead_or_shepherd())
2850                $owhere[] = "leadContactId=$contactId";
2851            if ($this->has_any_manager() && $contact->is_explicit_manager())
2852                $owhere[] = "managerContactId=$contactId";
2853            $where[] = "(" . join(" or ", $owhere) . ")";
2854        }
2855        if (get($options, "myConflicts"))
2856            $where[] = $contactId ? "PaperConflict.conflictType>0" : "false";
2857
2858        $pq = "select " . join(",\n    ", $cols)
2859            . "\nfrom " . join("\n    ", $joins);
2860        if (!empty($where))
2861            $pq .= "\nwhere " . join("\n    and ", $where);
2862        if (get($options, "tags") === "require")
2863            $pq .= "\nhaving paperTags!=''";
2864
2865        // grouping and ordering
2866        $pq .= "\ngroup by Paper.paperId\n"
2867            . get($options, "order", "order by Paper.paperId") . "\n";
2868
2869        //Conf::msg_debugt($pq);
2870        return $pq;
2871    }
2872
2873    function paperRow($sel, Contact $contact = null, &$whyNot = null) {
2874        $ret = null;
2875        $whyNot = ["conf" => $this];
2876
2877        if (!is_array($sel))
2878            $sel = array("paperId" => $sel);
2879        if (isset($sel["paperId"]))
2880            $whyNot["paperId"] = $sel["paperId"];
2881        if (isset($sel["reviewId"]))
2882            $whyNot["reviewId"] = $sel["reviewId"];
2883
2884        if (isset($sel["paperId"]) && cvtint($sel["paperId"]) < 0)
2885            $whyNot["invalidId"] = "paper";
2886        else if (isset($sel["reviewId"]) && cvtint($sel["reviewId"]) < 0
2887                 && !preg_match('/^\d+[A-Z][A-Z]?$/i', $sel["reviewId"]))
2888            $whyNot["invalidId"] = "review";
2889        else {
2890            $q = $this->paperQuery($contact, $sel);
2891            $result = $this->qe_raw($q);
2892
2893            if (!$result)
2894                $whyNot["dbError"] = "Database error while fetching paper (" . htmlspecialchars($q) . "): " . htmlspecialchars($this->dblink->error);
2895            else if ($result->num_rows == 0) {
2896                if (!$contact || $contact->isPC)
2897                    $whyNot["noPaper"] = 1;
2898                else
2899                    $whyNot["permission"] = "view_paper";
2900            } else
2901                $ret = PaperInfo::fetch($result, $contact, $this);
2902
2903            Dbl::free($result);
2904        }
2905
2906        return $ret;
2907    }
2908
2909    function paper_result(Contact $user = null, $options = []) {
2910        return $this->qe_raw($this->paperQuery($user, $options));
2911    }
2912
2913    function paper_set(Contact $user = null, $options = []) {
2914        $rowset = new PaperInfoSet;
2915        $result = $this->paper_result($user, $options);
2916        while (($prow = PaperInfo::fetch($result, $user, $this)))
2917            $rowset->add($prow);
2918        Dbl::free($result);
2919        return $rowset;
2920    }
2921
2922    function preferenceConflictQuery($type, $extra) {
2923        $q = "select PRP.paperId, PRP.contactId, PRP.preference
2924                from PaperReviewPreference PRP
2925                join ContactInfo c on (c.contactId=PRP.contactId and c.roles!=0 and (c.roles&" . Contact::ROLE_PC . ")!=0)
2926                join Paper P on (P.paperId=PRP.paperId)
2927                left join PaperConflict PC on (PC.paperId=PRP.paperId and PC.contactId=PRP.contactId)
2928                where PRP.preference<=-100 and coalesce(PC.conflictType,0)<=0
2929                  and P.timeWithdrawn<=0";
2930        if ($type != "all" && ($type || !$this->can_pc_see_all_submissions()))
2931            $q .= " and P.timeSubmitted>0";
2932        if ($extra)
2933            $q .= " " . $extra;
2934        return $q;
2935    }
2936
2937
2938    //
2939    // Message routines
2940    //
2941
2942    static function msg_on(Conf $conf = null, $type, $text) {
2943        if (PHP_SAPI == "cli") {
2944            if (is_array($text))
2945                $text = join("\n", $text);
2946            if ($type === "xmerror" || $type === "merror" || $type === 2)
2947                fwrite(STDERR, "$text\n");
2948            else if ($type === "xwarning" || $type === "warning" || $type === 1
2949                     || !defined("HOTCRP_TESTHARNESS"))
2950                fwrite(STDOUT, "$text\n");
2951        } else if ($conf && !$conf->headerPrinted) {
2952            ensure_session();
2953            $_SESSION[$conf->dsn]["msgs"][] = [$type, $text];
2954        } else if ($type[0] == "x" || is_int($type))
2955            echo Ht::xmsg($type, $text);
2956        else {
2957            if (is_array($text))
2958                $text = '<div class="multimessage">' . join("", array_map(function ($x) { return '<div class="mmm">' . $x . '</div>'; }, $text)) . '</div>';
2959            echo "<div class=\"$type\">$text</div>";
2960        }
2961    }
2962
2963    function msg($type, $text) {
2964        self::msg_on($this, $type, $text);
2965    }
2966
2967    function infoMsg($text, $minimal = false) {
2968        $this->msg($minimal ? "xinfo" : "info", $text);
2969    }
2970
2971    static function msg_info($text, $minimal = false) {
2972        self::msg_on(self::$g, $minimal ? "xinfo" : "info", $text);
2973    }
2974
2975    function warnMsg($text, $minimal = false) {
2976        $this->msg($minimal ? "xwarning" : "warning", $text);
2977    }
2978
2979    static function msg_warning($text, $minimal = false) {
2980        self::msg_on(self::$g, $minimal ? "xwarning" : "warning", $text);
2981    }
2982
2983    function confirmMsg($text, $minimal = false) {
2984        $this->msg($minimal ? "xconfirm" : "confirm", $text);
2985    }
2986
2987    static function msg_confirm($text, $minimal = false) {
2988        self::msg_on(self::$g, $minimal ? "xconfirm" : "confirm", $text);
2989    }
2990
2991    function errorMsg($text, $minimal = false) {
2992        $this->msg($minimal ? "xmerror" : "merror", $text);
2993        return false;
2994    }
2995
2996    static function msg_error($text, $minimal = false) {
2997        self::msg_on(self::$g, $minimal ? "xmerror" : "merror", $text);
2998        return false;
2999    }
3000
3001    static function msg_debugt($text) {
3002        if (is_object($text) || is_array($text) || $text === null || $text === false || $text === true)
3003            $text = json_encode_browser($text);
3004        self::msg_on(self::$g, "merror", Ht::pre_text_wrap($text));
3005        return false;
3006    }
3007
3008    function post_missing_msg() {
3009        $this->msg("merror", "Your uploaded data wasn’t received. This can happen on unusually slow connections, or if you tried to upload a file larger than I can accept.");
3010    }
3011
3012
3013    //
3014    // Conference header, footer
3015    //
3016
3017    function has_active_list() {
3018        return !!$this->_active_list;
3019    }
3020
3021    function active_list() {
3022        if ($this->_active_list === false)
3023            $this->_active_list = null;
3024        return $this->_active_list;
3025    }
3026
3027    function set_active_list(SessionList $list = null) {
3028        assert($this->_active_list === false);
3029        $this->_active_list = $list;
3030    }
3031
3032    function make_css_link($url, $media = null) {
3033        global $ConfSitePATH;
3034        if (str_starts_with($url, "<meta") || str_starts_with($url, "<link"))
3035            return $url;
3036        $t = '<link rel="stylesheet" type="text/css" href="';
3037        $absolute = preg_match(',\A(?:https:?:|/),i', $url);
3038        if (!$absolute)
3039            $t .= $this->opt["assetsUrl"];
3040        $t .= htmlspecialchars($url);
3041        if (!$absolute && ($mtime = @filemtime("$ConfSitePATH/$url")) !== false)
3042            $t .= "?mtime=$mtime";
3043        if ($media)
3044            $t .= '" media="' . $media;
3045        return $t . '">';
3046    }
3047
3048    function make_script_file($url, $no_strict = false, $integrity = null) {
3049        global $ConfSitePATH;
3050        if (str_starts_with($url, "scripts/")) {
3051            $post = "";
3052            if (($mtime = @filemtime("$ConfSitePATH/$url")) !== false)
3053                $post = "mtime=$mtime";
3054            if (get($this->opt, "strictJavascript") && !$no_strict)
3055                $url = $this->opt["scriptAssetsUrl"] . "cacheable.php?file=" . urlencode($url)
3056                    . "&strictjs=1" . ($post ? "&$post" : "");
3057            else
3058                $url = $this->opt["scriptAssetsUrl"] . $url . ($post ? "?$post" : "");
3059            if ($this->opt["scriptAssetsUrl"] === Navigation::siteurl())
3060                return Ht::script_file($url);
3061        }
3062        return Ht::script_file($url, ["crossorigin" => "anonymous", "integrity" => $integrity]);
3063    }
3064
3065    private function make_jquery_script_file($jqueryVersion) {
3066        $integrity = null;
3067        if ($this->opt("jqueryCdn")) {
3068            if ($jqueryVersion === "1.12.4")
3069                $integrity = "sha256-ZosEbRLbNQzLpnKIkEdrPv7lOy9C27hHQ+Xp8a4MxAQ=";
3070            else if ($jqueryVersion === "3.1.1")
3071                $integrity = "sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=";
3072            else if ($jqueryVersion === "3.2.1")
3073                $integrity = "sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=";
3074            else if ($jqueryVersion === "3.3.1")
3075                $integrity = "sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=";
3076            $jquery = "//code.jquery.com/jquery-{$jqueryVersion}.min.js";
3077        } else
3078            $jquery = "scripts/jquery-{$jqueryVersion}.min.js";
3079        return $this->make_script_file($jquery, true, $integrity);
3080    }
3081
3082    function prepare_content_security_policy() {
3083        if (($csp = $this->opt("contentSecurityPolicy"))) {
3084            if (is_string($csp))
3085                $csp = [$csp];
3086            else if ($csp === true)
3087                $csp = [];
3088            $report_only = false;
3089            if (($pos = array_search("'report-only'", $csp)) !== false) {
3090                $report_only = true;
3091                array_splice($csp, $pos, 1);
3092            }
3093            if (empty($csp))
3094                array_push($csp, "script-src", "'nonce'");
3095            if (($pos = array_search("'nonce'", $csp)) !== false) {
3096                $nonceval = base64_encode(random_bytes(16));
3097                $csp[$pos] = "'nonce-$nonceval'";
3098                Ht::set_script_nonce($nonceval);
3099            }
3100            header("Content-Security-Policy"
3101                   . ($report_only ? "-Report-Only: " : ": ")
3102                   . join(" ", $csp));
3103        }
3104    }
3105
3106    function set_cookie($name, $value, $expires_at) {
3107        setcookie($name, $value, $expires_at, Navigation::site_path(),
3108                  $this->opt("sessionDomain", ""), $this->opt("sessionSecure", false));
3109    }
3110
3111    function header_head($title, $extra = null) {
3112        global $Me, $Now, $ConfSitePATH;
3113        // clear session list cookie
3114        if (isset($_COOKIE["hotlist-info"]))
3115            $this->set_cookie("hotlist-info", "", $Now - 86400);
3116
3117        echo "<!DOCTYPE html>
3118<html lang=\"en\">
3119<head>
3120<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">
3121<meta name=\"google\" content=\"notranslate\">\n";
3122        if (!$this->opt("allowIndexPapers") && $this->paper)
3123            echo "<meta name=\"robots\" content=\"noindex,noarchive\">\n";
3124
3125        if (($font_script = $this->opt("fontScript"))) {
3126            if (!str_starts_with($font_script, "<script"))
3127                $font_script = Ht::script($font_script);
3128            echo $font_script, "\n";
3129        }
3130
3131        foreach (mkarray($this->opt("prependStylesheets", [])) as $css)
3132            echo $this->make_css_link($css), "\n";
3133        echo $this->make_css_link("stylesheets/style.css"), "\n";
3134        if ($this->opt("mobileStylesheet")) {
3135            echo '<meta name="viewport" content="width=device-width, initial-scale=1">', "\n";
3136            echo $this->make_css_link("stylesheets/mobile.css", "screen and (max-width: 768px)"), "\n";
3137        }
3138        foreach (mkarray($this->opt("stylesheets", [])) as $css)
3139            echo $this->make_css_link($css), "\n";
3140
3141        // favicon
3142        $favicon = $this->opt("favicon", "images/review24.png");
3143        if ($favicon) {
3144            if (strpos($favicon, "://") === false && $favicon[0] != "/") {
3145                if ($this->opt["assetsUrl"] && substr($favicon, 0, 7) === "images/")
3146                    $favicon = $this->opt["assetsUrl"] . $favicon;
3147                else
3148                    $favicon = Navigation::siteurl() . $favicon;
3149            }
3150            if (substr($favicon, -4) == ".png")
3151                echo "<link rel=\"icon\" type=\"image/png\" href=\"$favicon\">\n";
3152            else if (substr($favicon, -4) == ".ico")
3153                echo "<link rel=\"shortcut icon\" href=\"$favicon\">\n";
3154            else if (substr($favicon, -4) == ".gif")
3155                echo "<link rel=\"icon\" type=\"image/gif\" href=\"$favicon\">\n";
3156            else
3157                echo "<link rel=\"icon\" href=\"$favicon\">\n";
3158        }
3159
3160        // title
3161        echo "<title>";
3162        if ($title) {
3163            $title = preg_replace("/<([^>\"']|'[^']*'|\"[^\"]*\")*>/", "", $title);
3164            $title = preg_replace(",(?: |&nbsp;|\302\240)+,", " ", $title);
3165            $title = str_replace("&#x2215;", "-", $title);
3166        }
3167        if ($title && $title !== "Home")
3168            echo $title, " - ";
3169        echo htmlspecialchars($this->short_name), "</title>\n</head>\n";
3170
3171        // jQuery
3172        $stash = Ht::unstash();
3173        if (isset($this->opt["jqueryUrl"]))
3174            Ht::stash_html($this->make_script_file($this->opt["jqueryUrl"], true) . "\n");
3175        else {
3176            $jqueryVersion = get($this->opt, "jqueryVersion", "3.3.1");
3177            if ($jqueryVersion[0] === "3") {
3178                Ht::stash_html("<!--[if lt IE 9]>" . $this->make_jquery_script_file("1.12.4") . "<![endif]-->\n");
3179                Ht::stash_html("<![if !IE|gte IE 9]>" . $this->make_jquery_script_file($jqueryVersion) . "<![endif]>\n");
3180            } else
3181                Ht::stash_html($this->make_jquery_script_file($jqueryVersion) . "\n");
3182        }
3183        if ($this->opt("jqueryMigrate"))
3184            Ht::stash_html($this->make_script_file("//code.jquery.com/jquery-migrate-3.0.0.js", true));
3185
3186        // Javascript settings to set before script.js
3187        Ht::stash_script("siteurl=" . json_encode_browser(Navigation::siteurl()) . ";siteurl_suffix=\"" . Navigation::php_suffix() . "\"");
3188        if (session_id() !== "") {
3189            $p = "";
3190            if (($x = $this->opt("sessionDomain")))
3191                $p .= "; domain=" . $x;
3192            if ($this->opt("sessionSecure"))
3193                $p .= "; secure";
3194            Ht::stash_script("siteurl_postvalue=" . json_encode(post_value()) . ";siteurl_cookie_params=" . json_encode($p));
3195        }
3196        if (($urldefaults = hoturl_defaults()))
3197            Ht::stash_script("siteurl_defaults=" . json_encode_browser($urldefaults) . ";");
3198        Ht::stash_script("assetsurl=" . json_encode_browser($this->opt["assetsUrl"]) . ";");
3199        $huser = (object) array();
3200        if ($Me && $Me->email)
3201            $huser->email = $Me->email;
3202        if ($Me && $Me->is_pclike())
3203            $huser->is_pclike = true;
3204        if ($Me && $Me->has_database_account())
3205            $huser->cid = $Me->contactId;
3206        Ht::stash_script("hotcrp_user=" . json_encode_browser($huser) . ";");
3207
3208        $pid = $extra ? get($extra, "paperId") : null;
3209        $pid = $pid && ctype_digit($pid) ? (int) $pid : 0;
3210        if (!$pid && $this->paper)
3211            $pid = $this->paper->paperId;
3212        if ($pid)
3213            Ht::stash_script("hotcrp_paperid=$pid");
3214        if ($pid && $Me && $Me->is_admin_force())
3215            Ht::stash_script("hotcrp_want_override_conflict=true");
3216
3217        // script.js
3218        if (!$this->opt("noDefaultScript"))
3219            Ht::stash_html($this->make_script_file("scripts/script.js") . "\n");
3220
3221        // other scripts
3222        foreach ($this->opt("scripts", []) as $file)
3223            Ht::stash_html($this->make_script_file($file) . "\n");
3224
3225        if ($stash)
3226            Ht::stash_html($stash);
3227    }
3228
3229    function has_interesting_deadline($my_deadlines) {
3230        global $Now;
3231        if (get($my_deadlines->sub, "open"))
3232            foreach (["reg", "update", "sub"] as $k)
3233                if ($Now <= get($my_deadlines->sub, $k, 0) || get($my_deadlines->sub, "{$k}_ingrace"))
3234                    return true;
3235        if (get($my_deadlines, "is_author") && get($my_deadlines, "resps"))
3236            foreach (get($my_deadlines, "resps") as $r)
3237                if ($r->open && ($Now <= $r->done || get($r, "ingrace")))
3238                    return true;
3239        return false;
3240    }
3241
3242    static function echo_header(Conf $conf, $is_home, $site_div, $title_div,
3243                                $profile_html, $actions_html, $my_deadlines) {
3244        echo $site_div, '<div id="header_right">', $profile_html;
3245        if ($my_deadlines && $conf->has_interesting_deadline($my_deadlines))
3246            echo '<div id="maindeadline">&nbsp;</div>';
3247        else
3248            echo '<div id="maindeadline" class="hidden"></div>';
3249        echo '</div>', ($title_div ? : ""), ($actions_html ? : "");
3250    }
3251
3252    function header_body($title, $id, $extra = null) {
3253        global $ConfSitePATH, $Me, $Now;
3254        echo "<body";
3255        if ($id)
3256            echo ' id="', $id, '"';
3257        $class = get($extra, "class");
3258        if (($list = $this->active_list()))
3259            $class = ($class ? $class . " " : "") . "has-hotlist";
3260        if ($class)
3261            echo ' class="', $class, '"';
3262        if ($list)
3263            echo ' data-hotlist="', htmlspecialchars($list->info_string()), '"';
3264        echo ">\n";
3265
3266        // initial load (JS's timezone offsets are negative of PHP's)
3267        Ht::stash_script("hotcrp_load.time(" . (-date("Z", $Now) / 60) . "," . ($this->opt("time24hour") ? 1 : 0) . ")");
3268
3269        // deadlines settings
3270        $my_deadlines = null;
3271        if ($Me) {
3272            $my_deadlines = $Me->my_deadlines($this->paper);
3273            Ht::stash_script("hotcrp_deadlines.init(" . json_encode_browser($my_deadlines) . ")");
3274        }
3275        if ($this->default_format)
3276            Ht::stash_script("render_text.set_default_format(" . $this->default_format . ")");
3277
3278        // meeting tracker
3279        $trackerowner = ($trackerstate = $this->setting_json("tracker"))
3280            && $trackerstate->trackerid
3281            && $trackerstate->sessionid == session_id();
3282        if ($trackerowner)
3283            Ht::stash_script("hotcrp_deadlines.tracker_ui(0)");
3284
3285        echo '<div id="prebody"><div id="header">';
3286
3287        // $header_site
3288        $is_home = $id === "home";
3289        $site_div = '<div id="header_site" class="'
3290            . ($is_home ? "header_site_home" : "header_site_page")
3291            . '"><h1><a class="qq" href="' . $this->hoturl("index") . '">'
3292            . '<span class="header-site-name">'
3293            . htmlspecialchars($this->short_name) . '</span>';
3294        if (!$is_home)
3295            $site_div .= ' Home';
3296        $site_div .= '</a></h1></div>';
3297
3298        // $header_profile
3299        $profile_html = "";
3300        if ($Me && !$Me->is_empty()) {
3301            // profile link
3302            $profile_parts = [];
3303            if ($Me->has_email() && !$Me->disabled) {
3304                $profile_parts[] = '<a class="q" href="' . $this->hoturl("profile") . '"><strong>'
3305                    . htmlspecialchars($Me->email)
3306                    . '</strong></a> &nbsp; <a href="' . $this->hoturl("profile") . '">Profile</a>';
3307            }
3308
3309            // "act as" link
3310            if (($actas = get($_SESSION, "last_actas"))
3311                && get($_SESSION, "trueuser")
3312                && ($Me->privChair || Contact::$trueuser_privChair === $Me)) {
3313                // Link becomes true user if not currently chair.
3314                if (!$Me->privChair || strcasecmp($Me->email, $actas) == 0)
3315                    $actas = $_SESSION["trueuser"]->email;
3316                if (strcasecmp($Me->email, $actas) != 0)
3317                    $profile_parts[] = "<a href=\"" . selfHref(array("actas" => $actas)) . "\">"
3318                        . ($Me->privChair ? htmlspecialchars($actas) : "Admin")
3319                        . "&nbsp;" . Ht::img("viewas.png", "Act as " . htmlspecialchars($actas))
3320                        . "</a>";
3321            }
3322
3323            // help, sign out
3324            $x = ($id == "search" ? "t=$id" : ($id == "settings" ? "t=chair" : ""));
3325            if (!$Me->disabled)
3326                $profile_parts[] = '<a href="' . $this->hoturl("help", $x) . '">Help</a>';
3327            if (!$Me->has_email() && !isset($this->opt["httpAuthLogin"]))
3328                $profile_parts[] = '<a href="' . $this->hoturl("index", "signin=1") . '" class="nw">Sign in</a>';
3329            if (!$Me->is_empty() || isset($this->opt["httpAuthLogin"]))
3330                $profile_parts[] = '<a href="' . $this->hoturl_post("index", "signout=1") . '" class="nw">Sign out</a>';
3331
3332            if (!empty($profile_parts))
3333                $profile_html .= join(' <span class="barsep">·</span> ', $profile_parts);
3334        }
3335
3336        $action_bar = get($extra, "action_bar");
3337        if ($action_bar === null)
3338            $action_bar = actionBar();
3339
3340        $title_div = get($extra, "title_div");
3341        if (!$title_div) {
3342            if ($title && $title !== "Home")
3343                $title_div = '<div id="header_page"><h1>' . $title . '</h1></div>';
3344            else if ($action_bar)
3345                $title_div = '<hr class="c">';
3346        }
3347
3348        $renderf = $this->opt("headerRenderer");
3349        if (!$renderf)
3350            $renderf = "Conf::echo_header";
3351        if (is_array($renderf)) {
3352            require_once($renderf[0]);
3353            $renderf = $renderf[1];
3354        }
3355        call_user_func($renderf, $this, $is_home, $site_div, $title_div, $profile_html, $action_bar, $my_deadlines);
3356
3357        echo "  <hr class=\"c\"></div>\n";
3358
3359        $this->headerPrinted = true;
3360        echo "<div id=\"initialmsgs\">\n";
3361        if (($x = $this->opt("maintenance")))
3362            echo Ht::xmsg(2, is_string($x) ? $x : "<strong>The site is down for maintenance.</strong> Please check back later.");
3363        if (($msgs = $this->session("msgs")) && !empty($msgs)) {
3364            $this->save_session("msgs", null);
3365            foreach ($msgs as $m)
3366                $this->msg($m[0], $m[1]);
3367        }
3368        echo "</div>\n";
3369
3370        echo "</div>\n<div id=\"body\" class=\"body\">\n";
3371
3372        // If browser owns tracker, send it the script immediately
3373        if ($trackerowner)
3374            echo Ht::unstash();
3375
3376        // Callback for version warnings
3377        if ($Me && $Me->privChair
3378            && (!isset($_SESSION["updatecheck"])
3379                || $_SESSION["updatecheck"] + 3600 <= $Now)
3380            && (!isset($this->opt["updatesSite"]) || $this->opt["updatesSite"])) {
3381            $m = isset($this->opt["updatesSite"]) ? $this->opt["updatesSite"] : "//hotcrp.lcdf.org/updates";
3382            $m .= (strpos($m, "?") === false ? "?" : "&")
3383                . "addr=" . urlencode($_SERVER["SERVER_ADDR"])
3384                . "&base=" . urlencode(Navigation::siteurl())
3385                . "&version=" . HOTCRP_VERSION;
3386            $v = HOTCRP_VERSION;
3387            if (is_dir("$ConfSitePATH/.git")) {
3388                $args = array();
3389                exec("export GIT_DIR=" . escapeshellarg($ConfSitePATH) . "/.git; git rev-parse HEAD 2>/dev/null; git merge-base origin/master HEAD 2>/dev/null", $args);
3390                if (count($args) >= 1) {
3391                    $m .= "&git-head=" . urlencode($args[0]);
3392                    $v .= " " . $args[0];
3393                }
3394                if (count($args) >= 2) {
3395                    $m .= "&git-upstream=" . urlencode($args[1]);
3396                    $v .= " " . $args[1];
3397                }
3398            }
3399            Ht::stash_script("check_version(\"$m\",\"$v\")");
3400            $_SESSION["updatecheck"] = $Now;
3401        }
3402    }
3403
3404    function header($title, $id, $extra = null) {
3405        if (!$this->headerPrinted) {
3406            $this->header_head($title, $extra);
3407            $this->header_body($title, $id, $extra);
3408        }
3409    }
3410
3411    static function git_status() {
3412        global $ConfSitePATH;
3413        $args = array();
3414        if (is_dir("$ConfSitePATH/.git"))
3415            exec("export GIT_DIR=" . escapeshellarg($ConfSitePATH) . "/.git; git rev-parse HEAD 2>/dev/null; git rev-parse v" . HOTCRP_VERSION . " 2>/dev/null", $args);
3416        return count($args) == 2 ? $args : null;
3417    }
3418
3419    function footer() {
3420        global $Me, $ConfSitePATH;
3421        echo "</div>\n", // class='body'
3422            '<div id="footer"><div id="footer_crp">',
3423            $this->opt("extraFooter", ""),
3424            '<a href="http://read.seas.harvard.edu/~kohler/hotcrp/">HotCRP</a>';
3425        if (!$this->opt("noFooterVersion")) {
3426            if ($Me && $Me->privChair) {
3427                echo " v", HOTCRP_VERSION;
3428                if (($git_data = self::git_status()) && $git_data[0] !== $git_data[1])
3429                    echo " [", substr($git_data[0], 0, 7), "...]";
3430            } else
3431                echo "<!-- Version ", HOTCRP_VERSION, " -->";
3432        }
3433        echo "</div>\n  <hr class=\"c\"></div>\n";
3434        echo Ht::unstash(), "</body>\n</html>\n";
3435    }
3436
3437    function stash_hotcrp_pc(Contact $user) {
3438        if (!Ht::mark_stash("hotcrp_pc"))
3439            return;
3440        $hpcj = $list = [];
3441        foreach ($this->pc_members() as $pcm) {
3442            $hpcj[$pcm->contactId] = $j = (object) ["name" => $user->name_text_for($pcm), "email" => $pcm->email];
3443            if (($color_classes = $user->user_color_classes_for($pcm)))
3444                $j->color_classes = $color_classes;
3445            if ($this->sort_by_last && $pcm->lastName) {
3446                $r = Text::analyze_name($pcm);
3447                if (strlen($r->lastName) !== strlen($r->name))
3448                    $j->lastpos = strlen($r->firstName) + 1;
3449                if ($r->nameAmbiguous && $r->name !== "" && $r->email !== "")
3450                    $j->emailpos = strlen($r->name) + 1;
3451            }
3452            $list[] = $pcm->contactId;
3453        }
3454        $hpcj["__order__"] = $list;
3455        if ($this->sort_by_last)
3456            $hpcj["__sort__"] = "last";
3457        Ht::stash_script("hotcrp_pc=" . json_encode_browser($hpcj) . ";");
3458    }
3459
3460
3461    //
3462    // Action recording
3463    //
3464
3465    const action_log_query = "insert into ActionLog (ipaddr, contactId, destContactId, paperId, action) values ?v";
3466
3467    function save_logs($on) {
3468        if ($on && $this->_save_logs === false)
3469            $this->_save_logs = array();
3470        else if (!$on && $this->_save_logs !== false) {
3471            $qv = [];
3472            $last_pids = null;
3473            foreach ($this->_save_logs as $cid_text => $pids) {
3474                $pos = strpos($cid_text, "|");
3475                list($user, $dest_user) = explode(",", substr($cid_text, 0, $pos));
3476                $what = substr($cid_text, $pos + 1);
3477                $pids = array_keys($pids);
3478
3479                // Combine `Tag:` messages
3480                if (substr($what, 0, 5) === "Tag: "
3481                    && ($n = count($qv)) > 0
3482                    && substr($qv[$n-1][4], 0, 5) === "Tag: "
3483                    && $last_pids === $pids) {
3484                    $qv[$n-1][4] = $what . substr($qv[$n-1][4], 4);
3485                    continue;
3486                }
3487
3488                $qv[] = self::format_log_values($what, $user, $dest_user, $pids);
3489                $last_pids = $pids;
3490            }
3491            if (!empty($qv))
3492                $this->qe(self::action_log_query, $qv);
3493            $this->_save_logs = false;
3494        }
3495    }
3496
3497    private static function log_clean_user($user, &$text) {
3498        if (!$user)
3499            return 0;
3500        else if (!is_numeric($user)) {
3501            if ($user->email && !$user->contactId && !$user->is_site_contact)
3502                $text .= " <{$user->email}>";
3503            return $user->contactId;
3504        } else
3505            return $user;
3506    }
3507
3508    function log_for($user, $dest_user, $text, $pids = null) {
3509        $user = self::log_clean_user($user, $text);
3510        $dest_user = self::log_clean_user($dest_user, $text);
3511
3512        if (is_object($pids))
3513            $pids = array($pids->paperId);
3514        else if (!is_array($pids))
3515            $pids = $pids > 0 ? array($pids) : array();
3516        $ps = array();
3517        foreach ($pids as $p)
3518            $ps[] = is_object($p) ? $p->paperId : $p;
3519
3520        if ($this->_save_logs === false)
3521            $this->qe(self::action_log_query, [self::format_log_values($text, $user, $dest_user, $ps)]);
3522        else {
3523            $key = "$user,$dest_user|$text";
3524            if (!isset($this->_save_logs[$key]))
3525                $this->_save_logs[$key] = [];
3526            foreach ($ps as $p)
3527                $this->_save_logs[$key][$p] = true;
3528        }
3529    }
3530
3531    private static function format_log_values($text, $user, $dest_user, $pids) {
3532        $pid = null;
3533        if (count($pids) == 1)
3534            $pid = $pids[0];
3535        else if (count($pids) > 1)
3536            $text .= " (papers " . join(", ", $pids) . ")";
3537        return [get($_SERVER, "REMOTE_ADDR"), (int) $user, (int) $dest_user, $pid, substr($text, 0, 4096)];
3538    }
3539
3540
3541    // capabilities
3542
3543    function capability_manager($for = null) {
3544        if ($for && substr($for, 0, 1) === "U") {
3545            if (($cdb = $this->contactdb()))
3546                return new CapabilityManager($cdb, "U");
3547            else
3548                return null;
3549        } else
3550            return new CapabilityManager($this->dblink, "");
3551    }
3552
3553
3554    // messages
3555
3556    function message_name($name) {
3557        if (str_starts_with($name, "msg."))
3558            $name = substr($name, 4);
3559        if ($name === "revprefdescription" && $this->has_topics())
3560            $name .= ".withtopics";
3561        else if (str_starts_with($name, "resp_instrux") && $this->setting("resp_words", 500) > 0)
3562            $name .= ".wordlimit";
3563        return $name;
3564    }
3565
3566    function message_html($name, $expansions = null) {
3567        $name = $this->message_name($name);
3568        $html = get($this->settingTexts, "msg.$name");
3569        if ($html === null && ($p = strrpos($name, ".")) !== false)
3570            $html = get($this->settingTexts, "msg." . substr($name, 0, $p));
3571        if ($html === null)
3572            $html = Message::default_html($name);
3573        if ($html && $expansions)
3574            foreach ($expansions as $k => $v)
3575                $html = str_ireplace("%$k%", $v, $html);
3576        return $html;
3577    }
3578
3579    function message_default_html($name) {
3580        return Message::default_html($this->message_name($name));
3581    }
3582
3583
3584    function ims() {
3585        if (!$this->_ims) {
3586            $this->_ims = new IntlMsgSet;
3587            $m = ["?etc/msgs.json"];
3588            if (($lang = $this->opt("lang")))
3589                $m[] = "?etc/msgs.$lang.json";
3590            $this->_ims->set_default_priority(-1.0);
3591            expand_json_includes_callback($m, [$this->_ims, "addj"]);
3592            $this->_ims->clear_default_priority();
3593            if (($mlist = $this->opt("messageOverrides")))
3594                expand_json_includes_callback($mlist, [$this->_ims, "addj"]);
3595        }
3596        return $this->_ims;
3597    }
3598
3599    function _($itext) {
3600        return call_user_func_array([$this->ims(), "x"], func_get_args());
3601    }
3602
3603    function _c($context, $itext) {
3604        return call_user_func_array([$this->ims(), "xc"], func_get_args());
3605    }
3606
3607    function _i($id, $itext) {
3608        return call_user_func_array([$this->ims(), "xi"], func_get_args());
3609    }
3610
3611    function _ci($context, $id, $itext) {
3612        return call_user_func_array([$this->ims(), "xci"], func_get_args());
3613    }
3614
3615
3616    // API
3617    function _add_api_json($fj) {
3618        if (isset($fj->name) && is_string($fj->name)
3619            && isset($fj->callback) && is_string($fj->callback))
3620            return self::xt_add($this->_api_map, $fj->name, $fj);
3621        else
3622            return false;
3623    }
3624    private function api_map() {
3625        if ($this->_api_map === null) {
3626            $this->_api_map = [];
3627            expand_json_includes_callback(["etc/apifunctions.json"], [$this, "_add_api_json"]);
3628            if (($olist = $this->opt("apiFunctions")))
3629                expand_json_includes_callback($olist, [$this, "_add_api_json"]);
3630        }
3631        return $this->_api_map;
3632    }
3633    private function check_api_json($fj, Contact $user = null, $method) {
3634        if (isset($fj->allow_if) && !$this->xt_allowed($fj, $user))
3635            return false;
3636        else if (!$method)
3637            return true;
3638        else {
3639            $methodx = get($fj, strtolower($method));
3640            return $methodx
3641                || ($method === "POST" && $methodx === null && get($fj, "get"));
3642        }
3643    }
3644    function has_api($fn, Contact $user = null, $method = null) {
3645        return !!$this->api($fn, $user, $method);
3646    }
3647    function api($fn, Contact $user = null, $method = null) {
3648        $checkf = function ($xt) use ($user, $method) {
3649            return $this->check_api_json($xt, $user, $method);
3650        };
3651        $uf = $this->xt_search_name($this->api_map(), $fn, $checkf);
3652        return self::xt_enabled($uf) ? $uf : null;
3653    }
3654    private function call_api($fn, $uf, Contact $user, Qrequest $qreq, $prow) {
3655        $method = $qreq->method();
3656        if ($method !== "GET" && $method !== "HEAD" && $method !== "OPTIONS"
3657            && (!$uf || !get($uf, "allow_xss")) && !$qreq->post_ok())
3658            return new JsonResult(403, ["ok" => false, "error" => "Missing credentials."]);
3659        if (!$uf) {
3660            if ($this->has_api($fn, $user, null))
3661                return new JsonResult(405, ["ok" => false, "error" => "Method not supported."]);
3662            else if ($this->has_api($fn, null, $qreq->method()))
3663                return new JsonResult(403, ["ok" => false, "error" => "Permission error."]);
3664            else
3665                return new JsonResult(404, ["ok" => false, "error" => "Function not found."]);
3666        }
3667        if (!$prow && get($uf, "paper")) {
3668            $result = ["ok" => false];
3669            if (($whynot = $qreq->attachment("paper_whynot"))) {
3670                $status = isset($result["noPaper"]) ? 404 : 403;
3671                $result["error"] = whyNotText($whynot, true);
3672                if (isset($whynot["signin"]))
3673                    $result["loggedout"] = true;
3674            } else {
3675                $status = 400;
3676                $result["error"] = "No paper specified.";
3677            }
3678            return new JsonResult($status, $result);
3679        }
3680        self::xt_resolve_require($uf);
3681        return call_user_func($uf->callback, $user, $qreq, $prow, $uf);
3682    }
3683    function call_api_exit($fn, Contact $user, Qrequest $qreq, PaperInfo $prow = null) {
3684        // XXX precondition: $user->can_view_paper($prow) || !$prow
3685        $uf = $this->api($fn, $user, $qreq->method());
3686        if ($uf && get($uf, "redirect") && $qreq->redirect
3687            && preg_match('@\A(?![a-z]+:|/).+@', $qreq->redirect)) {
3688            try {
3689                JsonResultException::$capturing = true;
3690                $j = $this->call_api($fn, $uf, $user, $qreq, $prow);
3691            } catch (JsonResultException $ex) {
3692                $j = $ex->result;
3693            }
3694            if (is_object($j) && $j instanceof JsonResult)
3695                $j = $j->content;
3696            if (!get($j, "ok") && !get($j, "error"))
3697                Conf::msg_error("Internal error.");
3698            else if (($x = get($j, "error")))
3699                Conf::msg_error(htmlspecialchars($x));
3700            else if (($x = get($j, "error_html")))
3701                Conf::msg_error($x);
3702            Navigation::redirect_site($qreq->redirect);
3703        } else {
3704            $j = $this->call_api($fn, $uf, $user, $qreq, $prow);
3705            json_exit($j);
3706        }
3707    }
3708
3709
3710    // List action API
3711    function _add_list_action_json($fj) {
3712        $ok = false;
3713        if (isset($fj->name) && is_string($fj->name)) {
3714            if (isset($fj->render_callback) && is_string($fj->render_callback))
3715                $ok = self::xt_add($this->_list_action_renderers, $fj->name, $fj);
3716            if (isset($fj->callback) && is_string($fj->callback))
3717                $ok = self::xt_add($this->_list_action_map, $fj->name, $fj);
3718        } else if (is_string($fj->match) && is_string($fj->expand_callback)) {
3719            $this->_list_action_factories[] = $fj;
3720            $ok = true;
3721        }
3722        return $ok;
3723    }
3724    function list_action_map() {
3725        if ($this->_list_action_map === null) {
3726            $this->_list_action_map = $this->_list_action_renderers = $this->_list_action_factories = [];
3727            expand_json_includes_callback(["etc/listactions.json"], [$this, "_add_list_action_json"]);
3728            if (($olist = $this->opt("listActions")))
3729                expand_json_includes_callback($olist, [$this, "_add_list_action_json"]);
3730            usort($this->_list_action_factories, "Conf::xt_priority_compare");
3731        }
3732        return $this->_list_action_map;
3733    }
3734    function list_action_renderers() {
3735        $this->list_action_map();
3736        return $this->_list_action_renderers;
3737    }
3738    function has_list_action($name, Contact $user = null, $method = null) {
3739        return !!$this->list_action($name, $user, $method);
3740    }
3741    function list_action($name, Contact $user = null, $method = null) {
3742        $checkf = function ($xt) use ($user, $method) {
3743            return $this->check_api_json($xt, $user, $method);
3744        };
3745        $uf = $this->xt_search_name($this->list_action_map(), $name, $checkf);
3746        if (($s = strpos($name, "/")) !== false)
3747            $uf = $this->xt_search_name($this->list_action_map(), substr($name, 0, $s), $checkf, $uf);
3748        if (($expansions = $this->xt_search_factories($this->_list_action_factories, $name, $checkf, $uf, $user)))
3749            $uf = $expansions[0];
3750        return self::xt_resolve_require($uf);
3751    }
3752
3753    function make_csvg($basename, $flags = 0) {
3754        $csv = new CsvGenerator($flags);
3755        $csv->set_filename($this->download_prefix . $basename . $csv->extension());
3756        return $csv;
3757    }
3758
3759
3760    // Paper columns
3761    function _add_paper_column_json($fj) {
3762        $cb = isset($fj->callback) && is_string($fj->callback);
3763        if (isset($fj->name) && is_string($fj->name) && $cb) {
3764            return self::xt_add($this->_paper_column_map, $fj->name, $fj);
3765        } else if (is_string($fj->match) && (isset($fj->expand_callback) ? is_string($fj->expand_callback) : $cb)) {
3766            $this->_paper_column_factories[] = $fj;
3767            return true;
3768        } else
3769            return false;
3770    }
3771    function paper_column_map() {
3772        if ($this->_paper_column_map === null) {
3773            require_once("papercolumn.php");
3774            $this->_paper_column_map = $this->_paper_column_factories = [];
3775            expand_json_includes_callback(["etc/papercolumns.json"], [$this, "_add_paper_column_json"]);
3776            if (($olist = $this->opt("paperColumns")))
3777                expand_json_includes_callback($olist, [$this, "_add_paper_column_json"]);
3778            usort($this->_paper_column_factories, "Conf::xt_priority_compare");
3779        }
3780        return $this->_paper_column_map;
3781    }
3782    function paper_column_factories() {
3783        $this->paper_column_map();
3784        return $this->_paper_column_factories;
3785    }
3786    function basic_paper_column($name, Contact $user = null) {
3787        $checkf = function ($xt) use ($user) { return $this->xt_allowed($xt, $user); };
3788        $uf = $this->xt_search_name($this->paper_column_map(), $name, $checkf);
3789        return self::xt_enabled($uf) ? $uf : null;
3790    }
3791    function paper_columns($name, Contact $user) {
3792        $checkf = function ($xt) use ($user) { return $this->xt_allowed($xt, $user); };
3793        $uf = $this->xt_search_name($this->paper_column_map(), $name, $checkf);
3794        $expansions = $this->xt_search_factories($this->_paper_column_factories, $name, $checkf, $uf, $user, "i");
3795        return array_filter($expansions ? : [$uf], "Conf::xt_resolve_require");
3796    }
3797
3798
3799    // Option types
3800    function _add_option_type_json($fj) {
3801        $cb = isset($fj->callback) && is_string($fj->callback);
3802        if (isset($fj->name) && is_string($fj->name) && $cb)
3803            return self::xt_add($this->_option_type_map, $fj->name, $fj);
3804        else if (is_string($fj->match) && (isset($fj->expand_callback) ? is_string($fj->expand_callback) : $cb)) {
3805            $this->_option_type_factories[] = $fj;
3806            return true;
3807        } else
3808            return false;
3809    }
3810    function option_type_map() {
3811        if ($this->_option_type_map === null) {
3812            require_once("paperoption.php");
3813            $this->_option_type_map = $this->_option_type_factories = [];
3814            expand_json_includes_callback(["etc/optiontypes.json"], [$this, "_add_option_type_json"]);
3815            if (($olist = $this->opt("optionTypes")))
3816                expand_json_includes_callback($olist, [$this, "_add_option_type_json"]);
3817            usort($this->_option_type_factories, "Conf::xt_priority_compare");
3818            // option types are global (cannot be allowed per user)
3819            $m = [];
3820            foreach (array_keys($this->_option_type_map) as $name) {
3821                if (($uf = $this->xt_search_name($this->_option_type_map, $name, [$this, "xt_allowed"])))
3822                    $m[$name] = $uf;
3823            }
3824            $this->_option_type_map = $m;
3825        }
3826        return $this->_option_type_map;
3827    }
3828    function option_type($name) {
3829        $uf = get($this->option_type_map(), $name);
3830        if (($expansions = $this->xt_search_factories($this->_option_type_factories, $name, [$this, "xt_allowed"], $uf, null, "i")))
3831            $uf = $expansions[0];
3832        return $uf;
3833    }
3834
3835
3836    // Mail keywords
3837    function _add_mail_keyword_json($fj) {
3838        $cb = isset($fj->callback) && is_string($fj->callback);
3839        if (isset($fj->name) && is_string($fj->name) && $cb)
3840            return self::xt_add($this->_mail_keyword_map, $fj->name, $fj);
3841        else if (is_string($fj->match) && (isset($fj->expand_callback) ? is_string($fj->expand_callback) : $cb)) {
3842            $this->_mail_keyword_factories[] = $fj;
3843            return true;
3844        } else
3845            return false;
3846    }
3847    function mail_keyword_map() {
3848        if ($this->_mail_keyword_map === null) {
3849            $this->_mail_keyword_map = $this->_mail_keyword_factories = [];
3850            expand_json_includes_callback(["etc/mailkeywords.json"], [$this, "_add_mail_keyword_json"]);
3851            if (($mks = $this->opt("mailKeywords")))
3852                expand_json_includes_callback($mks, [$this, "_add_mail_keyword_json"]);
3853            usort($this->_mail_keyword_factories, "Conf::xt_priority_compare");
3854        }
3855        return $this->_mail_keyword_map;
3856    }
3857    function mail_keywords($name) {
3858        $checkf = [$this, "xt_allowed"];
3859        $uf = $this->xt_search_name($this->mail_keyword_map(), $name, $checkf);
3860        $expansions = $this->xt_search_factories($this->_mail_keyword_factories, $name, $checkf, $uf, null, "");
3861        return array_filter($expansions ? : [$uf], "Conf::xt_resolve_require");
3862    }
3863
3864
3865    // Hooks
3866    function _add_hook_json($fj) {
3867        if (isset($fj->callback) && is_string($fj->callback) && !isset($fj->synonym)) {
3868            if (isset($fj->event) && is_string($fj->event))
3869                return self::xt_add($this->_hook_map, $fj->event, $fj);
3870            else if (isset($fj->match) && is_string($fj->match)) {
3871                $this->_hook_factories[] = $fj;
3872                return true;
3873            }
3874        }
3875        return false;
3876    }
3877    function add_hook($name, $callback = null, $priority = null) {
3878        if ($this->_hook_map === null)
3879            $this->hook_map();
3880        $fj = is_object($name) ? $name : $callback;
3881        if (is_string($fj))
3882            $fj = (object) ["callback" => $fj];
3883        if (is_string($name))
3884            $fj->event = $name;
3885        if ($priority !== null)
3886            $fj->priority = $priority;
3887        return $this->_add_hook_json($fj) ? $fj : false;
3888    }
3889    function remove_hook($fj) {
3890        if (isset($fj->event) && is_string($fj->event)
3891            && isset($this->_hook_map[$fj->event])
3892            && ($i = array_search($fj, $this->_hook_map[$fj->event], true)) !== false) {
3893            array_splice($this->_hook_map[$fj->event], $i, 1);
3894            return true;
3895        } else if (isset($fj->match) && is_string($fj->match)
3896                   && ($i = array_search($fj, $this->_hook_factories, true)) !== false) {
3897            array_splice($this->_hook_factories, $i, 1);
3898            return true;
3899        }
3900        return false;
3901    }
3902    private function hook_map() {
3903        if ($this->_hook_map === null) {
3904            $this->_hook_map = $this->_hook_factories = [];
3905            if (($hlist = $this->opt("hooks")))
3906                expand_json_includes_callback($hlist, [$this, "_add_hook_json"]);
3907        }
3908        return $this->_hook_map;
3909    }
3910    function call_hooks($name, Contact $user = null /* ... args */) {
3911        $hs = get($this->hook_map(), $name);
3912        foreach ($this->_hook_factories as $fj) {
3913            if ($fj->match === ".*"
3914                || preg_match("\1\\A(?:{$fxt->match})\\z\1", $name, $m)) {
3915                $xfj = clone $fj;
3916                $xfj->event = $name;
3917                $xfj->match_data = $m;
3918                $hs[] = $xfj;
3919            }
3920        }
3921        if ($hs !== null) {
3922            $args = array_slice(func_get_args(), 1);
3923            usort($hs, "Conf::xt_priority_compare");
3924            $ids = [];
3925            foreach ($hs as $fj) {
3926                if ((!isset($fj->id) || !isset($ids[$fj->id]))
3927                    && $this->xt_allowed($fj, $user)) {
3928                    if (isset($fj->id))
3929                        $ids[$fj->id] = true;
3930                    if (!self::xt_disabled($fj)) {
3931                        $fj->conf = $this;
3932                        $fj->user = $user;
3933                        $args[0] = $fj;
3934                        $x = call_user_func_array($fj->callback, $args);
3935                        unset($fj->conf, $fj->user);
3936                        if ($x === false)
3937                            return false;
3938                    }
3939                }
3940            }
3941        }
3942    }
3943}
3944