diff --git a/src/applications/calendar/parser/ics/PhutilICSWriter.php b/src/applications/calendar/parser/ics/PhutilICSWriter.php index 8e7d06804c..c35008e079 100644 --- a/src/applications/calendar/parser/ics/PhutilICSWriter.php +++ b/src/applications/calendar/parser/ics/PhutilICSWriter.php @@ -1,391 +1,391 @@ getChildren() as $child) { $out[] = $this->writeNode($child); } return implode('', $out); } private function writeNode(PhutilCalendarNode $node) { if (!$this->getICSNodeType($node)) { return null; } $out = array(); $out[] = $this->writeBeginNode($node); $out[] = $this->writeNodeProperties($node); if ($node instanceof PhutilCalendarContainerNode) { foreach ($node->getChildren() as $child) { $out[] = $this->writeNode($child); } } $out[] = $this->writeEndNode($node); return implode('', $out); } private function writeBeginNode(PhutilCalendarNode $node) { $type = $this->getICSNodeType($node); return $this->wrapICSLine("BEGIN:{$type}"); } private function writeEndNode(PhutilCalendarNode $node) { $type = $this->getICSNodeType($node); return $this->wrapICSLine("END:{$type}"); } private function writeNodeProperties(PhutilCalendarNode $node) { $properties = $this->getNodeProperties($node); $out = array(); foreach ($properties as $property) { $propname = $property['name']; $propvalue = $property['value']; $propline = array(); $propline[] = $propname; foreach ($property['parameters'] as $parameter) { $paramname = $parameter['name']; $paramvalue = $parameter['value']; $propline[] = ";{$paramname}={$paramvalue}"; } $propline[] = ":{$propvalue}"; $propline = implode('', $propline); $out[] = $this->wrapICSLine($propline); } return implode('', $out); } private function getICSNodeType(PhutilCalendarNode $node) { switch ($node->getNodeType()) { case PhutilCalendarDocumentNode::NODETYPE: return 'VCALENDAR'; case PhutilCalendarEventNode::NODETYPE: return 'VEVENT'; default: return null; } } private function wrapICSLine($line) { $out = array(); $buf = ''; // NOTE: The line may contain sequences of combining characters which are // more than 80 bytes in length. If it does, we'll split them in the // middle of the sequence. This is okay and generally anticipated by // RFC5545, which even allows implementations to split multibyte // characters. The sequence will be stitched back together properly by // whatever is parsing things. foreach (phutil_utf8v($line) as $character) { // If adding this character would bring the line over 75 bytes, start // a new line. if (strlen($buf) + strlen($character) > 75) { $out[] = $buf."\r\n"; $buf = ' '; } $buf .= $character; } $out[] = $buf."\r\n"; return implode('', $out); } private function getNodeProperties(PhutilCalendarNode $node) { switch ($node->getNodeType()) { case PhutilCalendarDocumentNode::NODETYPE: return $this->getDocumentNodeProperties($node); case PhutilCalendarEventNode::NODETYPE: return $this->getEventNodeProperties($node); default: return array(); } } private function getDocumentNodeProperties( PhutilCalendarDocumentNode $event) { $properties = array(); $properties[] = $this->newTextProperty( 'VERSION', '2.0'); $properties[] = $this->newTextProperty( 'PRODID', self::getICSPRODID()); return $properties; } public static function getICSPRODID() { return '-//Phacility//Phabricator//EN'; } private function getEventNodeProperties(PhutilCalendarEventNode $event) { $properties = array(); $uid = $event->getUID(); if (!strlen($uid)) { throw new Exception( pht( 'Unable to write ICS document: event has no UID, but each event '. 'MUST have a UID.')); } $properties[] = $this->newTextProperty( 'UID', $uid); $created = $event->getCreatedDateTime(); if ($created) { $properties[] = $this->newDateTimeProperty( 'CREATED', $event->getCreatedDateTime()); } $dtstamp = $event->getModifiedDateTime(); if (!$dtstamp) { throw new Exception( pht( 'Unable to write ICS document: event has no modified time, but '. 'each event MUST have a modified time.')); } $properties[] = $this->newDateTimeProperty( 'DTSTAMP', $dtstamp); $dtstart = $event->getStartDateTime(); if ($dtstart) { $properties[] = $this->newDateTimeProperty( 'DTSTART', $dtstart); } $dtend = $event->getEndDateTime(); if ($dtend) { $properties[] = $this->newDateTimeProperty( 'DTEND', $event->getEndDateTime()); } $name = $event->getName(); - if (strlen($name)) { + if (phutil_nonempty_string($name)) { $properties[] = $this->newTextProperty( 'SUMMARY', $name); } $description = $event->getDescription(); - if (strlen($description)) { + if (phutil_nonempty_string($description)) { $properties[] = $this->newTextProperty( 'DESCRIPTION', $description); } $organizer = $event->getOrganizer(); if ($organizer) { $properties[] = $this->newUserProperty( 'ORGANIZER', $organizer); } $attendees = $event->getAttendees(); if ($attendees) { foreach ($attendees as $attendee) { $properties[] = $this->newUserProperty( 'ATTENDEE', $attendee); } } $rrule = $event->getRecurrenceRule(); if ($rrule) { $properties[] = $this->newRRULEProperty( 'RRULE', $rrule); } $recurrence_id = $event->getRecurrenceID(); if ($recurrence_id) { $properties[] = $this->newTextProperty( 'RECURRENCE-ID', $recurrence_id); } $exdates = $event->getRecurrenceExceptions(); if ($exdates) { $properties[] = $this->newDateTimesProperty( 'EXDATE', $exdates); } $rdates = $event->getRecurrenceDates(); if ($rdates) { $properties[] = $this->newDateTimesProperty( 'RDATE', $rdates); } return $properties; } private function newTextProperty( $name, $value, array $parameters = array()) { $map = array( '\\' => '\\\\', ',' => '\\,', "\n" => '\\n', ); $value = (array)$value; foreach ($value as $k => $v) { $v = str_replace(array_keys($map), array_values($map), $v); $value[$k] = $v; } $value = implode(',', $value); return $this->newProperty($name, $value, $parameters); } private function newDateTimeProperty( $name, PhutilCalendarDateTime $value, array $parameters = array()) { return $this->newDateTimesProperty($name, array($value), $parameters); } private function newDateTimesProperty( $name, array $values, array $parameters = array()) { assert_instances_of($values, 'PhutilCalendarDateTime'); if (head($values)->getIsAllDay()) { $parameters[] = array( 'name' => 'VALUE', 'values' => array( 'DATE', ), ); } $datetimes = array(); foreach ($values as $value) { $datetimes[] = $value->getISO8601(); } $datetimes = implode(';', $datetimes); return $this->newProperty($name, $datetimes, $parameters); } private function newUserProperty( $name, PhutilCalendarUserNode $value, array $parameters = array()) { $parameters[] = array( 'name' => 'CN', 'values' => array( $value->getName(), ), ); $partstat = null; switch ($value->getStatus()) { case PhutilCalendarUserNode::STATUS_INVITED: $partstat = 'NEEDS-ACTION'; break; case PhutilCalendarUserNode::STATUS_ACCEPTED: $partstat = 'ACCEPTED'; break; case PhutilCalendarUserNode::STATUS_DECLINED: $partstat = 'DECLINED'; break; } if ($partstat !== null) { $parameters[] = array( 'name' => 'PARTSTAT', 'values' => array( $partstat, ), ); } // TODO: We could reasonably fill in "ROLE" and "RSVP" here too, but it // isn't clear if these are important to external programs or not. return $this->newProperty($name, $value->getURI(), $parameters); } private function newRRULEProperty( $name, PhutilCalendarRecurrenceRule $rule, array $parameters = array()) { $value = $rule->toRRULE(); return $this->newProperty($name, $value, $parameters); } private function newProperty( $name, $value, array $parameters = array()) { $map = array( '^' => '^^', "\n" => '^n', '"' => "^'", ); $writable_params = array(); foreach ($parameters as $k => $parameter) { $value_list = array(); foreach ($parameter['values'] as $v) { $v = str_replace(array_keys($map), array_values($map), $v); // If the parameter value isn't a very simple one, quote it. // RFC5545 says that we MUST quote it if it has a colon, a semicolon, // or a comma, and that we MUST quote it if it's a URI. if (!preg_match('/^[A-Za-z0-9-]*\z/', $v)) { $v = '"'.$v.'"'; } $value_list[] = $v; } $writable_params[] = array( 'name' => $parameter['name'], 'value' => implode(',', $value_list), ); } return array( 'name' => $name, 'value' => $value, 'parameters' => $writable_params, ); } } diff --git a/src/applications/files/format/__tests__/PhabricatorFileStorageFormatTestCase.php b/src/applications/files/format/__tests__/PhabricatorFileStorageFormatTestCase.php index b3425ba313..bb850edc85 100644 --- a/src/applications/files/format/__tests__/PhabricatorFileStorageFormatTestCase.php +++ b/src/applications/files/format/__tests__/PhabricatorFileStorageFormatTestCase.php @@ -1,136 +1,140 @@ true, ); } public function testRot13Storage() { $engine = new PhabricatorTestStorageEngine(); $rot13_format = PhabricatorFileROT13StorageFormat::FORMATKEY; $data = 'The cow jumped over the full moon.'; $expect = 'Gur pbj whzcrq bire gur shyy zbba.'; $params = array( 'name' => 'test.dat', 'storageEngines' => array( $engine, ), 'format' => $rot13_format, ); $file = PhabricatorFile::newFromFileData($data, $params); // We should have a file stored as rot13, which reads back the input // data correctly. $this->assertEqual($rot13_format, $file->getStorageFormat()); $this->assertEqual($data, $file->loadFileData()); // The actual raw data in the storage engine should be encoded. $raw_data = $engine->readFile($file->getStorageHandle()); $this->assertEqual($expect, $raw_data); // If we generate an iterator over a slice of the file, it should return // the decrypted file. $iterator = $file->getFileDataIterator(4, 14); $raw_data = ''; foreach ($iterator as $data_chunk) { $raw_data .= $data_chunk; } $this->assertEqual('cow jumped', $raw_data); } public function testAES256Storage() { + if (!function_exists('openssl_encrypt')) { + $this->assertSkipped(pht('No OpenSSL extension available.')); + } + $engine = new PhabricatorTestStorageEngine(); $key_name = 'test.abcd'; $key_text = 'abcdefghijklmnopABCDEFGHIJKLMNOP'; PhabricatorKeyring::addKey( array( 'name' => $key_name, 'type' => 'aes-256-cbc', 'material.base64' => base64_encode($key_text), )); $format = id(new PhabricatorFileAES256StorageFormat()) ->selectMasterKey($key_name); $data = 'The cow jumped over the full moon.'; $params = array( 'name' => 'test.dat', 'storageEngines' => array( $engine, ), 'format' => $format, ); $file = PhabricatorFile::newFromFileData($data, $params); // We should have a file stored as AES256. $format_key = $format->getStorageFormatKey(); $this->assertEqual($format_key, $file->getStorageFormat()); $this->assertEqual($data, $file->loadFileData()); // The actual raw data in the storage engine should be encrypted. We // can't really test this, but we can make sure it's not the same as the // input data. $raw_data = $engine->readFile($file->getStorageHandle()); $this->assertTrue($data !== $raw_data); // If we generate an iterator over a slice of the file, it should return // the decrypted file. $iterator = $file->getFileDataIterator(4, 14); $raw_data = ''; foreach ($iterator as $data_chunk) { $raw_data .= $data_chunk; } $this->assertEqual('cow jumped', $raw_data); $iterator = $file->getFileDataIterator(4, null); $raw_data = ''; foreach ($iterator as $data_chunk) { $raw_data .= $data_chunk; } $this->assertEqual('cow jumped over the full moon.', $raw_data); } public function testStorageTampering() { $engine = new PhabricatorTestStorageEngine(); $good = 'The cow jumped over the full moon.'; $evil = 'The cow slept quietly, honoring the glorious dictator.'; $params = array( 'name' => 'message.txt', 'storageEngines' => array( $engine, ), ); // First, write the file normally. $file = PhabricatorFile::newFromFileData($good, $params); $this->assertEqual($good, $file->loadFileData()); // As an adversary, tamper with the file. $engine->tamperWithFile($file->getStorageHandle(), $evil); // Attempts to read the file data should now fail the integrity check. $caught = null; try { $file->loadFileData(); } catch (PhabricatorFileIntegrityException $ex) { $caught = $ex; } $this->assertTrue($caught instanceof PhabricatorFileIntegrityException); } } diff --git a/src/applications/nuance/github/NuanceGitHubRawEvent.php b/src/applications/nuance/github/NuanceGitHubRawEvent.php index b28a9222dc..8652dca9ad 100644 --- a/src/applications/nuance/github/NuanceGitHubRawEvent.php +++ b/src/applications/nuance/github/NuanceGitHubRawEvent.php @@ -1,391 +1,391 @@ type = $type; $event->raw = $raw; return $event; } public function getRepositoryFullName() { return $this->getRepositoryFullRawName(); } public function isIssueEvent() { if ($this->isPullRequestEvent()) { return false; } if ($this->type == self::TYPE_ISSUE) { return true; } switch ($this->getIssueRawKind()) { case 'IssuesEvent': return true; case 'IssueCommentEvent': if (!$this->getRawPullRequestData()) { return true; } break; } return false; } public function isPullRequestEvent() { if ($this->type == self::TYPE_ISSUE) { // TODO: This is wrong, some of these are pull events. return false; } $raw = $this->raw; switch ($this->getIssueRawKind()) { case 'PullRequestEvent': return true; case 'IssueCommentEvent': if ($this->getRawPullRequestData()) { return true; } break; } return false; } public function getIssueNumber() { if (!$this->isIssueEvent()) { return null; } return $this->getRawIssueNumber(); } public function getPullRequestNumber() { if (!$this->isPullRequestEvent()) { return null; } return $this->getRawIssueNumber(); } public function getID() { $raw = $this->raw; $id = idx($raw, 'id'); if ($id) { return (int)$id; } return null; } public function getComment() { if (!$this->isIssueEvent() && !$this->isPullRequestEvent()) { return null; } $raw = $this->raw; return idxv($raw, array('payload', 'comment', 'body')); } public function getURI() { $raw = $this->raw; if ($this->isIssueEvent() || $this->isPullRequestEvent()) { if ($this->type == self::TYPE_ISSUE) { $uri = idxv($raw, array('issue', 'html_url')); $uri = $uri.'#event-'.$this->getID(); } else { // The format of pull request events varies so we need to fish around // a bit to find the correct URI. $uri = idxv($raw, array('payload', 'pull_request', 'html_url')); $need_anchor = true; // For comments, we get a different anchor to link to the comment. In // this case, the URI comes with an anchor already. if (!$uri) { $uri = idxv($raw, array('payload', 'comment', 'html_url')); $need_anchor = false; } if (!$uri) { $uri = idxv($raw, array('payload', 'issue', 'html_url')); $need_anchor = true; } if ($need_anchor) { $uri = $uri.'#event-'.$this->getID(); } } } else { switch ($this->getIssueRawKind()) { case 'CreateEvent': $ref = idxv($raw, array('payload', 'ref')); $repo = $this->getRepositoryFullRawName(); return "https://github.com/{$repo}/commits/{$ref}"; case 'PushEvent': // These don't really have a URI since there may be multiple commits // involved and GitHub doesn't bundle the push as an object on its // own. Just try to find the URI for the log. The API also does // not return any HTML URI for these events. $head = idxv($raw, array('payload', 'head')); if ($head === null) { return null; } $repo = $this->getRepositoryFullRawName(); return "https://github.com/{$repo}/commits/{$head}"; case 'WatchEvent': // These have no reasonable URI. return null; default: return null; } } return $uri; } private function getRepositoryFullRawName() { $raw = $this->raw; $full = idxv($raw, array('repo', 'name')); - if (strlen($full)) { + if (phutil_nonempty_string($full)) { return $full; } // For issue events, the repository is not identified explicitly in the // response body. Parse it out of the URI. $matches = null; $ok = preg_match( '(/repos/((?:[^/]+)/(?:[^/]+))/issues/events/)', idx($raw, 'url'), $matches); if ($ok) { return $matches[1]; } return null; } private function getIssueRawKind() { $raw = $this->raw; return idxv($raw, array('type')); } private function getRawIssueNumber() { $raw = $this->raw; if ($this->type == self::TYPE_ISSUE) { return idxv($raw, array('issue', 'number')); } if ($this->type == self::TYPE_REPOSITORY) { $issue_number = idxv($raw, array('payload', 'issue', 'number')); if ($issue_number) { return $issue_number; } $pull_number = idxv($raw, array('payload', 'number')); if ($pull_number) { return $pull_number; } } return null; } private function getRawPullRequestData() { $raw = $this->raw; return idxv($raw, array('payload', 'issue', 'pull_request')); } public function getEventFullTitle() { switch ($this->type) { case self::TYPE_ISSUE: $title = $this->getRawIssueEventTitle(); break; case self::TYPE_REPOSITORY: $title = $this->getRawRepositoryEventTitle(); break; default: $title = pht('Unknown Event Type ("%s")', $this->type); break; } return pht( 'GitHub %s %s (%s)', $this->getRepositoryFullRawName(), $this->getTargetObjectName(), $title); } public function getActorGitHubUserID() { $raw = $this->raw; return (int)idxv($raw, array('actor', 'id')); } private function getTargetObjectName() { if ($this->isPullRequestEvent()) { $number = $this->getRawIssueNumber(); return pht('Pull Request #%d', $number); } else if ($this->isIssueEvent()) { $number = $this->getRawIssueNumber(); return pht('Issue #%d', $number); } else if ($this->type == self::TYPE_REPOSITORY) { $raw = $this->raw; $type = idx($raw, 'type'); switch ($type) { case 'CreateEvent': $ref = idxv($raw, array('payload', 'ref')); $ref_type = idxv($raw, array('payload', 'ref_type')); switch ($ref_type) { case 'branch': return pht('Branch %s', $ref); case 'tag': return pht('Tag %s', $ref); default: return pht('Ref %s', $ref); } break; case 'PushEvent': $ref = idxv($raw, array('payload', 'ref')); if (preg_match('(^refs/heads/)', $ref)) { return pht('Branch %s', substr($ref, strlen('refs/heads/'))); } else { return pht('Ref %s', $ref); } break; case 'WatchEvent': $actor = idxv($raw, array('actor', 'login')); return pht('User %s', $actor); } return pht('Unknown Object'); } else { return pht('Unknown Object'); } } private function getRawIssueEventTitle() { $raw = $this->raw; $action = idxv($raw, array('event')); switch ($action) { case 'assigned': $assignee = idxv($raw, array('assignee', 'login')); $title = pht('Assigned: %s', $assignee); break; case 'closed': $title = pht('Closed'); break; case 'demilestoned': $milestone = idxv($raw, array('milestone', 'title')); $title = pht('Removed Milestone: %s', $milestone); break; case 'labeled': $label = idxv($raw, array('label', 'name')); $title = pht('Added Label: %s', $label); break; case 'locked': $title = pht('Locked'); break; case 'milestoned': $milestone = idxv($raw, array('milestone', 'title')); $title = pht('Added Milestone: %s', $milestone); break; case 'renamed': $title = pht('Renamed'); break; case 'reopened': $title = pht('Reopened'); break; case 'unassigned': $assignee = idxv($raw, array('assignee', 'login')); $title = pht('Unassigned: %s', $assignee); break; case 'unlabeled': $label = idxv($raw, array('label', 'name')); $title = pht('Removed Label: %s', $label); break; case 'unlocked': $title = pht('Unlocked'); break; default: $title = pht('"%s"', $action); break; } return $title; } private function getRawRepositoryEventTitle() { $raw = $this->raw; $type = idx($raw, 'type'); switch ($type) { case 'CreateEvent': return pht('Created'); case 'PushEvent': $head = idxv($raw, array('payload', 'head')); $head = substr($head, 0, 12); return pht('Pushed: %s', $head); case 'IssuesEvent': $action = idxv($raw, array('payload', 'action')); switch ($action) { case 'closed': return pht('Closed'); case 'opened': return pht('Created'); case 'reopened': return pht('Reopened'); default: return pht('"%s"', $action); } break; case 'IssueCommentEvent': $action = idxv($raw, array('payload', 'action')); switch ($action) { case 'created': return pht('Comment'); default: return pht('"%s"', $action); } break; case 'PullRequestEvent': $action = idxv($raw, array('payload', 'action')); switch ($action) { case 'opened': return pht('Created'); default: return pht('"%s"', $action); } break; case 'WatchEvent': return pht('Watched'); } return pht('"%s"', $type); } } diff --git a/src/infrastructure/markup/blockrule/PhutilRemarkupListBlockRule.php b/src/infrastructure/markup/blockrule/PhutilRemarkupListBlockRule.php index 5bdcab2b8f..b7d82edfef 100644 --- a/src/infrastructure/markup/blockrule/PhutilRemarkupListBlockRule.php +++ b/src/infrastructure/markup/blockrule/PhutilRemarkupListBlockRule.php @@ -1,567 +1,567 @@ $line) { $matches = null; if (preg_match($regex, $line)) { $regex = self::CONT_BLOCK_PATTERN; if (preg_match('/^(\s+)/', $line, $matches)) { $space = strlen($matches[1]); } else { $space = 0; } $min_space = min($min_space, $space); } } $regex = self::START_BLOCK_PATTERN; if ($min_space) { foreach ($lines as $key => $line) { if (preg_match($regex, $line)) { $regex = self::CONT_BLOCK_PATTERN; $lines[$key] = substr($line, $min_space); } } } // The input text may have linewraps in it, like this: // // - derp derp derp derp // derp derp derp derp // - blarp blarp blarp blarp // // Group text lines together into list items, stored in $items. So the // result in the above case will be: // // array( // array( // "- derp derp derp derp", // " derp derp derp derp", // ), // array( // "- blarp blarp blarp blarp", // ), // ); $item = array(); $starts_at = null; $regex = self::START_BLOCK_PATTERN; foreach ($lines as $line) { $match = null; if (preg_match($regex, $line, $match)) { if (!$starts_at && !empty($match[1])) { $starts_at = $match[1]; } $regex = self::CONT_BLOCK_PATTERN; if ($item) { $items[] = $item; $item = array(); } } $item[] = $line; } if ($item) { $items[] = $item; } if (!$starts_at) { $starts_at = 1; } // Process each item to normalize the text, remove line wrapping, and // determine its depth (indentation level) and style (ordered vs unordered). // // We preserve consecutive linebreaks and interpret them as paragraph // breaks. // // Given the above example, the processed array will look like: // // array( // array( // 'text' => 'derp derp derp derp derp derp derp derp', // 'depth' => 0, // 'style' => '-', // ), // array( // 'text' => 'blarp blarp blarp blarp', // 'depth' => 0, // 'style' => '-', // ), // ); $has_marks = false; foreach ($items as $key => $item) { // Trim space around newlines, to strip trailing whitespace and formatting // indentation. $item = preg_replace('/ *(\n+) */', '\1', implode("\n", $item)); // Replace single newlines with a space. Preserve multiple newlines as // paragraph breaks. $item = preg_replace('/(? $text, 'depth' => $depth, 'style' => $style, 'mark' => $mark, ); } $items = array_values($items); // Users can create a sub-list by indenting any deeper amount than the // previous list, so these are both valid: // // - a // - b // // - a // - b // // In the former case, we'll have depths (0, 2). In the latter case, depths // (0, 4). We don't actually care about how many spaces there are, only // how many list indentation levels (that is, we want to map both of // those cases to (0, 1), indicating "outermost list" and "first sublist"). // // This is made more complicated because lists at two different indentation // levels might be at the same list level: // // - a // - b // - c // - d // // Here, 'b' and 'd' are at the same list level (2) but different indent // levels (2, 4). // // Users can also create "staircases" like this: // // - a // - b // # c // // While this is silly, we'd like to render it as faithfully as possible. // // In order to do this, we convert the list of nodes into a tree, // normalizing indentation levels and inserting dummy nodes as necessary to // make the tree well-formed. See additional notes at buildTree(). // // In the case above, the result is a tree like this: // // - // - // - a // - b // # c $l = 0; $r = count($items); $tree = $this->buildTree($items, $l, $r, $cur_level = 0); // We may need to open a list on a node, but they do not have // list style information yet. We need to propagate list style information // backward through the tree. In the above example, the tree now looks // like this: // // - // - // - a // - b // # c $this->adjustTreeStyleInformation($tree); // Finally, we have enough information to render the tree. $out = $this->renderTree($tree, 0, $has_marks, $starts_at); if ($this->getEngine()->isTextMode()) { $out = implode('', $out); $out = rtrim($out, "\n"); $out = preg_replace('/ +$/m', '', $out); return $out; } return phutil_implode_html('', $out); } /** * See additional notes in @{method:markupText}. */ private function buildTree(array $items, $l, $r, $cur_level) { if ($l == $r) { return array(); } if ($cur_level > self::MAXIMUM_LIST_NESTING_DEPTH) { // This algorithm is recursive and we don't need you blowing the stack // with your oh-so-clever 50,000-item-deep list. Cap indentation levels // at a reasonable number and just shove everything deeper up to this // level. $nodes = array(); for ($ii = $l; $ii < $r; $ii++) { $nodes[] = array( 'level' => $cur_level, 'items' => array(), ) + $items[$ii]; } return $nodes; } $min = $l; for ($ii = $r - 1; $ii >= $l; $ii--) { if ($items[$ii]['depth'] <= $items[$min]['depth']) { $min = $ii; } } $min_depth = $items[$min]['depth']; $nodes = array(); if ($min != $l) { $nodes[] = array( 'text' => null, 'level' => $cur_level, 'style' => null, 'mark' => null, 'items' => $this->buildTree($items, $l, $min, $cur_level + 1), ); } $last = $min; for ($ii = $last + 1; $ii < $r; $ii++) { if ($items[$ii]['depth'] == $min_depth) { $nodes[] = array( 'level' => $cur_level, 'items' => $this->buildTree($items, $last + 1, $ii, $cur_level + 1), ) + $items[$last]; $last = $ii; } } $nodes[] = array( 'level' => $cur_level, 'items' => $this->buildTree($items, $last + 1, $r, $cur_level + 1), ) + $items[$last]; return $nodes; } /** * See additional notes in @{method:markupText}. */ private function adjustTreeStyleInformation(array &$tree) { // The effect here is just to walk backward through the nodes at this level // and apply the first style in the list to any empty nodes we inserted // before it. As we go, also recurse down the tree. $style = '-'; for ($ii = count($tree) - 1; $ii >= 0; $ii--) { if ($tree[$ii]['style'] !== null) { // This is the earliest node we've seen with style, so set the // style to its style. $style = $tree[$ii]['style']; } else { // This node has no style, so apply the current style. $tree[$ii]['style'] = $style; } if ($tree[$ii]['items']) { $this->adjustTreeStyleInformation($tree[$ii]['items']); } } } /** * See additional notes in @{method:markupText}. */ private function renderTree( array $tree, $level, $has_marks, $starts_at = 1) { $style = idx(head($tree), 'style'); $out = array(); if (!$this->getEngine()->isTextMode()) { switch ($style) { case '#': $tag = 'ol'; break; case '-': $tag = 'ul'; break; } $start_attr = null; - if (ctype_digit($starts_at) && $starts_at > 1) { + if (ctype_digit(phutil_string_cast($starts_at)) && $starts_at > 1) { $start_attr = hsprintf(' start="%d"', $starts_at); } if ($has_marks) { $out[] = hsprintf( '<%s class="remarkup-list remarkup-list-with-checkmarks"%s>', $tag, $start_attr); } else { $out[] = hsprintf( '<%s class="remarkup-list"%s>', $tag, $start_attr); } $out[] = "\n"; } $number = $starts_at; foreach ($tree as $item) { if ($this->getEngine()->isTextMode()) { if ($item['text'] === null) { // Don't render anything. } else { $indent = str_repeat(' ', 2 * $level); $out[] = $indent; if ($item['mark'] !== null) { if ($item['mark']) { $out[] = '[X] '; } else { $out[] = '[ ] '; } } else { switch ($style) { case '#': $out[] = $number.'. '; $number++; break; case '-': $out[] = '- '; break; } } $parts = preg_split('/\n{2,}/', $item['text']); foreach ($parts as $key => $part) { if ($key != 0) { $out[] = "\n\n ".$indent; } $out[] = $this->applyRules($part); } $out[] = "\n"; } } else { if ($item['text'] === null) { $out[] = hsprintf('
  • '); } else { if ($item['mark'] !== null) { if ($item['mark'] == true) { $out[] = hsprintf( '
  • '); } else { $out[] = hsprintf( '
  • '); } $out[] = phutil_tag( 'input', array( 'type' => 'checkbox', 'checked' => ($item['mark'] ? 'checked' : null), 'disabled' => 'disabled', )); $out[] = ' '; } else { $out[] = hsprintf('
  • '); } $parts = preg_split('/\n{2,}/', $item['text']); foreach ($parts as $key => $part) { if ($key != 0) { $out[] = array( "\n", phutil_tag('br'), phutil_tag('br'), "\n", ); } $out[] = $this->applyRules($part); } } } if ($item['items']) { $subitems = $this->renderTree($item['items'], $level + 1, $has_marks); foreach ($subitems as $i) { $out[] = $i; } } if (!$this->getEngine()->isTextMode()) { $out[] = hsprintf("
  • \n"); } } if (!$this->getEngine()->isTextMode()) { switch ($style) { case '#': $out[] = hsprintf(''); break; case '-': $out[] = hsprintf(''); break; } } return $out; } } diff --git a/src/infrastructure/markup/markuprule/PhutilRemarkupDocumentLinkRule.php b/src/infrastructure/markup/markuprule/PhutilRemarkupDocumentLinkRule.php index 2170d9ae5e..ded57d4c77 100644 --- a/src/infrastructure/markup/markuprule/PhutilRemarkupDocumentLinkRule.php +++ b/src/infrastructure/markup/markuprule/PhutilRemarkupDocumentLinkRule.php @@ -1,183 +1,183 @@ getEngine(); $is_anchor = false; if (strncmp($link, '/', 1) == 0) { - $base = $engine->getConfig('uri.base'); + $base = phutil_string_cast($engine->getConfig('uri.base')); $base = rtrim($base, '/'); $link = $base.$link; } else if (strncmp($link, '#', 1) == 0) { $here = $engine->getConfig('uri.here'); $link = $here.$link; $is_anchor = true; } if ($engine->isTextMode()) { // If present, strip off "mailto:" or "tel:". $link = preg_replace('/^(?:mailto|tel):/', '', $link); if (!strlen($name)) { return $link; } return $name.' <'.$link.'>'; } if (!strlen($name)) { $name = $link; $name = preg_replace('/^(?:mailto|tel):/', '', $name); } if ($engine->getState('toc')) { return $name; } $same_window = $engine->getConfig('uri.same-window', false); if ($same_window) { $target = null; } else { $target = '_blank'; } // For anchors on the same page, always stay here. if ($is_anchor) { $target = null; } return phutil_tag( 'a', array( 'href' => $link, 'class' => 'remarkup-link', 'target' => $target, 'rel' => 'noreferrer', ), $name); } public function markupAlternateLink(array $matches) { $uri = trim($matches[2]); if (!strlen($uri)) { return $matches[0]; } // NOTE: We apply some special rules to avoid false positives here. The // major concern is that we do not want to convert `x[0][1](y)` in a // discussion about C source code into a link. To this end, we: // // - Don't match at word boundaries; // - require the URI to contain a "/" character or "@" character; and // - reject URIs which being with a quote character. if ($uri[0] == '"' || $uri[0] == "'" || $uri[0] == '`') { return $matches[0]; } if (strpos($uri, '/') === false && strpos($uri, '@') === false && strncmp($uri, 'tel:', 4)) { return $matches[0]; } return $this->markupDocumentLink( array( $matches[0], $matches[2], $matches[1], )); } public function markupDocumentLink(array $matches) { $uri = trim($matches[1]); - $name = trim(idx($matches, 2)); + $name = trim(idx($matches, 2, '')); if (!$this->isFlatText($uri)) { return $matches[0]; } if (!$this->isFlatText($name)) { return $matches[0]; } // If whatever is being linked to begins with "/" or "#", or has "://", // or is "mailto:" or "tel:", treat it as a URI instead of a wiki page. $is_uri = preg_match('@(^/)|(://)|(^#)|(^(?:mailto|tel):)@', $uri); if ($is_uri && strncmp('/', $uri, 1) && strncmp('#', $uri, 1)) { $protocols = $this->getEngine()->getConfig( 'uri.allowed-protocols', array()); try { $protocol = id(new PhutilURI($uri))->getProtocol(); if (!idx($protocols, $protocol)) { // Don't treat this as a URI if it's not an allowed protocol. $is_uri = false; } } catch (Exception $ex) { // We can end up here if we try to parse an ambiguous URI, see // T12796. $is_uri = false; } } // As a special case, skip "[[ / ]]" so that Phriction picks it up as a // link to the Phriction root. It is more useful to be able to use this // syntax to link to the root document than the home page of the install. if ($uri == '/') { $is_uri = false; } if (!$is_uri) { return $matches[0]; } return $this->getEngine()->storeText($this->renderHyperlink($uri, $name)); } }