diff --git a/batch/apispec.php b/batch/apispec.php index 5b4e6a908..96aa4eff6 100644 --- a/batch/apispec.php +++ b/batch/apispec.php @@ -14,6 +14,10 @@ class APISpec_Batch { public $user; /** @var array> */ public $api_map; + /** @var array */ + public $schemas = []; + /** @var array */ + public $parameters = []; function __construct(Conf $conf, $arg) { $this->conf = $conf; @@ -37,13 +41,24 @@ function run() { $paths[$path] = $this->expand($fn); } } - fwrite(STDOUT, json_encode([ + $components = []; + if (!empty($this->schemas)) { + $components["schemas"] = $this->schemas; + } + if (!empty($this->parameters)) { + $components["parameters"] = $this->parameters; + } + $j = [ "openapi" => "3.0.0", "info" => [ "title" => "HotCRP" ], "paths" => $paths - ], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n"); + ]; + if (!empty($components)) { + $j["components"] = $components; + } + fwrite(STDOUT, json_encode($j, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n"); return 0; } @@ -58,22 +73,52 @@ private function expand($fn) { return $x; } + /** @param string $name + * @return array */ + private function resolve_common_schema($name) { + if (!isset($this->schemas[$name])) { + if ($name === "pid") { + $this->schemas[$name] = [ + "type" => "integer", + "minimum" => 1 + ]; + } else { + assert(false); + } + } + return ["\$ref" => "#/components/schemas/{$name}"]; + } + + /** @param string $name + * @return array */ + private function resolve_common_param($name) { + if (!isset($this->parameters[$name])) { + if ($name === "p") { + $this->parameters[$name] = [ + "name" => "p", + "in" => "path", + "required" => true, + "schema" => $this->resolve_common_schema("pid") + ]; + } else { + assert(false); + } + } + return ["\$ref" => "#/components/parameters/{$name}"]; + } + /** @return object */ private function expand1($fn, $method, $j) { $x = (object) []; $params = []; if ($j->paper ?? false) { - $params["p"] = [ - "name" => "p", - "in" => "path", - "required" => true - ]; + $params[] = $this->resolve_common_param("p"); } $mparameters = strtolower($method) . "_parameters"; foreach ($j->$mparameters ?? $j->parameters ?? [] as $p) { $optional = str_starts_with($p, "?"); $name = $optional ? substr($p, 1) : $p; - $params[$name] = [ + $params[] = [ "name" => $name, "in" => "query", "required" => !$optional diff --git a/batch/autoassign.php b/batch/autoassign.php index 724edc672..84454a39e 100644 --- a/batch/autoassign.php +++ b/batch/autoassign.php @@ -1,4 +1,4 @@ -_jtok->update_use(); $this->parse_arg($arg); - $this->parse_arg($getopt->parse($this->_jtok->input("assign_argv") ?? [])); + $this->parse_arg($getopt->parse($this->_jtok->input("assign_argv") ?? [], 0)); $this->complete_arg(); } catch (CommandLineException $ex) { $this->report([MessageItem::error("<0>{$ex->getMessage()}")], $ex->exitStatus); @@ -121,6 +121,12 @@ private function reportx($message_list, $exit_status = null) { assert(false); } + /** @param string $msg */ + static private function my_error_log($msg) { + error_log($msg); + file_put_contents("/tmp/hotcrp.log", $msg, FILE_APPEND); + } + /** @param associative-array $arg */ private function parse_arg($arg) { $this->quiet = $this->quiet || isset($arg["quiet"]); diff --git a/lib/getopt.php b/lib/getopt.php index 392a9681d..86eb32f41 100644 --- a/lib/getopt.php +++ b/lib/getopt.php @@ -410,15 +410,16 @@ static function value_allowed($s) { } /** @param list $argv + * @param ?int $first_arg * @return array> */ - function parse($argv) { + function parse($argv, $first_arg = null) { $res = []; $rest = []; $pot = 0; $active_po = null; $oname = $name = ""; $odone = false; - for ($i = 1; $i !== count($argv); ++$i) { + for ($i = $first_arg ?? 1; $i !== count($argv); ++$i) { $arg = $argv[$i]; $po = null; $wantpo = $value = false; diff --git a/lib/mailer.php b/lib/mailer.php index ea89422e5..fc57d1c49 100644 --- a/lib/mailer.php +++ b/lib/mailer.php @@ -286,9 +286,9 @@ function kw_passwordlink($args, $isbool, $uf) { } else { $capinfo->set_user($this->recipient)->set_token_pattern("hcpw0[20]"); } - $capinfo->set_expires_after(259200); - $token = $capinfo->create(); - $this->preparation->reset_capability = $token; + $capinfo->set_expires_after(259200)->insert(); + assert($capinfo->stored()); + $this->preparation->reset_capability = $capinfo->salt; } $token = $this->censor ? "HIDDEN" : $this->preparation->reset_capability; return $this->conf->hoturl_raw("resetpassword", null, Conf::HOTURL_ABSOLUTE | Conf::HOTURL_NO_DEFAULTS) . "/" . urlencode($token); diff --git a/lib/qrequest.php b/lib/qrequest.php index f3415af06..1559892a2 100644 --- a/lib/qrequest.php +++ b/lib/qrequest.php @@ -776,6 +776,34 @@ function maybe_post_value() { return ".empty"; } } + + + /** @return array */ + function debug_json() { + $a = []; + foreach ($this->_v as $k => $v) { + if ($v === "__array__" && ($av = $this->_a[$k] ?? null) !== null) { + if (count($av) > 20) { + $av = array_slice($av, 0, 20); + $av[] = "..."; + } + $a[$k] = $av; + } else if ($v !== null) { + if (strlen($v) > 120) { + $v = substr($v, 0, 117) . "..."; + } + $a[$k] = $v; + } + } + foreach ($this->_files as $k => $v) { + $fv = ["name" => $v->name, "type" => $v->type, "size" => $v->size]; + if ($v->error) { + $fv["error"] = $v->error; + } + $a[$k] = $v; + } + return $a; + } } class QrequestFile { @@ -801,18 +829,4 @@ function __construct($a) { $this->content = $a["content"] ?? null; $this->error = $a["error"] ?? 0; } - - /** @return array{name:string,type:string,size:int,tmp_name?:string,content?:string,error:int} - * @deprecated */ - function as_array() { - $a = ["name" => $this->name, "type" => $this->type, "size" => $this->size]; - if ($this->tmp_name !== null) { - $a["tmp_name"] = $this->tmp_name; - } - if ($this->content !== null) { - $a["content"] = $this->content; - } - $a["error"] = $this->error; - return $a; - } } diff --git a/scripts/script.js b/scripts/script.js index 39382d64a..fea55fcc4 100644 --- a/scripts/script.js +++ b/scripts/script.js @@ -4425,7 +4425,7 @@ function foldup(evt, opts) { } } if (!("open" in opts) - && (this.tagName === "INPUT" || this.tagName === "SELECT")) { + && (this.tagName === "INPUT" || this.tagName === "SELECT" || this.tagName === "TEXTAREA")) { var value = null; if (this.type === "checkbox") { opts.open = this.checked; @@ -4435,9 +4435,12 @@ function foldup(evt, opts) { value = this.value; } else if (this.type === "select-one") { value = this.selectedIndex < 0 ? "" : this.options[this.selectedIndex].value; + } else if (this.type === "text" || this.type === "textarea") { + opts.open = this.value !== ""; } if (value !== null) { - var values = (e.getAttribute("data-" + foldname + "-values") || "").split(/\s+/); + var vstr = e.getAttribute("data-" + foldname + "-values") || "", + values = $.trim(vstr) === "" ? [] : vstr.split(/\s+/); opts.open = values.indexOf(value) >= 0; } } @@ -5266,7 +5269,7 @@ hotcrp.monitor_job = function (jobid, statuselt) { } if (data.progress != null || data.status === "done") { let ex = statuselt.firstElementChild; - while (ex && ex.nodeName !== "P" && ex.nodeName !== "PROGRESS") { + while (ex && !hasClass(ex, "is-job-progress") && ex.nodeName !== "PROGRESS") { ex = ex.nextElementSibling; } if (!ex || ex.nodeName !== "PROGRESS") { @@ -5286,11 +5289,11 @@ hotcrp.monitor_job = function (jobid, statuselt) { } if (data.progress && data.progress !== true) { let ex = statuselt.firstElementChild; - while (ex && ex.nodeName !== "P") { + while (ex && !hasClass(ex, "is-job-progress")) { ex = ex.nextElementSibling; } if (!ex) { - ex = $e("p", "mb-0"); + ex = $e("p", "mb-0 is-job-progress"); statuselt.appendChild(ex); } ex.replaceChildren($e("strong", null, "Status:"), " " + data.progress.replace(/\.*$/, "...")); @@ -6284,7 +6287,7 @@ function revrating_key(evt) { function make_review_h2(rrow, rlink, rdesc) { let h2 = $e("h2"), ma, rd = $e("span"); - if (rrow.folded) { + if (rrow.collapsed) { ma = $e("button", {type: "button", "class": "qo ui js-foldup", "data-fold-target": 20}, make_expander_element(20)); } else { ma = $e("a", {href: hoturl("review", rlink), "class": "qo"}); @@ -6294,7 +6297,7 @@ function make_review_h2(rrow, rlink, rdesc) { ma.append(rd); h2.append(ma); if (rrow.editable) { - if (rrow.folded) { + if (rrow.collapsed) { ma = $e("a", {href: hoturl("review", rlink), "class": "qo"}); h2.append(" ", ma); } else { @@ -6362,10 +6365,12 @@ hotcrp.add_review = function (rrow) { rdesc = "Draft " + rdesc; if (rrow.ordinal) rdesc += " #" + rid; + if (rrow.folded && rrow.collapsed == null) /* XXX */ + rrow.collapsed = rrow.folded; earticle = document.createElement("article"); earticle.id = "r" + rid; - earticle.className = "pcard revcard need-anchor-unfold has-fold fold20" + (rrow.folded ? "c" : "o"); + earticle.className = "pcard revcard need-anchor-unfold has-fold fold20" + (rrow.collapsed ? "c" : "o"); earticle.setAttribute("data-pid", rrow.pid); earticle.setAttribute("data-rid", rrow.rid); rrow.ordinal && earticle.setAttribute("data-review-ordinal", rrow.ordinal); @@ -7238,6 +7243,8 @@ function cmt_save_callback(cj) { if (!data.cmt && editing_response) { data.cmt = {is_new: true, response: cj.response, editable: true}; } + if (data.cmt && data.cmt.folded && data.cmt.collapsed == null) /* XXX */ + data.cmt.collapsed = data.cmt.folded; var new_cid = data.cmt ? cj_cid(data.cmt) : null; if (new_cid) { cmts[new_cid] = data.cmt; @@ -7329,7 +7336,7 @@ function cmt_button_click(evt) { evt.preventDefault(); cmt_save(this, "submit"); } else if (this.name === "cancel") { - cj.folded && fold(this.closest(".cmtcard"), true, 20); + cj.collapsed && fold(this.closest(".cmtcard"), true, 20); cmt_render(cj, false); } else if (this.name === "delete") { override_deadlines.call(this, function () { @@ -7388,7 +7395,7 @@ function cmt_render(cj, editing) { if (cj.response && cj.text !== false) { const h2 = $e("h2"); let cnc = h2; - if (cj.folded && !editing) { + if (cj.collapsed && !editing) { cnc = $e("button", {type: "button", "class": "qo ui js-foldup", "data-fold-target": 20}, make_expander_element(20)); } else if (cj.editable && !editing) { cnc = $e("button", {type: "button", "class": "qo ui cmteditor"}); @@ -7396,7 +7403,7 @@ function cmt_render(cj, editing) { h2 === cnc || h2.append(cnc); cnc.append($e("span", "cmtcard-header-name", cj_name(cj))); if (cj.editable && !editing) { - if (cj.folded) { + if (cj.collapsed) { cnc = $e("button", {type: "button", "class": "qo ui cmteditor"}); h2.append(" ", cnc); } @@ -7508,6 +7515,8 @@ function cmt_render_preview(evt, format, value, dest) { function add_comment(cj, editing) { var cid = cj_cid(cj), celt = $$(cid); + if (cj.folded && cj.collapsed == null) /* XXX */ + cj.collapsed = cj.folded; cmts[cid] = cj; if (editing == null && cj.response @@ -7517,9 +7526,9 @@ function add_comment(cj, editing) { && hotcrp.status.myperm.is_author) { editing = 2; } - if (cj.folded + if (cj.collapsed && cj.text === false) { - cj.folded = false; + cj.collapsed = false; } if (celt) { cmt_render(cj, editing); @@ -7557,7 +7566,7 @@ function add_new_comment_button(cj, cid) { function add_new_comment(cj, cid) { document.querySelector(".pcontainer").insertBefore($e("article", { - id: cid, "class": "pcard cmtcard cmtid comment need-anchor-unfold has-fold ".concat(cj.folded ? "fold20c" : "fold20o", cj.editable ? " editable" : "") + id: cid, "class": "pcard cmtcard cmtid comment need-anchor-unfold has-fold ".concat(cj.collapsed ? "fold20c" : "fold20o", cj.editable ? " editable" : "") }), $$("k-comment-actions")); } @@ -12986,7 +12995,7 @@ handle_ui.on("js-edit-namedsearches", function () { }, svge_use_licon("trash"))); qentry.append($e("textarea", { id: "k-named_search/" + count + "/search", - "name": "named_search/" + count + "/search", + name: "named_search/" + count + "/search", "class": "editsearches-query need-autogrow w-99", rows: 1, cols: 64, placeholder: "(All)" }, f.q)) @@ -13023,16 +13032,16 @@ handle_ui.on("js-edit-namedsearches", function () { } } function ondelete() { - var $x = $(this).closest(".editsearches-search"); - if ($x[0].hasAttribute("data-search-new")) - $x.addClass("hidden"); - else { - $x.find(".editsearches-query").closest(".entryi").addClass("hidden"); - $x.find(".editsearches-name").prop("disabled", true).css("text-decoration", "line-through"); - $x.find(".delete-link").prop("disabled", true); - $x.append(render_feedback_list([{status: 1, message: "<0>This named search will be deleted."}])); + var fs = this.closest("fieldset"); + if (fs.hasAttribute("data-search-new")) { + addClass(fs, "hidden"); + } else { + $(fs).find(".editsearches-query").closest(".entryi").addClass("hidden"); + $(fs).find(".editsearches-name").prop("disabled", true).css("text-decoration", "line-through"); + $(fs).find(".delete-link").prop("disabled", true); + fs.append(render_feedback_list([{status: 1, message: "<0>This named search will be deleted."}])); } - $x.append(hidden_input("named_search/" + $x.data("searchNumber") + "/delete", 1)); + fs.append(hidden_input("named_search/" + fs.getAttribute("data-search-number") + "/delete", 1)); } function submit(evt) { evt.preventDefault(); diff --git a/src/api/api_job.php b/src/api/api_job.php index ba43d35bb..302c8f242 100644 --- a/src/api/api_job.php +++ b/src/api/api_job.php @@ -12,7 +12,11 @@ static function job(Contact $user, Qrequest $qreq) { return JsonResult::make_parameter_error("job"); } - $tok = Job_Capability::find($user->conf, $jobid); + try { + $tok = Job_Capability::find($user->conf, $jobid); + } catch (CommandLineException $ex) { + $tok = null; + } if (!$tok) { return new JsonResult(404, ["ok" => false]); } diff --git a/src/api/api_searchconfig.php b/src/api/api_searchconfig.php index c6871fb7e..9b9b58bb7 100644 --- a/src/api/api_searchconfig.php +++ b/src/api/api_searchconfig.php @@ -25,7 +25,10 @@ static function viewoptions(Contact $user, Qrequest $qreq) { $want = join(" ", $parsed_view); if ($want !== $pl->unparse_baseline_view()) { - $user->conf->save_setting("{$report}display_default", 1, join(" ", $parsed_view)); + $user->conf->save_setting("{$report}display_default", 1, $want); + if (strpos($want, "show:show") !== false) { + error_log("saving with show:show: " . json_encode($qreq->debug_json())); + } } else { $user->conf->save_setting("{$report}display_default", null); } @@ -261,11 +264,11 @@ static function save_namedsearch(Contact $user, Qrequest $qreq) { $ssjs = $user->conf->named_searches(); foreach ($ssjs as $sj) { $sj->id = $sj->name; + $sj->fidx = null; } $tagger = new Tagger($user); // determine new formula set from request - $id2idx = []; $msgset = new MessageSet; for ($fidx = 1; isset($qreq["named_search/{$fidx}/id"]); ++$fidx) { $id = $qreq["named_search/{$fidx}/id"]; @@ -281,16 +284,20 @@ static function save_namedsearch(Contact $user, Qrequest $qreq) { $pfx = $name === "" ? "" : "{$name}: "; // find matching search - $sidx = 0; - while ($sidx !== count($ssjs) - && strcasecmp($id, $ssjs[$sidx]->id) !== 0) { - ++$sidx; + if ($id === "new") { + $sidx = count($ssjs); + } else { + $sidx = 0; + while ($sidx !== count($ssjs) + && strcasecmp($id, $ssjs[$sidx]->id) !== 0) { + ++$sidx; + } } if ($sidx === count($ssjs)) { - if ($id !== "new") { - $msgset->error_at("named_search/{$fidx}/name", "<0>{$pfx}This search has been deleted"); + if ($deleted || ($name === "" && $q === "")) { continue; - } else if ($deleted || ($name === "" && $q === "")) { + } else if ($id !== "new") { + $msgset->error_at("named_search/{$fidx}/name", "<0>{$pfx}This search has been deleted"); continue; } } @@ -312,7 +319,7 @@ static function save_namedsearch(Contact $user, Qrequest $qreq) { continue; } - // complain about stuff + // check for errors or create if ($q === "") { $msgset->error_at("named_search/{$fidx}/search", "<0>{$pfx}Search required"); } else if ($name === "") { @@ -333,14 +340,18 @@ static function save_namedsearch(Contact $user, Qrequest $qreq) { } // check for duplicate names - $names = []; + $fidx_by_name = []; foreach ($ssjs as $sj) { $name = strtolower($sj->name); - if (isset($names[$name])) { - $msgset->error_at("named_search/{$sj->fidx}/name", "<0>Search name ‘{$sj->name}’ is not unique"); - $msgset->error_at("named_search/" . $names[$name] . "/name"); + if (array_key_exists($name, $fidx_by_name)) { + if (isset($sj->fidx)) { + $msgset->error_at("named_search/{$sj->fidx}/name", "<0>Search name ‘{$sj->name}’ is not unique"); + } + if (isset($fidx_by_name[$name])) { + $msgset->error_at("named_search/{$fidx_by_name[$name]}/name", "<0>Search name ‘{$sj->name}’ is not unique"); + } } else { - $names[$name] = $sj->fidx; + $fidx_by_name[$name] = $sj->fidx; } } diff --git a/src/api/api_upload.php b/src/api/api_upload.php index ef4ae2da1..97b06e383 100644 --- a/src/api/api_upload.php +++ b/src/api/api_upload.php @@ -120,7 +120,8 @@ private function assign_token() { $this->_cap->set_salt("hcup" . base48_encode(random_bytes(12))); if (($handle = fopen($this->segment_file(), "x"))) { fclose($handle); - if ($this->_cap->create()) { + $this->_cap->insert(); + if ($this->_cap->stored()) { return true; } unlink($this->segment_file()); @@ -161,9 +162,8 @@ function exec_start(Contact $user, Qrequest $qreq, ?PaperInfo $prow) { } else if ($size > $this->max_size) { return self::_make_simple_error(400, "<0>`size` too large") + ["maxsize" => $this->max_size]; } - $this->_cap = new TokenInfo($this->conf, TokenInfo::UPLOAD); - $this->_cap->set_user($user)->set_expires_after(7200); - $this->_cap->paperId = $prow ? $prow->paperId : 0; + $this->_cap = (new TokenInfo($this->conf, TokenInfo::UPLOAD)) + ->set_user($user)->set_paper($prow)->set_expires_after(7200); if (isset($qreq->filename) && strlen($qreq->filename) <= 255 && is_valid_utf8($qreq->filename)) { diff --git a/src/autoassigner.php b/src/autoassigner.php index ffba62436..7c601ebb8 100644 --- a/src/autoassigner.php +++ b/src/autoassigner.php @@ -475,6 +475,7 @@ final protected function set_aapaper_ndesired($pid, $ndesired) { final protected function make_ae() { gc_collect_cycles(); + $this->mark_progress("Initializing"); $this->ainfo = []; foreach ($this->acs as $ac) { $alist = []; diff --git a/src/capabilities/cap_authorview.php b/src/capabilities/cap_authorview.php index 6866b0c2a..0b681a33b 100644 --- a/src/capabilities/cap_authorview.php +++ b/src/capabilities/cap_authorview.php @@ -26,10 +26,11 @@ static function make($prow) { Dbl::free($result); // create new token if (!$prow->_author_view_token || !$prow->_author_view_token->salt) { - $tok = new TokenInfo($prow->conf, TokenInfo::AUTHORVIEW); - $tok->paperId = $prow->paperId; - $tok->set_token_pattern("hcav{$prow->paperId}[16]"); - if ($tok->create()) { + $tok = (new TokenInfo($prow->conf, TokenInfo::AUTHORVIEW)) + ->set_paper($prow) + ->set_token_pattern("hcav{$prow->paperId}[16]") + ->insert(); + if ($tok->stored()) { $prow->_author_view_token = $tok; } } diff --git a/src/capabilities/cap_job.php b/src/capabilities/cap_job.php index f23739bc9..4f2fc2c90 100644 --- a/src/capabilities/cap_job.php +++ b/src/capabilities/cap_job.php @@ -70,7 +70,6 @@ static function claim(Conf $conf, $salt, $command = null) { static function run_live(TokenInfo $tok, ?Qrequest $qreq = null, $redirect_uri = null) { assert(self::validate($tok, null)); $batch_class = $tok->input("batch_class"); - $argv = $tok->input("argv"); $status = "done"; $detacher = function () use (&$status, $qreq, $redirect_uri) { @@ -89,7 +88,16 @@ static function run_live(TokenInfo $tok, ?Qrequest $qreq = null, $redirect_uri = putenv("HOTCRP_JOB={$tok->salt}"); try { - $x = call_user_func("{$batch_class}_Batch::make_args", $tok->input("argv"), $detacher); + $argv = [$batch_class]; + if (($confid = $tok->conf->opt("confid"))) { + // The `-n` option is not normally needed: the batch class + // calls initialize_conf, which does nothing as Conf::$main + // is already initialized. But we should include it anyway + // for consistency. + $argv[] = "-n{$confid}"; + } + array_push($argv, ...$tok->input("argv")); + $x = call_user_func("{$batch_class}_Batch::make_args", $argv, $detacher); $x->run(); } catch (CommandLineException $ex) { } @@ -97,4 +105,48 @@ static function run_live(TokenInfo $tok, ?Qrequest $qreq = null, $redirect_uri = putenv("HOTCRP_JOB="); return $status; } + + /** @return int */ + static function run_background(TokenInfo $tok) { + assert(self::validate($tok, null)); + $batch_class = $tok->input("batch_class"); + $f = strtolower($batch_class) . "_batch.php"; + $paths = SiteLoader::expand_includes(SiteLoader::$root, $f, ["autoload" => true]); + if (count($paths) !== 1 + || ($s = file_get_contents($paths[0], false, null, 0, 1024)) === false + || !preg_match('/\A[^\n]*\/\*\{hotcrp\s*([^\n]*?)\}\*\//', $s, $m) + || !preg_match("/(?:\\A|\\s){$batch_class}_Batch(?:\\s|\\z)/", $m[1])) { + return -1; + } + + $cmd = []; + $cmd[] = self::shell_quote_light($tok->conf->opt("phpCommand") ?? "php"); + $cmd[] = self::shell_quote_light($paths[0]); + if (($confid = $tok->conf->opt("confid"))) { + $cmd[] = self::shell_quote_light("-n{$confid}"); + } + foreach ($tok->input("argv") as $w) { + $cmd[] = self::shell_quote_light($w); + } + + $env = getenv(); + $env["HOTCRP_JOB"] = $tok->salt; + $env["HOTCRP_EXEC_MODE"] = "background"; + + $redirect = PHP_VERSION_ID >= 70400 ? ["redirect", 1] : ["file", "/dev/null", "a"]; + $p = proc_open(join(" ", $cmd), + [["file", "/dev/null", "r"], ["file", "/dev/null", "a"], $redirect], + $pipes, + SiteLoader::$root, + $env); + return proc_close($p); + } + + static function shell_quote_light($word) { + if (preg_match('/\A[-_.,:+\/a-zA-Z0-9][-_.,:=+\/a-zA-Z0-9~]*\z/', $word)) { + return $word; + } else { + return escapeshellarg($word); + } + } } diff --git a/src/capabilities/cap_reviewaccept.php b/src/capabilities/cap_reviewaccept.php index def1b9274..058a59bbe 100644 --- a/src/capabilities/cap_reviewaccept.php +++ b/src/capabilities/cap_reviewaccept.php @@ -23,19 +23,16 @@ static function make($rrow, $create) { Dbl::free($result); if (!$tok && $create) { - $tok = new TokenInfo($rrow->conf, TokenInfo::REVIEWACCEPT); - $tok->paperId = $rrow->paperId; - $tok->reviewId = $rrow->reviewId; - $tok->contactId = $rrow->contactId; - $tok->set_invalid_after(2592000); /* 30 days */ - $tok->set_expires_after(5184000); /* 60 days */ - $tok->set_token_pattern("hcra{$rrow->reviewId}[16]"); - if (!$tok->create()) { - return null; - } + $tok = (new TokenInfo($rrow->conf, TokenInfo::REVIEWACCEPT)) + ->set_review($rrow) + ->set_user_id($rrow->contactId) + ->set_invalid_after(2592000 /* 30 days */) + ->set_expires_after(5184000 /* 60 days */) + ->set_token_pattern("hcra{$rrow->reviewId}[16]") + ->insert(); } - return $tok; + return $tok && $tok->stored() ? $tok : null; } static function apply_review_acceptor(Contact $user, $uf) { diff --git a/src/commentinfo.php b/src/commentinfo.php index d3efceebd..f890c7a0b 100644 --- a/src/commentinfo.php +++ b/src/commentinfo.php @@ -209,7 +209,7 @@ static function script($prow) { $j["done"] = $rrd->done; } } - $t[] = "hotcrp.set_response_round(" . json_encode($rrd->name) . "," . json_encode($j) . ")"; + $t[] = "hotcrp.set_response_round(" . json_encode_browser($rrd->name) . "," . json_encode_browser($j) . ")"; } Icons::stash_licon("ui_tag"); Icons::stash_licon("ui_attachment"); @@ -290,6 +290,38 @@ function mtime(Contact $viewer) { } } + /** @return string */ + function raw_contents() { + return $this->commentOverflow ?? $this->comment ?? ""; + } + + /** @param ?Contact $viewer + * @param bool $censor_mentions + * @param ?int $censor_mentions_after + * @return string */ + function contents($viewer = null, $censor_mentions = false, $censor_mentions_after = null) { + $t = $this->raw_contents(); + if ($t === "" + || !$censor_mentions + || !($mx = $this->data("mentions")) + || !is_array($mx)) { + return $t; + } + $delta = 0; + foreach ($mx as $m) { + if (is_array($m) + && count($m) >= 4 + && $m[3] + && (!$viewer || $m[0] !== $viewer->contactId) + && ($censor_mentions_after === null || $m[1] + $delta < $censor_mentions_after)) { + $r = $this->prow->unparse_pseudonym($viewer, $m[0]) ?? "Anonymous"; + $t = substr_replace($t, "@{$r}", $m[1] + $delta, $m[2] - $m[1]); + $delta += strlen($r) - ($m[2] - $m[1] - 1); + } + } + return $t; + } + /** @return object */ private function make_data() { if ($this->_jdata === null) { @@ -316,7 +348,7 @@ function set_data($key, $value) { } else { $this->_jdata->$key = $value; } - $s = json_encode($this->_jdata); + $s = json_encode_db($this->_jdata); $this->commentData = $s === "{}" ? null : $s; } @@ -396,13 +428,15 @@ static function group_by_identity($crows, Contact $viewer, $separateColors) { private function unparse_commenter_pseudonym(Contact $viewer) { if (($this->commentType & self::CT_BYAUTHOR_MASK) !== 0) { return "Author"; - } else if (($this->commentType & (self::CTVIS_MASK | self::CT_BYSHEPHERD)) === (self::CTVIS_AUTHOR | self::CT_BYSHEPHERD)) { + } else if (($this->commentType & (self::CTVIS_MASK | self::CT_BYSHEPHERD)) === (self::CTVIS_AUTHOR | self::CT_BYSHEPHERD) + && $this->contactId === $this->prow->shepherdContactId) { return "Shepherd"; } else if (($rrow = $this->prow->review_by_user($this->contactId)) && $rrow->reviewOrdinal && $viewer->can_view_review_assignment($this->prow, $rrow)) { return "Reviewer " . unparse_latin_ordinal($rrow->reviewOrdinal); - } else if (($this->commentType & self::CT_BYSHEPHERD) !== 0) { + } else if (($this->commentType & self::CT_BYSHEPHERD) !== 0 + && $this->contactId === $this->prow->shepherdContactId) { return "Shepherd"; } else if (($this->commentType & self::CT_BYADMINISTRATOR) !== 0) { return "Administrator"; @@ -522,33 +556,6 @@ function attachment_ids() { return $this->attachments()->document_ids(); } - /** @param ?Contact $viewer - * @param bool $censor_mentions - * @param ?int $censor_mentions_after - * @return string */ - function contents($viewer = null, $censor_mentions = false, $censor_mentions_after = null) { - $t = $this->commentOverflow ?? $this->comment ?? ""; - if ($t === "" - || !$censor_mentions - || !($mx = $this->data("mentions")) - || !is_array($mx)) { - return $t; - } - $delta = 0; - foreach ($mx as $m) { - if (is_array($m) - && count($m) >= 4 - && $m[3] - && (!$viewer || $m[0] !== $viewer->contactId) - && ($censor_mentions_after === null || $m[1] + $delta < $censor_mentions_after)) { - $r = $this->prow->unparse_pseudonym($viewer, $m[0]) ?? "Anonymous"; - $t = substr_replace($t, "@{$r}", $m[1] + $delta, $m[2] - $m[1]); - $delta += strlen($r) - ($m[2] - $m[1] - 1); - } - } - return $t; - } - /** @param bool $editable * @return list */ function attachments_json($editable = false) { @@ -602,7 +609,7 @@ function unparse_json(Contact $viewer) { if (($this->commentType & self::CT_DRAFT) !== 0) { $cj->draft = true; if (!$this->prow->has_author($viewer)) { - $cj->folded = true; + $cj->collapsed = $cj->folded /* XXX */ = true; } } if (($this->commentType & self::CT_RESPONSE) !== 0) { @@ -689,7 +696,7 @@ function unparse_json(Contact $viewer) { } } else { $cj->text = false; - $cj->word_count = count_words($this->commentOverflow ?? $this->comment); + $cj->word_count = count_words($this->raw_contents()); } return $cj; @@ -866,10 +873,13 @@ function save_comment($req, Contact $acting_user) { // notifications $displayed = ($ctype & self::CT_DRAFT) === 0; - // text + // text, mentions $text = $req["text"] ?? null; + $desired_mentions = []; if ($text !== false) { $text = (string) $text; + $desired_mentions = $this->analyze_mentions($user, $text, $ctype); + $this->set_data("mentions", empty($desired_mentions) ? null : $desired_mentions); } // query @@ -977,7 +987,7 @@ function save_comment($req, Contact $acting_user) { } $ch = []; if ($this->commentId - && $text !== ($this->commentOverflow ?? $this->comment)) { + && $text !== $this->raw_contents()) { $ch[] = "text"; } if ($this->commentId @@ -1051,8 +1061,8 @@ function save_comment($req, Contact $acting_user) { if ($displayed && $this->commentId && ($this->commentType & self::CTVIS_MASK) > self::CTVIS_ADMINONLY - && strpos($text, "@") !== false) { - $this->analyze_mentions($user); + && !empty($desired_mentions)) { + $this->inform_mentions($desired_mentions); } if ($this->timeNotified === $this->timeModified) { @@ -1076,25 +1086,28 @@ private function notification($user, $types) { return $n; } - /** @param Contact $user */ - private function analyze_mentions($user) { - // enumerate desired mentions and save them - $desired_mentions = []; - $text = $this->commentOverflow ?? $this->comment; - foreach (MentionParser::parse($text, ...Completion_API::mention_lists($user, $this->prow, $this->commentType & self::CTVIS_MASK, Completion_API::MENTION_PARSE)) as $mpx) { + /** @param Contact $user + * @param string $text + * @param int $ctype + * @return list */ + private function analyze_mentions($user, $text, $ctype) { + if (strpos($text, "@") === false) { + return []; + } + $dm = []; + foreach (MentionParser::parse($text, ...Completion_API::mention_lists($user, $this->prow, $ctype & self::CTVIS_MASK, Completion_API::MENTION_PARSE)) as $mpx) { $named = $mpx[0] instanceof Contact || $mpx[0]->status !== Author::STATUS_ANONYMOUS_REVIEWER; - $desired_mentions[] = [$mpx[0]->contactId, $mpx[1], $mpx[2], $named]; - $this->conf->prefetch_user_by_id($mpx[0]->contactId); + $dm[] = [$mpx[0]->contactId, $mpx[1], $mpx[2], $named]; } + return $dm; + } - $old_data = $this->commentData; - $this->set_data("mentions", empty($desired_mentions) ? null : $desired_mentions); - if ($this->commentData !== $old_data) { - $this->conf->qe("update PaperComment set commentData=? where paperId=? and commentId=?", $this->commentData, $this->paperId, $this->commentId); + /** @param list $mentions */ + private function inform_mentions($mentions) { + foreach ($mentions as $mxm) { + $this->conf->prefetch_user_by_id($mxm[0]); } - - // go over mentions, send email - foreach ($desired_mentions as $mxm) { + foreach ($mentions as $mxm) { $mentionee = $this->conf->user_by_id($mxm[0], USER_SLICE); if (!$mentionee) { continue; @@ -1110,7 +1123,8 @@ private function analyze_mentions($user) { "comment_row" => $this ]); if (!$mxm[3]) { - $notification->user_html = htmlspecialchars(substr($text, $mxm[1] + 1, $mxm[2] - $mxm[1] - 1)); + $n = substr($this->raw_contents(), $mxm[1] + 1, $mxm[2] - $mxm[1] - 1); + $notification->user_html = htmlspecialchars($n); } } } diff --git a/src/conference.php b/src/conference.php index 75ff1c6e0..d592c19ce 100644 --- a/src/conference.php +++ b/src/conference.php @@ -356,7 +356,7 @@ function __load_settings() { function load_settings() { $this->__load_settings(); - if ($this->sversion < 293) { + if ($this->sversion < 295) { $old_nerrors = Dbl::$nerrors; while ((new UpdateSchema($this))->run()) { usleep(50000); @@ -5572,7 +5572,7 @@ private function clean_tokens() { $ct_cleanups[$tf->type] = true; } if (!empty($ct_cleanups)) { - $result = TokenInfo::expired_tokens_result($this, array_keys($ct_cleanups)); + $result = TokenInfo::expired_result($this, array_keys($ct_cleanups)); while (($tok = TokenInfo::fetch($result, $this, false))) { if (($tf = $this->token_type($tok->capabilityType)) && isset($tf->cleanup_function)) diff --git a/src/formulagraph.php b/src/formulagraph.php index c4c9f5f21..1bb959afc 100644 --- a/src/formulagraph.php +++ b/src/formulagraph.php @@ -546,7 +546,7 @@ private function _cdf_data(PaperInfoSet $rowset) { $qcolors = $this->_qstyles; foreach ($need_anal as $qi => $na) { - if ($na && $has_color[$qi] >= 5 * $no_color[$qi]) { + if ($na && $has_color[$qi] && $has_color[$qi] >= 5 * $no_color[$qi]) { $qcolors[$qi] = join(" ", $qcolorset[$qi]); } } diff --git a/src/init.php b/src/init.php index f169c9c2a..c54441ef7 100644 --- a/src/init.php +++ b/src/init.php @@ -78,6 +78,15 @@ if (function_exists("pcntl_signal")) { pcntl_signal(SIGPIPE, SIG_DFL); } + if (getenv("HOTCRP_EXEC_MODE") === "background" + && function_exists("pcntl_fork")) { + if (function_exists("posix_setsid")) { + posix_setsid(); + } + if (pcntl_fork() > 0) { + exit(0); + } + } } diff --git a/src/multiconference.php b/src/multiconference.php index 9458583a8..ab1b75af5 100644 --- a/src/multiconference.php +++ b/src/multiconference.php @@ -168,7 +168,7 @@ static function fail(...$arg) { if ($maintenance) { $j["maintenance"] = true; } - echo json_encode($j), "\n"; + echo json_encode_browser($j), "\n"; exit; } diff --git a/src/options/o_authors.php b/src/options/o_authors.php index 0394e7239..452a90ec5 100644 --- a/src/options/o_authors.php +++ b/src/options/o_authors.php @@ -315,6 +315,13 @@ function render(FieldRender $fr, PaperValue $ov) { } } + function jsonSerialize() { + $j = parent::jsonSerialize(); + if ($this->max_count > 0) { + $j->max = $this->max_count; + } + return $j; + } function export_setting() { $sfs = parent::export_setting(); $sfs->max = $this->max_count; diff --git a/src/options/o_checkboxes.php b/src/options/o_checkboxes.php index d5e79fbf2..3f9274e32 100644 --- a/src/options/o_checkboxes.php +++ b/src/options/o_checkboxes.php @@ -20,12 +20,6 @@ function jsonSerialize() { if ($this->is_ids_nontrivial()) { $j->ids = $this->ids(); } - if ($this->min_count > 1) { - $j->min = $this->min_count; - } - if ($this->max_count > 0) { - $j->max = $this->max_count; - } return $j; } diff --git a/src/options/o_checkboxesbase.php b/src/options/o_checkboxesbase.php index 6e065ef40..04129da7f 100644 --- a/src/options/o_checkboxesbase.php +++ b/src/options/o_checkboxesbase.php @@ -24,6 +24,24 @@ function __construct(Conf $conf, $args) { } + function jsonSerialize() { + $j = parent::jsonSerialize(); + if ($this->min_count > 1) { + $j->min = $this->min_count; + } + if ($this->max_count > 0) { + $j->max = $this->max_count; + } + return $j; + } + + function export_setting() { + $sfs = parent::export_setting(); + $sfs->min = $this->min_count; + $sfs->max = $this->max_count; + return $sfs; + } + /** @return TopicSet */ abstract function topic_set(); @@ -260,13 +278,6 @@ function render(FieldRender $fr, PaperValue $ov) { } } - function export_setting() { - $sfs = parent::export_setting(); - $sfs->min = $this->min_count; - $sfs->max = $this->max_count; - return $sfs; - } - function parse_search(SearchWord $sword, PaperSearch $srch) { return $this->parse_topic_set_search($sword, $srch, $this->topic_set(), true); } diff --git a/src/options/o_topics.php b/src/options/o_topics.php index 091614565..d8819f12b 100644 --- a/src/options/o_topics.php +++ b/src/options/o_topics.php @@ -13,18 +13,6 @@ function __construct(Conf $conf, $args) { } } - function jsonSerialize() { - $j = parent::jsonSerialize(); - if ($this->min_count > 1) { - $j->min = $this->min_count; - } - if ($this->max_count > 0) { - $j->max = $this->max_count; - } - return $j; - } - - function topic_set() { return $this->conf->topic_set(); } diff --git a/src/pages/p_authorize.php b/src/pages/p_authorize.php index cd409b9be..8302752b7 100644 --- a/src/pages/p_authorize.php +++ b/src/pages/p_authorize.php @@ -149,8 +149,8 @@ private function handle_request(OAuthClient $client) { ->change_data("state", $this->qreq->state) ->change_data("nonce", $this->qreq->nonce) ->change_data("client_id", $client->client_id) - ->change_data("redirect_uri", $this->qreq->redirect_uri); - $this->token->create(); + ->change_data("redirect_uri", $this->qreq->redirect_uri) + ->insert(); $this->client = $client; $this->qreq->print_header("Sign in", "authorize", ["action_bar" => "", "hide_header" => true, "body_class" => "body-signin"]); diff --git a/src/pages/p_autoassign.php b/src/pages/p_autoassign.php index 506642e95..72637f8b5 100644 --- a/src/pages/p_autoassign.php +++ b/src/pages/p_autoassign.php @@ -15,8 +15,6 @@ class Autoassign_Page { public $ms; /** @var string */ public $jobid; - /** @var bool */ - private $detached = false; function __construct(Contact $user, Qrequest $qreq) { assert($user->is_manager()); @@ -469,7 +467,7 @@ function redirect_uri() { function start_job() { // prepare arguments for batch autoassigner $qreq = $this->qreq; - $argv = ["batch/autoassign", "-q" . $this->ssel->unparse_search(), "-t" . $qreq->t]; + $argv = ["-q" . $this->ssel->unparse_search(), "-t" . $qreq->t]; if ($qreq->pctyp === "sel") { $pcsel = []; @@ -513,10 +511,11 @@ function start_job() { $argmap->$k1 = $k; } - $tok = Job_Capability::make($this->user, "Autoassign", ["batch/autoassign.php", "-je", "-D"]) + $tok = Job_Capability::make($this->user, "Autoassign", ["-je", "-D"]) ->set_input("assign_argv", $argv) - ->set_input("argmap", $argmap); - $this->jobid = $tok->create(); + ->set_input("argmap", $argmap) + ->insert(); + $this->jobid = $tok->salt; assert($this->jobid !== null); $s = Job_Capability::run_live($tok, $this->qreq, [$this, "redirect_uri"]); @@ -649,7 +648,7 @@ private function handle_in_progress(TokenInfo $tok) { '

Preparing assignment

'; echo Ht::feedback_msg($this->ms); if (($s = $tok->data("progress"))) { - echo '

Status: ', htmlspecialchars($s), '

'; + echo '

Status: ', htmlspecialchars($s), '

'; } echo '', Ht::unstash_script("hotcrp.monitor_autoassignment(" . json_encode_browser($this->jobid) . ")"); diff --git a/src/pages/p_oauth.php b/src/pages/p_oauth.php index 32a2a078b..ff7750939 100644 --- a/src/pages/p_oauth.php +++ b/src/pages/p_oauth.php @@ -102,8 +102,8 @@ function start() { "redirect" => $this->qreq->redirect, "site_uri" => $this->conf->opt("paperSite"), "nonce" => $nonce - ]); - if ($tok->create()) { + ])->insert(); + if ($tok->stored()) { $params = "client_id=" . urlencode($authi->client_id) . "&response_type=code" . "&scope=" . rawurlencode($authi->scope ?? "openid email profile") diff --git a/src/pages/p_profile.php b/src/pages/p_profile.php index 4e17105fb..81b0b2a99 100644 --- a/src/pages/p_profile.php +++ b/src/pages/p_profile.php @@ -201,9 +201,10 @@ private function save_user($ustatus, $acct) { $capability->set_user($acct) ->set_token_pattern("hcce[20]") ->set_expires_after(259200) - ->assign_data(["oldemail" => $acct->email, "uemail" => $ustatus->jval->email]); - if (($token = $capability->create())) { - $rest = ["capability_token" => $token, "sensitive" => true]; + ->assign_data(["oldemail" => $acct->email, "uemail" => $ustatus->jval->email]) + ->insert(); + if ($capability->stored()) { + $rest = ["capability_token" => $capability->salt, "sensitive" => true]; $mailer = new HotCRPMailer($this->conf, $acct, $rest); $prep = $mailer->prepare("@changeemail", $rest); } else { diff --git a/src/pages/p_search.php b/src/pages/p_search.php index 520af5e1f..ace2c6837 100644 --- a/src/pages/p_search.php +++ b/src/pages/p_search.php @@ -432,7 +432,6 @@ static function redisplay(Contact $user, Qrequest $qreq) { $pl->apply_view_qreq($qreq); $param = ["#" => "view"]; foreach ($pl->unparse_view(PaperList::VIEWORIGIN_SEARCH, false) as $vx) { - error_log($vx); if (str_starts_with($vx, "sort:score[")) { $param["scoresort"] = substr($vx, 11, -1); } else if (strpos($vx, "[") === false) { diff --git a/src/papersearch.php b/src/papersearch.php index 2ae505db6..a79b260b3 100644 --- a/src/papersearch.php +++ b/src/papersearch.php @@ -1170,16 +1170,20 @@ function then_term() { } /** @param ?SearchAtom $a + * @param bool $top * @return array{int,int} */ - private static function strip_show_atom($a) { + private static function strip_show_atom($a, $top) { if (!$a || ($a->kword && in_array($a->kword, ["show", "hide", "edit", "sort", "showsort", "editsort"]))) { return [0, 0]; } + if ($a->op && $a->op->type === "(" && $top && ($ch = $a->child[0] ?? null)) { + return self::strip_show_atom($ch, true); + } if (!$a->kword && $a->op && !$a->op->unary) { $pos1 = $pos2 = null; foreach ($a->child as $ch) { - $span = self::strip_show_atom($ch); + $span = self::strip_show_atom($ch, false); if ($span[0] >= $span[1]) { continue; } @@ -1201,7 +1205,7 @@ private static function strip_show_atom($a) { * @return string */ private static function strip_show($q) { $splitter = new SearchSplitter($q, 0, strlen($q)); - $span = self::strip_show_atom($splitter->parse_expression()); + $span = self::strip_show_atom($splitter->parse_expression(), true); return $span[0] < $span[1] ? substr($q, $span[0], $span[1] - $span[0]) : ""; } @@ -1213,7 +1217,7 @@ function group_anno_list() { for ($i = 0; $i !== $ng; ++$i) { $ch = $this->_then_term->group_head_term($i); $srchstr = $ch->source_subquery($this->q); - if ($ch->get_float("view")) { + if ($ch->get_float("view") || str_starts_with($srchstr, "(")) { $srchstr = self::strip_show($srchstr); } $h = $ch->get_float("legend"); diff --git a/src/papertable.php b/src/papertable.php index c88e15493..156b3919c 100644 --- a/src/papertable.php +++ b/src/papertable.php @@ -2963,7 +2963,7 @@ function print_rc($rrows, $comments) { if (($any_submitted || $rc->reviewStatus === ReviewInfo::RS_ADOPTED) && $rc->reviewStatus < ReviewInfo::RS_COMPLETED && !$this->user->is_my_review($rc)) { - $rcj->folded = true; + $rcj->collapsed = $rcj->folded /* XXX */ = true; } $s .= "hotcrp.add_review(" . json_encode_browser($rcj) . ");\n"; } else { diff --git a/src/reviewdiffinfo.php b/src/reviewdiffinfo.php index 16c35756d..1bd194e7d 100644 --- a/src/reviewdiffinfo.php +++ b/src/reviewdiffinfo.php @@ -197,7 +197,7 @@ static function unparse_patch($patch) { $upatch[$n] = $v; } } - $str = json_encode($upatch); + $str = json_encode_db($upatch); if ($bdata !== "") { $str = strlen($str) . $str . $bdata; } diff --git a/src/reviewfieldsearch.php b/src/reviewfieldsearch.php index c4ebc1020..cf1c18701 100644 --- a/src/reviewfieldsearch.php +++ b/src/reviewfieldsearch.php @@ -32,6 +32,7 @@ abstract function test_value($rrow, $fv); * @param ReviewInfo $rrow * @return bool */ final function test_review($user, $prow, $rrow) { + /** @phan-suppress-next-line PhanTypeMismatchArgument */ return $this->test_value($rrow, $rrow->fval($this->rf)); } diff --git a/src/schema.sql b/src/schema.sql index 10e4b4ae6..c824cfeac 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -36,6 +36,7 @@ CREATE TABLE `Capability` ( `inputData` varbinary(16384) DEFAULT NULL, `data` varbinary(16384) DEFAULT NULL, `outputData` longblob DEFAULT NULL, + `lookupKey` varbinary(255) DEFAULT NULL, PRIMARY KEY (`salt`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -626,7 +627,7 @@ CREATE TABLE `TopicInterest` ( -- Initial settings -- (each setting must be on its own line for createdb.sh) insert into Settings (name, value, data) values - ('allowPaperOption', 293, null), -- schema version + ('allowPaperOption', 295, null), -- schema version ('setupPhase', 1, null), -- initial user is chair ('no_papersub', 1, null), -- no submissions yet ('sub_pcconf', 1, null), -- collect PC conflicts, not collaborators diff --git a/src/tokeninfo.php b/src/tokeninfo.php index a75e49306..a31e85bab 100644 --- a/src/tokeninfo.php +++ b/src/tokeninfo.php @@ -12,13 +12,16 @@ class TokenInfo { /** @var int * @readonly */ public $capabilityType; - /** @var int */ + /** @var int + * @readonly */ public $contactId; - /** @var int */ + /** @var int + * @readonly */ public $paperId; /** @var int */ public $reviewId; - /** @var int */ + /** @var ?int + * @readonly */ public $timeCreated; /** @var int * @readonly */ @@ -39,6 +42,9 @@ class TokenInfo { public $data; /** @var ?string */ public $outputData; + /** @var ?string + * @readonly */ + public $lookupKey; /** @var ?string */ public $email; @@ -79,6 +85,11 @@ function __construct(Conf $conf, $capabilityType = null) { } } + /** @return bool */ + final function stored() { + return $this->timeCreated !== null; + } + /** return \mysqli */ private function dblink() { return $this->is_cdb ? $this->conf->contactdb() : $this->conf->dblink; @@ -87,7 +98,7 @@ private function dblink() { /** @param bool $is_cdb * @return $this * @suppress PhanAccessReadOnlyProperty */ - function set_contactdb($is_cdb) { + final function set_contactdb($is_cdb) { assert($this->_user === false && !$this->contactId); $this->is_cdb = $is_cdb; return $this; @@ -95,8 +106,8 @@ function set_contactdb($is_cdb) { /** @return $this * @suppress PhanAccessReadOnlyProperty */ - function set_user(Contact $user) { - assert(!$this->is_cdb); + final function set_user(Contact $user) { + assert(!$this->is_cdb && !$this->stored()); $this->is_cdb = false; $this->contactId = $user->contactId > 0 ? $user->contactId : 0; $this->email = $user->email; @@ -106,8 +117,8 @@ function set_user(Contact $user) { /** @return $this * @suppress PhanAccessReadOnlyProperty */ - function set_cdb_user(Contact $user) { - assert($user->contactDbId > 0); + final function set_cdb_user(Contact $user) { + assert($user->contactDbId > 0 && !$this->stored()); $this->is_cdb = true; $this->contactId = $user->contactDbId > 0 ? $user->contactDbId : 0; $this->email = $user->email; @@ -115,16 +126,43 @@ function set_cdb_user(Contact $user) { return $this; } + /** @param int $uid + * @return $this + * @suppress PhanAccessReadOnlyProperty */ + final function set_user_id($uid) { + assert(!$this->is_cdb && !$this->stored()); + $this->contactId = $uid; + return $this; + } + + /** @return $this + * @suppress PhanAccessReadOnlyProperty */ + final function set_paper(?PaperInfo $prow) { + assert(!$this->stored()); + $this->paperId = $prow ? $prow->paperId : 0; + return $this; + } + + /** @return $this + * @suppress PhanAccessReadOnlyProperty */ + final function set_review(ReviewInfo $rrow) { + assert(!$this->stored()); + $this->paperId = $rrow->paperId; + $this->reviewId = $rrow->reviewId; + return $this; + } + /** @param string $pattern * @return $this */ - function set_token_pattern($pattern) { + final function set_token_pattern($pattern) { + assert(!$this->stored()); $this->_token_pattern = $pattern; return $this; } /** @param callable(TokenInfo):bool $approver * @return $this */ - function set_token_approver($approver) { + final function set_token_approver($approver) { $this->_token_approver = $approver; return $this; } @@ -132,15 +170,24 @@ function set_token_approver($approver) { /** @param string $salt * @return $this * @suppress PhanAccessReadOnlyProperty */ - function set_salt($salt) { + final function set_salt($salt) { + assert(!$this->stored()); $this->salt = $salt; return $this; } + /** @param ?string $key + * @return $this + * @suppress PhanAccessReadOnlyProperty */ + final function set_lookup_key($key) { + $this->lookupKey = $key; + return $this; + } + /** @param int $t * @return $this * @suppress PhanAccessReadOnlyProperty */ - function set_invalid_at($t) { + final function set_invalid_at($t) { if ($t !== $this->timeInvalid) { $this->timeInvalid = $t; $this->_changes |= self::CHF_TIMES; @@ -150,12 +197,12 @@ function set_invalid_at($t) { /** @param int $seconds * @return $this */ - function set_invalid_after($seconds) { + final function set_invalid_after($seconds) { return $this->set_invalid_at(Conf::$now + $seconds); } /** @return $this */ - function set_invalid() { + final function set_invalid() { if ($this->timeInvalid <= 0 || $this->timeInvalid >= Conf::$now) { $this->set_invalid_at(Conf::$now - 1); } @@ -164,7 +211,7 @@ function set_invalid() { /** @param int $seconds * @return $this */ - function extend_validity($seconds) { + final function extend_validity($seconds) { if ($this->timeInvalid > 0 && $this->timeInvalid < Conf::$now + $seconds) { $this->set_invalid_at(Conf::$now + $seconds); } @@ -174,7 +221,7 @@ function extend_validity($seconds) { /** @param int $t * @return $this * @suppress PhanAccessReadOnlyProperty */ - function set_expires_at($t) { + final function set_expires_at($t) { if ($t !== $this->timeExpires) { $this->timeExpires = $t; $this->_changes |= self::CHF_TIMES; @@ -184,13 +231,13 @@ function set_expires_at($t) { /** @param int $seconds * @return $this */ - function set_expires_after($seconds) { + final function set_expires_after($seconds) { return $this->set_expires_at(Conf::$now + $seconds); } /** @param int $seconds * @return $this */ - function extend_expiry($seconds) { + final function extend_expiry($seconds) { if ($this->timeExpires > 0 && $this->timeExpires < Conf::$now + $seconds) { $this->set_expires_at(Conf::$now + $seconds); } @@ -200,7 +247,7 @@ function extend_expiry($seconds) { /** @param null|string|associative-array|object $data * @return $this * @suppress PhanAccessReadOnlyProperty */ - function set_input($data, $value = null) { + final function set_input($data, $value = null) { json_encode_object_change($this->inputData, $this->_jinputData, $data, $value, func_num_args()); return $this; } @@ -208,7 +255,7 @@ function set_input($data, $value = null) { /** @param null|string|associative-array|object $data * @return $this * @suppress PhanAccessReadOnlyProperty */ - function assign_data($data) { + final function assign_data($data) { if ($data !== null && !is_string($data)) { $data = json_encode_db($data); } @@ -265,23 +312,31 @@ static function find_active($token, $capabilityType, Conf $conf, $is_cdb = false /** @param list $types * @return Dbl_Result */ - static function expired_tokens_result(Conf $conf, $types) { + static function expired_result(Conf $conf, $types) { // do not load `inputData` or `outputData` return $conf->ql("select capabilityType, contactId, paperId, reviewId, timeCreated, timeUsed, timeInvalid, timeExpires, salt, `data` from Capability where timeExpires>0 and timeExpiresql("select * from Capability where (timeExpires<=0 or timeExpires>=?) and lookupKey?e", + Conf::$now, $lookup_key); + } + /** @param ?int $capabilityType * @return bool */ - function is_active($capabilityType = null) { + final function is_active($capabilityType = null) { return ($capabilityType === null || $this->capabilityType === $capabilityType) && ($this->timeExpires === 0 || $this->timeExpires > Conf::$now) && ($this->timeInvalid === 0 || $this->timeInvalid > Conf::$now); } /** @return ?Contact */ - function user() { + final function user() { if ($this->_user === false) { if ($this->contactId <= 0) { $this->_user = null; @@ -295,7 +350,7 @@ function user() { } /** @return ?Contact */ - function local_user() { + final function local_user() { if (!$this->is_cdb) { return $this->user(); } else if ($this->email !== null) { @@ -307,20 +362,20 @@ function local_user() { } /** @return string */ - function instantiate_token() { + final function instantiate_token() { return preg_replace_callback('/\[(\d+)\]/', function ($m) { return base48_encode(random_bytes(intval($m[1]))); }, $this->_token_pattern); } - /** @return ?string + /** @return $this * @suppress PhanAccessReadOnlyProperty */ - function create() { + final function insert() { + assert($this->timeCreated === null); assert($this->capabilityType > 0); $this->contactId = $this->contactId ?? 0; $this->paperId = $this->paperId ?? 0; $this->reviewId = $this->reviewId ?? 0; - $this->timeCreated = $this->timeCreated ?? Conf::$now; $this->timeUsed = $this->timeUsed ?? 0; $this->timeInvalid = $this->timeInvalid ?? 0; $this->timeExpires = $this->timeExpires ?? 0; @@ -331,9 +386,9 @@ function create() { $qf = ""; $qv = [ - null, $this->capabilityType, $this->contactId, $this->paperId, - $this->timeCreated, $this->timeUsed, $this->timeInvalid, - $this->timeExpires, $this->data + null /* salt */, null /* timeCreated */, + $this->capabilityType, $this->contactId, $this->paperId, + $this->timeUsed, $this->timeInvalid, $this->timeExpires, $this->data ]; if ($this->reviewId !== 0) { $qf .= ", reviewId"; @@ -343,13 +398,18 @@ function create() { $qf .= ", inputData"; $qv[] = $this->inputData; } + if ($this->lookupKey !== null) { + $qf .= ", lookupKey"; + $qv[] = $this->lookupKey; + } for ($tries = 0; $tries < ($need_salt ? 5 : 1); ++$tries) { if ($need_salt) { $this->salt = $this->instantiate_token(); } $qv[0] = $this->salt; - $result = Dbl::qe($this->dblink(), "insert into Capability (salt, capabilityType, contactId, paperId, timeCreated, timeUsed, timeInvalid, timeExpires, data{$qf}) values ?v", [$qv]); + $qv[1] = Conf::$now; + $result = Dbl::qe($this->dblink(), "insert into Capability (salt, timeCreated, capabilityType, contactId, paperId, timeUsed, timeInvalid, timeExpires, data{$qf}) values ?v", [$qv]); if ($result->affected_rows <= 0) { continue; } @@ -358,32 +418,40 @@ function create() { Dbl::qe($this->dblink(), "delete from Capability where salt=?", $this->salt); continue; } - $this->update(); - return $this->salt; + $this->timeCreated = $qv[1]; + $this->update(); // does nothing unless _token_approver modifies self + return $this; } if ($need_salt) { $this->salt = null; } $this->_changes = $changes; - return null; + return $this; + } + + /** @return ?string + * @deprecated */ + function create() { + $this->insert(); + return $this->timeCreated ? $this->salt : null; } /** @param ?string $key * @return mixed */ - function data($key = null) { + final function data($key = null) { $this->_jdata = $this->_jdata ?? json_decode_object($this->data); return $key ? $this->_jdata->$key ?? null : $this->_jdata; } - function load_data() { + final function load_data() { /** @phan-suppress-next-line PhanAccessReadOnlyProperty */ $this->data = Dbl::fetch_value($this->dblink(), "select `data` from Capability where salt=?", $this->salt); } /** @param ?string $key * @return mixed */ - function input($key = null) { + final function input($key = null) { $this->_jinputData = $this->_jinputData ?? json_decode_object($this->inputData); return $key ? $this->_jinputData->$key ?? null : $this->_jinputData; } @@ -391,7 +459,7 @@ function input($key = null) { /** @param ?int $within_sec * @return $this */ - function update_use($within_sec = null) { + final function update_use($within_sec = null) { if ($within_sec === null) { Conf::set_current_time(); } @@ -405,7 +473,7 @@ function update_use($within_sec = null) { /** @param ?string $data * @return $this */ - function change_data($data, $value = null) { + final function change_data($data, $value = null) { if (json_encode_object_change($this->data, $this->_jdata, $data, $value, func_num_args())) { $this->_changes |= self::CHF_DATA; } @@ -414,7 +482,7 @@ function change_data($data, $value = null) { /** @param ?string $data * @return $this */ - function change_output($data, $value = null) { + final function change_output($data, $value = null) { if (json_encode_object_change($this->outputData, $this->_joutputData, $data, $value, func_num_args())) { $this->_changes |= self::CHF_OUTPUT_DATA; } @@ -423,14 +491,14 @@ function change_output($data, $value = null) { /** @return $this * @suppress PhanAccessReadOnlyProperty */ - function unload_output() { + final function unload_output() { $this->outputData = $this->_joutputData = null; $this->_changes &= ~self::CHF_OUTPUT_DATA; return $this; } /** @return bool */ - function update() { + final function update() { assert($this->capabilityType > 0 && !!$this->salt); if (($this->_changes ?? 0) === 0) { return false; @@ -457,7 +525,7 @@ function update() { return true; } - function delete() { + final function delete() { Dbl::qe($this->dblink(), "delete from Capability where salt=?", $this->salt); } } diff --git a/src/updateschema.php b/src/updateschema.php index 8dc307a90..8743b04c1 100644 --- a/src/updateschema.php +++ b/src/updateschema.php @@ -1169,6 +1169,26 @@ private function v291_unfuck_checkboxes($options_data) { } } + private function v295_unfuck_mentions() { + $result = $this->conf->qe("select * from PaperComment where commentData like '%mention%' and (comment is null or comment not like '%@%')"); + $cleanf = Dbl::make_multi_ql_stager($this->conf->dblink); + while (($ci = $result->fetch_object())) { + $text = $ci->commentOverflow ?? $ci->comment; + if (strpos($text, "@") === false + && ($data = json_decode($ci->commentData)) + && isset($data->mentions)) { + unset($data->mentions); + $datastr = json_encode_db($data); + $cleanf("update PaperComment set commentData=? where paperId=? and commentId=?", + $datastr === "{}" ? null : $datastr, + $ci->paperId, $ci->commentId); + } + } + $result->close(); + $cleanf(null); + return true; + } + /** @return bool */ function run() { $conf = $this->conf; @@ -3006,6 +3026,14 @@ function run() { && $conf->ql_ok("alter table Paper change `withdrawReason` `withdrawReason` blob DEFAULT NULL")) { $conf->update_schema_version(293); } + if ($conf->sversion === 293 + && $conf->ql_ok("alter table Capability add `lookupKey` varbinary(255) DEFAULT NULL")) { + $conf->update_schema_version(294); + } + if ($conf->sversion === 294 + && $this->v295_unfuck_mentions()) { + $conf->update_schema_version(295); + } $conf->ql_ok("delete from Settings where name='__schema_lock'"); Conf::$main = $old_conf_g; diff --git a/src/userinfo/u_developer.php b/src/userinfo/u_developer.php index 0ae41b430..61254df9f 100644 --- a/src/userinfo/u_developer.php +++ b/src/userinfo/u_developer.php @@ -220,8 +220,8 @@ function request_new_bearer_token(UserStatus $us) { function save_new_bearer_token(UserStatus $us) { if ($this->_new_token !== null) { - $this->_new_token->set_token_pattern("hct_[30]"); - if ($this->_new_token->create() !== null) { + $this->_new_token->set_token_pattern("hct_[30]")->insert(); + if ($this->_new_token->stored()) { $us->diffs["API tokens"] = true; } else { $us->error_at(null, "<0>Error while creating new API token");