Select the types of activity you want to include in your feed.
@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
···11+<?php
22+33+/**
44+ * Abstract interface to an identity provider or authentication source, like
55+ * Twitter, Facebook or Google.
66+ *
77+ * Generally, adapters are handed some set of credentials particular to the
88+ * provider they adapt, and they turn those credentials into standard
99+ * information about the user's identity. For example, the LDAP adapter is given
1010+ * a username and password (and some other configuration information), uses them
1111+ * to talk to the LDAP server, and produces a username, email, and so forth.
1212+ *
1313+ * Since the credentials a provider requires are specific to each provider, the
1414+ * base adapter does not specify how an adapter should be constructed or
1515+ * configured -- only what information it is expected to be able to provide once
1616+ * properly configured.
1717+ */
1818+abstract class PhutilAuthAdapter extends Phobject {
1919+2020+ /**
2121+ * Get a unique identifier associated with the identity. For most providers,
2222+ * this is an account ID.
2323+ *
2424+ * The account ID needs to be unique within this adapter's configuration, such
2525+ * that `<adapterKey, accountID>` is globally unique and always identifies the
2626+ * same identity.
2727+ *
2828+ * If the adapter was unable to authenticate an identity, it should return
2929+ * `null`.
3030+ *
3131+ * @return string|null Unique account identifier, or `null` if authentication
3232+ * failed.
3333+ */
3434+ abstract public function getAccountID();
3535+3636+3737+ /**
3838+ * Get a string identifying this adapter, like "ldap". This string should be
3939+ * unique to the adapter class.
4040+ *
4141+ * @return string Unique adapter identifier.
4242+ */
4343+ abstract public function getAdapterType();
4444+4545+4646+ /**
4747+ * Get a string identifying the domain this adapter is acting on. This allows
4848+ * an adapter (like LDAP) to act against different identity domains without
4949+ * conflating credentials. For providers like Facebook or Google, the adapters
5050+ * just return the relevant domain name.
5151+ *
5252+ * @return string Domain the adapter is associated with.
5353+ */
5454+ abstract public function getAdapterDomain();
5555+5656+5757+ /**
5858+ * Generate a string uniquely identifying this adapter configuration. Within
5959+ * the scope of a given key, all account IDs must uniquely identify exactly
6060+ * one identity.
6161+ *
6262+ * @return string Unique identifier for this adapter configuration.
6363+ */
6464+ public function getAdapterKey() {
6565+ return $this->getAdapterType().':'.$this->getAdapterDomain();
6666+ }
6767+6868+6969+ /**
7070+ * Optionally, return an email address associated with this account.
7171+ *
7272+ * @return string|null An email address associated with the account, or
7373+ * `null` if data is not available.
7474+ */
7575+ public function getAccountEmail() {
7676+ return null;
7777+ }
7878+7979+8080+ /**
8181+ * Optionally, return a human readable username associated with this account.
8282+ *
8383+ * @return string|null Account username, or `null` if data isn't available.
8484+ */
8585+ public function getAccountName() {
8686+ return null;
8787+ }
8888+8989+9090+ /**
9191+ * Optionally, return a URI corresponding to a human-viewable profile for
9292+ * this account.
9393+ *
9494+ * @return string|null A profile URI associated with this account, or
9595+ * `null` if the data isn't available.
9696+ */
9797+ public function getAccountURI() {
9898+ return null;
9999+ }
100100+101101+102102+ /**
103103+ * Optionally, return a profile image URI associated with this account.
104104+ *
105105+ * @return string|null URI for an account profile image, or `null` if one is
106106+ * not available.
107107+ */
108108+ public function getAccountImageURI() {
109109+ return null;
110110+ }
111111+112112+113113+ /**
114114+ * Optionally, return a real name associated with this account.
115115+ *
116116+ * @return string|null A human real name, or `null` if this data is not
117117+ * available.
118118+ */
119119+ public function getAccountRealName() {
120120+ return null;
121121+ }
122122+123123+}
···11+<?php
22+33+/**
44+ * Empty authentication adapter with no logic.
55+ *
66+ * This adapter can be used when you need an adapter for some technical reason
77+ * but it doesn't make sense to put logic inside it.
88+ */
99+final class PhutilEmptyAuthAdapter extends PhutilAuthAdapter {
1010+1111+ private $accountID;
1212+ private $adapterType;
1313+ private $adapterDomain;
1414+1515+ public function setAdapterDomain($adapter_domain) {
1616+ $this->adapterDomain = $adapter_domain;
1717+ return $this;
1818+ }
1919+2020+ public function getAdapterDomain() {
2121+ return $this->adapterDomain;
2222+ }
2323+2424+ public function setAdapterType($adapter_type) {
2525+ $this->adapterType = $adapter_type;
2626+ return $this;
2727+ }
2828+2929+ public function getAdapterType() {
3030+ return $this->adapterType;
3131+ }
3232+3333+ public function setAccountID($account_id) {
3434+ $this->accountID = $account_id;
3535+ return $this;
3636+ }
3737+3838+ public function getAccountID() {
3939+ return $this->accountID;
4040+ }
4141+4242+}
···11+<?php
22+33+/**
44+ * The user aborted the authentication workflow, by clicking "Cancel" or "Deny"
55+ * or taking some similar action.
66+ *
77+ * For example, in OAuth/OAuth2 workflows, the authentication provider
88+ * generally presents the user with a confirmation dialog with two options,
99+ * "Approve" and "Deny".
1010+ *
1111+ * If an adapter detects that the user has explicitly bailed out of the
1212+ * workflow, it should throw this exception.
1313+ */
1414+final class PhutilAuthUserAbortedException extends PhutilAuthException {}
···11+<?php
22+33+abstract class PhutilCalendarDateTime
44+ extends Phobject {
55+66+ private $viewerTimezone;
77+ private $isAllDay = false;
88+99+ public function setViewerTimezone($viewer_timezone) {
1010+ $this->viewerTimezone = $viewer_timezone;
1111+ return $this;
1212+ }
1313+1414+ public function getViewerTimezone() {
1515+ return $this->viewerTimezone;
1616+ }
1717+1818+ public function setIsAllDay($is_all_day) {
1919+ $this->isAllDay = $is_all_day;
2020+ return $this;
2121+ }
2222+2323+ public function getIsAllDay() {
2424+ return $this->isAllDay;
2525+ }
2626+2727+ public function getEpoch() {
2828+ $datetime = $this->newPHPDateTime();
2929+ return (int)$datetime->format('U');
3030+ }
3131+3232+ public function getISO8601() {
3333+ $datetime = $this->newPHPDateTime();
3434+3535+ if ($this->getIsAllDay()) {
3636+ return $datetime->format('Ymd');
3737+ } else if ($this->getTimezone()) {
3838+ // With a timezone, the event occurs at a specific second universally.
3939+ // We return the UTC representation of that point in time.
4040+ $datetime->setTimezone(new DateTimeZone('UTC'));
4141+ return $datetime->format('Ymd\\THis\\Z');
4242+ } else {
4343+ // With no timezone, events are "floating" and occur at local time.
4444+ // We return a representation without the "Z".
4545+ return $datetime->format('Ymd\\THis');
4646+ }
4747+ }
4848+4949+ abstract public function newPHPDateTimeZone();
5050+ abstract public function newPHPDateTime();
5151+ abstract public function newAbsoluteDateTime();
5252+5353+ abstract public function getTimezone();
5454+}
···11+<?php
22+33+final class PhutilCalendarRecurrenceSet
44+ extends Phobject {
55+66+ private $sources = array();
77+ private $viewerTimezone = 'UTC';
88+99+ public function addSource(PhutilCalendarRecurrenceSource $source) {
1010+ $this->sources[] = $source;
1111+ return $this;
1212+ }
1313+1414+ public function setViewerTimezone($viewer_timezone) {
1515+ $this->viewerTimezone = $viewer_timezone;
1616+ return $this;
1717+ }
1818+1919+ public function getViewerTimezone() {
2020+ return $this->viewerTimezone;
2121+ }
2222+2323+ public function getEventsBetween(
2424+ PhutilCalendarDateTime $start = null,
2525+ PhutilCalendarDateTime $end = null,
2626+ $limit = null) {
2727+2828+ if ($end === null && $limit === null) {
2929+ throw new Exception(
3030+ pht(
3131+ 'Recurring event range queries must have an end date, a limit, or '.
3232+ 'both.'));
3333+ }
3434+3535+ $timezone = $this->getViewerTimezone();
3636+3737+ $sources = array();
3838+ foreach ($this->sources as $source) {
3939+ $source = clone $source;
4040+ $source->setViewerTimezone($timezone);
4141+ $source->resetSource();
4242+4343+ $sources[] = array(
4444+ 'source' => $source,
4545+ 'state' => null,
4646+ 'epoch' => null,
4747+ );
4848+ }
4949+5050+ if ($start) {
5151+ $start = clone $start;
5252+ $start->setViewerTimezone($timezone);
5353+ $min_epoch = $start->getEpoch();
5454+ } else {
5555+ $min_epoch = 0;
5656+ }
5757+5858+ if ($end) {
5959+ $end = clone $end;
6060+ $end->setViewerTimezone($timezone);
6161+ $end_epoch = $end->getEpoch();
6262+ } else {
6363+ $end_epoch = null;
6464+ }
6565+6666+ $results = array();
6767+ $index = 0;
6868+ $cursor = 0;
6969+ while (true) {
7070+ // Get the next event for each source which we don't have a future
7171+ // event for.
7272+ foreach ($sources as $key => $source) {
7373+ $state = $source['state'];
7474+ $epoch = $source['epoch'];
7575+7676+ if ($state !== null && $epoch >= $cursor) {
7777+ // We have an event for this source, and it's a future event, so
7878+ // we don't need to do anything.
7979+ continue;
8080+ }
8181+8282+ $next = $source['source']->getNextEvent($cursor);
8383+ if ($next === null) {
8484+ // This source doesn't have any more events, so we're all done.
8585+ unset($sources[$key]);
8686+ continue;
8787+ }
8888+8989+ $next_epoch = $next->getEpoch();
9090+9191+ if ($end_epoch !== null && $next_epoch > $end_epoch) {
9292+ // We have an end time and the next event from this source is
9393+ // past that end, so we know there are no more relevant events
9494+ // coming from this source.
9595+ unset($sources[$key]);
9696+ continue;
9797+ }
9898+9999+ $sources[$key]['state'] = $next;
100100+ $sources[$key]['epoch'] = $next_epoch;
101101+ }
102102+103103+ if (!$sources) {
104104+ // We've run out of sources which can produce valid events in the
105105+ // window, so we're all done.
106106+ break;
107107+ }
108108+109109+ // Find the minimum event time across all sources.
110110+ $next_epoch = null;
111111+ foreach ($sources as $source) {
112112+ if ($next_epoch === null) {
113113+ $next_epoch = $source['epoch'];
114114+ } else {
115115+ $next_epoch = min($next_epoch, $source['epoch']);
116116+ }
117117+ }
118118+119119+ $is_exception = false;
120120+ $next_source = null;
121121+ foreach ($sources as $source) {
122122+ if ($source['epoch'] == $next_epoch) {
123123+ if ($source['source']->getIsExceptionSource()) {
124124+ $is_exception = true;
125125+ } else {
126126+ $next_source = $source;
127127+ }
128128+ }
129129+ }
130130+131131+ // If this is an exception, it means the event does NOT occur. We
132132+ // skip it and move on. If it's not an exception, it does occur, so
133133+ // we record it.
134134+ if (!$is_exception) {
135135+136136+ // Only actually include this event in the results if it starts after
137137+ // any specified start time. We increment the index regardless, so we
138138+ // return results with proper offsets.
139139+ if ($next_source['epoch'] >= $min_epoch) {
140140+ $results[$index] = $next_source['state'];
141141+ }
142142+ $index++;
143143+144144+ if ($limit !== null && (count($results) >= $limit)) {
145145+ break;
146146+ }
147147+ }
148148+149149+ $cursor = $next_epoch + 1;
150150+151151+ // If we have an end of the window and we've reached it, we're done.
152152+ if ($end_epoch) {
153153+ if ($cursor > $end_epoch) {
154154+ break;
155155+ }
156156+ }
157157+ }
158158+159159+ return $results;
160160+ }
161161+162162+}
···11+BEGIN:VCALENDAR
22+BEGIN:VEVENT
33+DTSTART:20160719T095722Z
44+DURATION:P1DT17H4M23S
55+SUMMARY:Duration Event
66+DESCRIPTION:This is an event with a complex duration.
77+END:VEVENT
88+END:VCALENDAR
···11+BEGIN:VCALENDAR
22+BEGIN:VEVENT
33+DTSTART:20150101T000000
44+DURATION:P1D
55+SUMMARY:New Year's 2015
66+DESCRIPTION:This is an event with a floating start time.
77+END:VEVENT
88+END:VCALENDAR
···11+BEGIN:VCALENDAR
22+VERSION:2.0
33+PRODID:-//Phacility//Phabricator//EN
44+BEGIN:VEVENT
55+UID:tea-time
66+CREATED:20160915T070000Z
77+DTSTAMP:20160915T070000Z
88+DTSTART:20160916T150000Z
99+DTEND:20160916T160000Z
1010+SUMMARY:Tea Time
1111+DESCRIPTION:Tea and\, perhaps\, crumpets.\nYour presence is requested!\nThi
1212+ s is a long list of types of tea to test line wrapping: earl grey tea\, En
1313+ glish breakfast tea\, black tea\, green tea\, t-rex\, oolong tea\, mint te
1414+ a\, tea with milk.
1515+END:VEVENT
1616+END:VCALENDAR
···11+<?php
22+33+/**
44+ * Remarkup prevents several classes of text-processing problems by replacing
55+ * tokens in the text as they are marked up. For example, if you write something
66+ * like this:
77+ *
88+ * //D12//
99+ *
1010+ * It is processed in several stages. First the "D12" matches and is replaced
1111+ * with a token, in the form of "<0x01><ID number><literal "Z">". The first
1212+ * byte, "<0x01>" is a single byte with value 1 that marks a token. If this is
1313+ * token ID "444", the text may now look like this:
1414+ *
1515+ * //<0x01>444Z//
1616+ *
1717+ * Now the italics match and are replaced, using the next token ID:
1818+ *
1919+ * <0x01>445Z
2020+ *
2121+ * When processing completes, all the tokens are replaced with their final
2222+ * equivalents. For example, token 444 is evaluated to:
2323+ *
2424+ * <a href="http://...">...</a>
2525+ *
2626+ * Then token 445 is evaluated:
2727+ *
2828+ * <em><0x01>444Z</em>
2929+ *
3030+ * ...and all tokens it contains are replaced:
3131+ *
3232+ * <em><a href="http://...">...</a></em>
3333+ *
3434+ * If we didn't do this, the italics rule could match the "//" in "http://",
3535+ * or any other number of processing mistakes could occur, some of which create
3636+ * security risks.
3737+ *
3838+ * This class generates keys, and stores the map of keys to replacement text.
3939+ */
4040+final class PhutilRemarkupBlockStorage extends Phobject {
4141+4242+ const MAGIC_BYTE = "\1";
4343+4444+ private $map = array();
4545+ private $index = 0;
4646+4747+ public function store($text) {
4848+ $key = self::MAGIC_BYTE.(++$this->index).'Z';
4949+ $this->map[$key] = $text;
5050+ return $key;
5151+ }
5252+5353+ public function restore($corpus, $text_mode = false) {
5454+ $map = $this->map;
5555+5656+ if (!$text_mode) {
5757+ foreach ($map as $key => $content) {
5858+ $map[$key] = phutil_escape_html($content);
5959+ }
6060+ $corpus = phutil_escape_html($corpus);
6161+ }
6262+6363+ // NOTE: Tokens may contain other tokens: for example, a table may have
6464+ // links inside it. So we can't do a single simple find/replace, because
6565+ // we need to find and replace child tokens inside the content of parent
6666+ // tokens.
6767+6868+ // However, we know that rules which have child tokens must always store
6969+ // all their child tokens first, before they store their parent token: you
7070+ // have to pass the "store(text)" API a block of text with tokens already
7171+ // in it, so you must have created child tokens already.
7272+7373+ // Thus, all child tokens will appear in the list before parent tokens, so
7474+ // if we start at the beginning of the list and replace all the tokens we
7575+ // find in each piece of content, we'll end up expanding all subtokens
7676+ // correctly.
7777+7878+ $map[] = $corpus;
7979+ $seen = array();
8080+ foreach ($map as $key => $content) {
8181+ $seen[$key] = true;
8282+8383+ // If the content contains no token magic, we don't need to replace
8484+ // anything.
8585+ if (strpos($content, self::MAGIC_BYTE) === false) {
8686+ continue;
8787+ }
8888+8989+ $matches = null;
9090+ preg_match_all(
9191+ '/'.self::MAGIC_BYTE.'\d+Z/',
9292+ $content,
9393+ $matches,
9494+ PREG_OFFSET_CAPTURE);
9595+9696+ $matches = $matches[0];
9797+9898+ // See PHI1114. We're replacing all the matches in one pass because this
9999+ // is significantly faster than doing "substr_replace()" in a loop if the
100100+ // corpus is large and we have a large number of matches.
101101+102102+ // Build a list of string pieces in "$parts" by interleaving the
103103+ // plain strings between each token and the replacement token text, then
104104+ // implode the whole thing when we're done.
105105+106106+ $parts = array();
107107+ $pos = 0;
108108+ foreach ($matches as $next) {
109109+ $subkey = $next[0];
110110+111111+ // If we've matched a token pattern but don't actually have any
112112+ // corresponding token, just skip this match. This should not be
113113+ // possible, and should perhaps be an error.
114114+ if (!isset($seen[$subkey])) {
115115+ if (!isset($map[$subkey])) {
116116+ throw new Exception(
117117+ pht(
118118+ 'Matched token key "%s" while processing remarkup block, but '.
119119+ 'this token does not exist in the token map.',
120120+ $subkey));
121121+ } else {
122122+ throw new Exception(
123123+ pht(
124124+ 'Matched token key "%s" while processing remarkup block, but '.
125125+ 'this token appears later in the list than the key being '.
126126+ 'processed ("%s").',
127127+ $subkey,
128128+ $key));
129129+ }
130130+ }
131131+132132+ $subpos = $next[1];
133133+134134+ // If there were any non-token bytes since the last token, add them.
135135+ if ($subpos > $pos) {
136136+ $parts[] = substr($content, $pos, $subpos - $pos);
137137+ }
138138+139139+ // Add the token replacement text.
140140+ $parts[] = $map[$subkey];
141141+142142+ // Move the non-token cursor forward over the token.
143143+ $pos = $subpos + strlen($subkey);
144144+ }
145145+146146+ // Add any leftover non-token bytes after the last token.
147147+ $parts[] = substr($content, $pos);
148148+149149+ $content = implode('', $parts);
150150+151151+ $map[$key] = $content;
152152+ }
153153+ $corpus = last($map);
154154+155155+ if (!$text_mode) {
156156+ $corpus = phutil_safe_html($corpus);
157157+ }
158158+159159+ return $corpus;
160160+ }
161161+162162+ public function overwrite($key, $new_text) {
163163+ $this->map[$key] = $new_text;
164164+ return $this;
165165+ }
166166+167167+ public function getMap() {
168168+ return $this->map;
169169+ }
170170+171171+ public function setMap(array $map) {
172172+ $this->map = $map;
173173+ return $this;
174174+ }
175175+176176+}
···11+<?php
22+33+final class PhutilRemarkupLiteralBlockRule extends PhutilRemarkupBlockRule {
44+55+ public function getPriority() {
66+ return 450;
77+ }
88+99+ public function getMatchingLineCount(array $lines, $cursor) {
1010+ // NOTE: We're consuming all continguous blocks of %%% literals, so this:
1111+ //
1212+ // %%%a%%%
1313+ // %%%b%%%
1414+ //
1515+ // ...is equivalent to:
1616+ //
1717+ // %%%a
1818+ // b%%%
1919+ //
2020+ // If they are separated by a blank newline, they are parsed as two
2121+ // different blocks. This more clearly represents the original text in the
2222+ // output text and assists automated escaping of blocks coming into the
2323+ // system.
2424+2525+ $num_lines = 0;
2626+ while (preg_match('/^\s*%%%/', $lines[$cursor])) {
2727+ $num_lines++;
2828+2929+ // If the line has ONLY "%%%", the block opener doesn't get to double
3030+ // up as a block terminator.
3131+ if (preg_match('/^\s*%%%\s*\z/', $lines[$cursor])) {
3232+ $num_lines++;
3333+ $cursor++;
3434+ }
3535+3636+ while (isset($lines[$cursor])) {
3737+ if (!preg_match('/%%%\s*$/', $lines[$cursor])) {
3838+ $num_lines++;
3939+ $cursor++;
4040+ continue;
4141+ }
4242+ break;
4343+ }
4444+4545+ $cursor++;
4646+4747+ $found_empty = false;
4848+ while (isset($lines[$cursor])) {
4949+ if (!strlen(trim($lines[$cursor]))) {
5050+ $num_lines++;
5151+ $cursor++;
5252+ $found_empty = true;
5353+ continue;
5454+ }
5555+ break;
5656+ }
5757+5858+ if ($found_empty) {
5959+ // If there's an empty line after the block, stop merging blocks.
6060+ break;
6161+ }
6262+6363+ if (!isset($lines[$cursor])) {
6464+ // If we're at the end of the input, stop looking for more lines.
6565+ break;
6666+ }
6767+ }
6868+6969+ return $num_lines;
7070+ }
7171+7272+ public function markupText($text, $children) {
7373+ $text = rtrim($text);
7474+ $text = phutil_split_lines($text, $retain_endings = true);
7575+ foreach ($text as $key => $line) {
7676+ $line = preg_replace('/^\s*%%%/', '', $line);
7777+ $line = preg_replace('/%%%(\s*)\z/', '\1', $line);
7878+ $text[$key] = $line;
7979+ }
8080+8181+ if ($this->getEngine()->isTextMode()) {
8282+ return implode('', $text);
8383+ }
8484+8585+ return phutil_tag(
8686+ 'p',
8787+ array(
8888+ 'class' => 'remarkup-literal',
8989+ ),
9090+ phutil_implode_html(phutil_tag('br', array()), $text));
9191+ }
9292+9393+}
···11+<?php
22+33+abstract class PhutilRemarkupQuotedBlockRule
44+ extends PhutilRemarkupBlockRule {
55+66+ final public function supportsChildBlocks() {
77+ return true;
88+ }
99+1010+ final protected function normalizeQuotedBody($text) {
1111+ $text = phutil_split_lines($text, true);
1212+ foreach ($text as $key => $line) {
1313+ $text[$key] = substr($line, 1);
1414+ }
1515+1616+ // If every line in the block is empty or begins with at least one leading
1717+ // space, strip the initial space off each line. When we quote text, we
1818+ // normally add "> " (with a space) to the beginning of each line, which
1919+ // can disrupt some other rules. If the block appears to have this space
2020+ // in front of each line, remove it.
2121+2222+ $strip_space = true;
2323+ foreach ($text as $key => $line) {
2424+ $len = strlen($line);
2525+2626+ if (!$len) {
2727+ // We'll still strip spaces if there are some completely empty
2828+ // lines, they may have just had trailing whitespace trimmed.
2929+ continue;
3030+ }
3131+3232+ // If this line is part of a nested quote block, just ignore it when
3333+ // realigning this quote block. It's either an author attribution
3434+ // line with ">>!", or we'll deal with it in a subrule when processing
3535+ // the nested quote block.
3636+ if ($line[0] == '>') {
3737+ continue;
3838+ }
3939+4040+ if ($line[0] == ' ' || $line[0] == "\n") {
4141+ continue;
4242+ }
4343+4444+ // The first character of this line is something other than a space, so
4545+ // we can't strip spaces.
4646+ $strip_space = false;
4747+ break;
4848+ }
4949+5050+ if ($strip_space) {
5151+ foreach ($text as $key => $line) {
5252+ $len = strlen($line);
5353+ if (!$len) {
5454+ continue;
5555+ }
5656+5757+ if ($line[0] !== ' ') {
5858+ continue;
5959+ }
6060+6161+ $text[$key] = substr($line, 1);
6262+ }
6363+ }
6464+6565+ // Strip leading empty lines.
6666+ foreach ($text as $key => $line) {
6767+ if (!strlen(trim($line))) {
6868+ unset($text[$key]);
6969+ }
7070+ }
7171+7272+ return implode('', $text);
7373+ }
7474+7575+ final protected function getQuotedText($text) {
7676+ $text = rtrim($text, "\n");
7777+7878+ $no_whitespace = array(
7979+ // For readability, we render nested quotes as ">> quack",
8080+ // not "> > quack".
8181+ '>' => true,
8282+8383+ // If the line is empty except for a newline, do not add an
8484+ // unnecessary dangling space.
8585+ "\n" => true,
8686+ );
8787+8888+ $text = phutil_split_lines($text, true);
8989+ foreach ($text as $key => $line) {
9090+ $c = null;
9191+ if (isset($line[0])) {
9292+ $c = $line[0];
9393+ } else {
9494+ $c = null;
9595+ }
9696+9797+ if (isset($no_whitespace[$c])) {
9898+ $text[$key] = '>'.$line;
9999+ } else {
100100+ $text[$key] = '> '.$line;
101101+ }
102102+ }
103103+ $text = implode('', $text);
104104+105105+ return $text;
106106+ }
107107+108108+}
···11+<?php
22+33+final class PhutilRemarkupSimpleTableBlockRule extends PhutilRemarkupBlockRule {
44+55+ public function getMatchingLineCount(array $lines, $cursor) {
66+ $num_lines = 0;
77+ while (isset($lines[$cursor])) {
88+ if (preg_match('/^(\s*\|.*+\n?)+$/', $lines[$cursor])) {
99+ $num_lines++;
1010+ $cursor++;
1111+ } else {
1212+ break;
1313+ }
1414+ }
1515+1616+ return $num_lines;
1717+ }
1818+1919+ public function markupText($text, $children) {
2020+ $matches = array();
2121+2222+ $rows = array();
2323+ foreach (explode("\n", $text) as $line) {
2424+ // Ignore ending delimiters.
2525+ $line = rtrim($line, '|');
2626+2727+ // NOTE: The complexity in this regular expression allows us to match
2828+ // a table like "| a | [[ href | b ]] | c |".
2929+3030+ preg_match_all(
3131+ '/\|'.
3232+ '('.
3333+ '(?:'.
3434+ '(?:\\[\\[.*?\\]\\])'. // [[ ... | ... ]], a link
3535+ '|'.
3636+ '(?:[^|[]+)'. // Anything but "|" or "[".
3737+ '|'.
3838+ '(?:\\[[^\\|[])'. // "[" followed by anything but "[" or "|"
3939+ ')*'.
4040+ ')/', $line, $matches);
4141+4242+ $any_header = false;
4343+ $any_content = false;
4444+4545+ $cells = array();
4646+ foreach ($matches[1] as $cell) {
4747+ $cell = trim($cell);
4848+4949+ // If this row only has empty cells and "--" cells, and it has at
5050+ // least one "--" cell, it's marking the rows above as <th> cells
5151+ // instead of <td> cells.
5252+5353+ // If it has other types of cells, it's always a content row.
5454+5555+ // If it has only empty cells, it's an empty row.
5656+5757+ if (strlen($cell)) {
5858+ if (preg_match('/^--+\z/', $cell)) {
5959+ $any_header = true;
6060+ } else {
6161+ $any_content = true;
6262+ }
6363+ }
6464+6565+ $cells[] = array('type' => 'td', 'content' => $this->applyRules($cell));
6666+ }
6767+6868+ $is_header = ($any_header && !$any_content);
6969+7070+ if (!$is_header) {
7171+ $rows[] = array('type' => 'tr', 'content' => $cells);
7272+ } else if ($rows) {
7373+ // Mark previous row with headings.
7474+ foreach ($cells as $i => $cell) {
7575+ if ($cell['content']) {
7676+ $last_key = last_key($rows);
7777+ if (!isset($rows[$last_key]['content'][$i])) {
7878+ // If this row has more cells than the previous row, there may
7979+ // not be a cell above this one to turn into a <th />.
8080+ continue;
8181+ }
8282+8383+ $rows[$last_key]['content'][$i]['type'] = 'th';
8484+ }
8585+ }
8686+ }
8787+ }
8888+8989+ if (!$rows) {
9090+ return $this->applyRules($text);
9191+ }
9292+9393+ return $this->renderRemarkupTable($rows);
9494+ }
9595+9696+}
···11+<?php
22+33+final class PhutilRemarkupDocumentLinkRule extends PhutilRemarkupRule {
44+55+ public function getPriority() {
66+ return 150.0;
77+ }
88+99+ public function apply($text) {
1010+ // Handle mediawiki-style links: [[ href | name ]]
1111+ $text = preg_replace_callback(
1212+ '@\B\\[\\[([^|\\]]+)(?:\\|([^\\]]+))?\\]\\]\B@U',
1313+ array($this, 'markupDocumentLink'),
1414+ $text);
1515+1616+ // Handle markdown-style links: [name](href)
1717+ $text = preg_replace_callback(
1818+ '@'.
1919+ '\B'.
2020+ '\\[([^\\]]+)\\]'.
2121+ '\\('.
2222+ '(\s*'.
2323+ // See T12343. This is making some kind of effort to implement
2424+ // parenthesis balancing rules. It won't get nested parentheses
2525+ // right, but should do OK for Wikipedia pages, which seem to be
2626+ // the most important use case.
2727+2828+ // Match zero or more non-parenthesis, non-space characters.
2929+ '[^\s()]*'.
3030+ // Match zero or more sequences of "(...)", where two balanced
3131+ // parentheses enclose zero or more normal characters. If we
3232+ // match some, optionally match more stuff at the end.
3333+ '(?:(?:\\([^ ()]*\\))+[^\s()]*)?'.
3434+ '\s*)'.
3535+ '\\)'.
3636+ '\B'.
3737+ '@U',
3838+ array($this, 'markupAlternateLink'),
3939+ $text);
4040+4141+ return $text;
4242+ }
4343+4444+ protected function renderHyperlink($link, $name) {
4545+ $engine = $this->getEngine();
4646+4747+ $is_anchor = false;
4848+ if (strncmp($link, '/', 1) == 0) {
4949+ $base = $engine->getConfig('uri.base');
5050+ $base = rtrim($base, '/');
5151+ $link = $base.$link;
5252+ } else if (strncmp($link, '#', 1) == 0) {
5353+ $here = $engine->getConfig('uri.here');
5454+ $link = $here.$link;
5555+5656+ $is_anchor = true;
5757+ }
5858+5959+ if ($engine->isTextMode()) {
6060+ // If present, strip off "mailto:" or "tel:".
6161+ $link = preg_replace('/^(?:mailto|tel):/', '', $link);
6262+6363+ if (!strlen($name)) {
6464+ return $link;
6565+ }
6666+6767+ return $name.' <'.$link.'>';
6868+ }
6969+7070+ if (!strlen($name)) {
7171+ $name = $link;
7272+ $name = preg_replace('/^(?:mailto|tel):/', '', $name);
7373+ }
7474+7575+ if ($engine->getState('toc')) {
7676+ return $name;
7777+ }
7878+7979+ $same_window = $engine->getConfig('uri.same-window', false);
8080+ if ($same_window) {
8181+ $target = null;
8282+ } else {
8383+ $target = '_blank';
8484+ }
8585+8686+ // For anchors on the same page, always stay here.
8787+ if ($is_anchor) {
8888+ $target = null;
8989+ }
9090+9191+ return phutil_tag(
9292+ 'a',
9393+ array(
9494+ 'href' => $link,
9595+ 'class' => 'remarkup-link',
9696+ 'target' => $target,
9797+ 'rel' => 'noreferrer',
9898+ ),
9999+ $name);
100100+ }
101101+102102+ public function markupAlternateLink(array $matches) {
103103+ $uri = trim($matches[2]);
104104+105105+ if (!strlen($uri)) {
106106+ return $matches[0];
107107+ }
108108+109109+ // NOTE: We apply some special rules to avoid false positives here. The
110110+ // major concern is that we do not want to convert `x[0][1](y)` in a
111111+ // discussion about C source code into a link. To this end, we:
112112+ //
113113+ // - Don't match at word boundaries;
114114+ // - require the URI to contain a "/" character or "@" character; and
115115+ // - reject URIs which being with a quote character.
116116+117117+ if ($uri[0] == '"' || $uri[0] == "'" || $uri[0] == '`') {
118118+ return $matches[0];
119119+ }
120120+121121+ if (strpos($uri, '/') === false &&
122122+ strpos($uri, '@') === false &&
123123+ strncmp($uri, 'tel:', 4)) {
124124+ return $matches[0];
125125+ }
126126+127127+ return $this->markupDocumentLink(
128128+ array(
129129+ $matches[0],
130130+ $matches[2],
131131+ $matches[1],
132132+ ));
133133+ }
134134+135135+ public function markupDocumentLink(array $matches) {
136136+ $uri = trim($matches[1]);
137137+ $name = trim(idx($matches, 2));
138138+139139+ // If whatever is being linked to begins with "/" or "#", or has "://",
140140+ // or is "mailto:" or "tel:", treat it as a URI instead of a wiki page.
141141+ $is_uri = preg_match('@(^/)|(://)|(^#)|(^(?:mailto|tel):)@', $uri);
142142+143143+ if ($is_uri && strncmp('/', $uri, 1) && strncmp('#', $uri, 1)) {
144144+ $protocols = $this->getEngine()->getConfig(
145145+ 'uri.allowed-protocols',
146146+ array());
147147+148148+ try {
149149+ $protocol = id(new PhutilURI($uri))->getProtocol();
150150+ if (!idx($protocols, $protocol)) {
151151+ // Don't treat this as a URI if it's not an allowed protocol.
152152+ $is_uri = false;
153153+ }
154154+ } catch (Exception $ex) {
155155+ // We can end up here if we try to parse an ambiguous URI, see
156156+ // T12796.
157157+ $is_uri = false;
158158+ }
159159+ }
160160+161161+ // As a special case, skip "[[ / ]]" so that Phriction picks it up as a
162162+ // link to the Phriction root. It is more useful to be able to use this
163163+ // syntax to link to the root document than the home page of the install.
164164+ if ($uri == '/') {
165165+ $is_uri = false;
166166+ }
167167+168168+ if (!$is_uri) {
169169+ return $matches[0];
170170+ }
171171+172172+ return $this->getEngine()->storeText($this->renderHyperlink($uri, $name));
173173+ }
174174+175175+}
···11+<?php
22+33+final class PhutilRemarkupHighlightRule extends PhutilRemarkupRule {
44+55+ public function getPriority() {
66+ return 1000.0;
77+ }
88+99+ public function apply($text) {
1010+ if ($this->getEngine()->isTextMode()) {
1111+ return $text;
1212+ }
1313+1414+ return $this->replaceHTML(
1515+ '@!!(.+?)(!{2,})@',
1616+ array($this, 'applyCallback'),
1717+ $text);
1818+ }
1919+2020+ protected function applyCallback(array $matches) {
2121+ // Remove the two exclamation points that represent syntax.
2222+ $excitement = substr($matches[2], 2);
2323+2424+ // If the internal content consists of ONLY exclamation points, leave it
2525+ // untouched so "!!!!!" is five exclamation points instead of one
2626+ // highlighted exclamation point.
2727+ if (preg_match('/^!+\z/', $matches[1])) {
2828+ return $matches[0];
2929+ }
3030+3131+ // $excitement now has two fewer !'s than we started with.
3232+ return hsprintf('<span class="remarkup-highlight">%s%s</span>',
3333+ $matches[1], $excitement);
3434+3535+ }
3636+3737+}
···11+<?php
22+33+final class PhutilRemarkupHyperlinkRule extends PhutilRemarkupRule {
44+55+ const KEY_HYPERLINKS = 'hyperlinks';
66+77+ public function getPriority() {
88+ return 400.0;
99+ }
1010+1111+ public function apply($text) {
1212+ // Hyperlinks with explicit "<>" around them get linked exactly, without
1313+ // the "<>". Angle brackets are basically special and mean "this is a URL
1414+ // with weird characters". This is assumed to be reasonable because they
1515+ // don't appear in normal text or normal URLs.
1616+ $text = preg_replace_callback(
1717+ '@<(\w{3,}://[^\s'.PhutilRemarkupBlockStorage::MAGIC_BYTE.']+?)>@',
1818+ array($this, 'markupHyperlinkAngle'),
1919+ $text);
2020+2121+ // We match "{uri}", but do not link it by default.
2222+ $text = preg_replace_callback(
2323+ '@{(\w{3,}://[^\s'.PhutilRemarkupBlockStorage::MAGIC_BYTE.']+?)}@',
2424+ array($this, 'markupHyperlinkCurly'),
2525+ $text);
2626+2727+ // Anything else we match "ungreedily", which means we'll look for
2828+ // stuff that's probably puncutation or otherwise not part of the URL and
2929+ // not link it. This lets someone write "QuicK! Go to
3030+ // http://www.example.com/!". We also apply some paren balancing rules.
3131+3232+ // NOTE: We're explicitly avoiding capturing stored blocks, so text like
3333+ // `http://www.example.com/[[x | y]]` doesn't get aggressively captured.
3434+ $text = preg_replace_callback(
3535+ '@(\w{3,}://[^\s'.PhutilRemarkupBlockStorage::MAGIC_BYTE.']+)@',
3636+ array($this, 'markupHyperlinkUngreedy'),
3737+ $text);
3838+3939+ return $text;
4040+ }
4141+4242+ public function markupHyperlinkAngle(array $matches) {
4343+ return $this->markupHyperlink('<', $matches);
4444+ }
4545+4646+ public function markupHyperlinkCurly(array $matches) {
4747+ return $this->markupHyperlink('{', $matches);
4848+ }
4949+5050+ protected function markupHyperlink($mode, array $matches) {
5151+ $raw_uri = $matches[1];
5252+5353+ try {
5454+ $uri = new PhutilURI($raw_uri);
5555+ } catch (Exception $ex) {
5656+ return $matches[0];
5757+ }
5858+5959+ $engine = $this->getEngine();
6060+6161+ $token = $engine->storeText($raw_uri);
6262+6363+ $list_key = self::KEY_HYPERLINKS;
6464+ $link_list = $engine->getTextMetadata($list_key, array());
6565+6666+ $link_list[] = array(
6767+ 'token' => $token,
6868+ 'uri' => $raw_uri,
6969+ 'mode' => $mode,
7070+ );
7171+7272+ $engine->setTextMetadata($list_key, $link_list);
7373+7474+ return $token;
7575+ }
7676+7777+ protected function renderHyperlink($link, $is_embed) {
7878+ // If the URI is "{uri}" and no handler picked it up, we just render it
7979+ // as plain text.
8080+ if ($is_embed) {
8181+ return $this->renderRawLink($link, $is_embed);
8282+ }
8383+8484+ $engine = $this->getEngine();
8585+8686+ $same_window = $engine->getConfig('uri.same-window', false);
8787+ if ($same_window) {
8888+ $target = null;
8989+ } else {
9090+ $target = '_blank';
9191+ }
9292+9393+ return phutil_tag(
9494+ 'a',
9595+ array(
9696+ 'href' => $link,
9797+ 'class' => 'remarkup-link',
9898+ 'target' => $target,
9999+ 'rel' => 'noreferrer',
100100+ ),
101101+ $link);
102102+ }
103103+104104+ private function renderRawLink($link, $is_embed) {
105105+ if ($is_embed) {
106106+ return '{'.$link.'}';
107107+ } else {
108108+ return $link;
109109+ }
110110+ }
111111+112112+ protected function markupHyperlinkUngreedy($matches) {
113113+ $match = $matches[1];
114114+ $tail = null;
115115+ $trailing = null;
116116+ if (preg_match('/[;,.:!?]+$/', $match, $trailing)) {
117117+ $tail = $trailing[0];
118118+ $match = substr($match, 0, -strlen($tail));
119119+ }
120120+121121+ // If there's a closing paren at the end but no balancing open paren in
122122+ // the URL, don't link the close paren. This is an attempt to gracefully
123123+ // handle the two common paren cases, Wikipedia links and English language
124124+ // parentheticals, e.g.:
125125+ //
126126+ // http://en.wikipedia.org/wiki/Noun_(disambiguation)
127127+ // (see also http://www.example.com)
128128+ //
129129+ // We could apply a craftier heuristic here which tries to actually balance
130130+ // the parens, but this is probably sufficient.
131131+ if (preg_match('/\\)$/', $match) && !preg_match('/\\(/', $match)) {
132132+ $tail = ')'.$tail;
133133+ $match = substr($match, 0, -1);
134134+ }
135135+136136+ try {
137137+ $uri = new PhutilURI($match);
138138+ } catch (Exception $ex) {
139139+ return $matches[0];
140140+ }
141141+142142+ $link = $this->markupHyperlink(null, array(null, $match));
143143+144144+ return hsprintf('%s%s', $link, $tail);
145145+ }
146146+147147+ public function didMarkupText() {
148148+ $engine = $this->getEngine();
149149+150150+ $protocols = $engine->getConfig('uri.allowed-protocols', array());
151151+ $is_toc = $engine->getState('toc');
152152+ $is_text = $engine->isTextMode();
153153+ $is_mail = $engine->isHTMLMailMode();
154154+155155+ $list_key = self::KEY_HYPERLINKS;
156156+ $raw_list = $engine->getTextMetadata($list_key, array());
157157+158158+ $links = array();
159159+ foreach ($raw_list as $key => $link) {
160160+ $token = $link['token'];
161161+ $raw_uri = $link['uri'];
162162+ $mode = $link['mode'];
163163+164164+ $is_embed = ($mode === '{');
165165+ $is_literal = ($mode === '<');
166166+167167+ // If we're rendering in a "Table of Contents" or a plain text mode,
168168+ // we're going to render the raw URI without modifications.
169169+ if ($is_toc || $is_text) {
170170+ $result = $this->renderRawLink($raw_uri, $is_embed);
171171+ $engine->overwriteStoredText($token, $result);
172172+ continue;
173173+ }
174174+175175+ // If this URI doesn't use a whitelisted protocol, don't link it. This
176176+ // is primarily intended to prevent "javascript://" silliness.
177177+ $uri = new PhutilURI($raw_uri);
178178+ $protocol = $uri->getProtocol();
179179+ $valid_protocol = idx($protocols, $protocol);
180180+ if (!$valid_protocol) {
181181+ $result = $this->renderRawLink($raw_uri, $is_embed);
182182+ $engine->overwriteStoredText($token, $result);
183183+ continue;
184184+ }
185185+186186+ // If the URI is written as "<uri>", we'll render it literally even if
187187+ // some handler would otherwise deal with it.
188188+ // If we're rendering for HTML mail, we also render literally.
189189+ if ($is_literal || $is_mail) {
190190+ $result = $this->renderHyperlink($raw_uri, $is_embed);
191191+ $engine->overwriteStoredText($token, $result);
192192+ continue;
193193+ }
194194+195195+ // Otherwise, this link is a valid resource which extensions are allowed
196196+ // to handle.
197197+ $links[$key] = $link;
198198+ }
199199+200200+ if (!$links) {
201201+ return;
202202+ }
203203+204204+ foreach ($links as $key => $link) {
205205+ $links[$key] = new PhutilRemarkupHyperlinkRef($link);
206206+ }
207207+208208+ $extensions = PhutilRemarkupHyperlinkEngineExtension::getAllLinkEngines();
209209+ foreach ($extensions as $extension) {
210210+ $extension = id(clone $extension)
211211+ ->setEngine($engine)
212212+ ->processHyperlinks($links);
213213+214214+ foreach ($links as $key => $link) {
215215+ $result = $link->getResult();
216216+ if ($result !== null) {
217217+ $engine->overwriteStoredText($link->getToken(), $result);
218218+ unset($links[$key]);
219219+ }
220220+ }
221221+222222+ if (!$links) {
223223+ break;
224224+ }
225225+ }
226226+227227+ // Render any remaining links in a normal way.
228228+ foreach ($links as $link) {
229229+ $result = $this->renderHyperlink($link->getURI(), $link->isEmbed());
230230+ $engine->overwriteStoredText($link->getToken(), $result);
231231+ }
232232+ }
233233+234234+}
···11+<?php
22+33+abstract class PhutilRemarkupRule extends Phobject {
44+55+ private $engine;
66+ private $replaceCallback;
77+88+ public function setEngine(PhutilRemarkupEngine $engine) {
99+ $this->engine = $engine;
1010+ return $this;
1111+ }
1212+1313+ public function getEngine() {
1414+ return $this->engine;
1515+ }
1616+1717+ public function getPriority() {
1818+ return 500.0;
1919+ }
2020+2121+ abstract public function apply($text);
2222+2323+ public function getPostprocessKey() {
2424+ return spl_object_hash($this);
2525+ }
2626+2727+ public function didMarkupText() {
2828+ return;
2929+ }
3030+3131+ protected function replaceHTML($pattern, $callback, $text) {
3232+ $this->replaceCallback = $callback;
3333+ return phutil_safe_html(preg_replace_callback(
3434+ $pattern,
3535+ array($this, 'replaceHTMLCallback'),
3636+ phutil_escape_html($text)));
3737+ }
3838+3939+ private function replaceHTMLCallback(array $match) {
4040+ return phutil_escape_html(call_user_func(
4141+ $this->replaceCallback,
4242+ array_map('phutil_safe_html', $match)));
4343+ }
4444+4545+4646+ /**
4747+ * Safely generate a tag.
4848+ *
4949+ * In Remarkup contexts, it's not safe to use arbitrary text in tag
5050+ * attributes: even though it will be escaped, it may contain replacement
5151+ * tokens which are then replaced with markup.
5252+ *
5353+ * This method acts as @{function:phutil_tag}, but checks attributes before
5454+ * using them.
5555+ *
5656+ * @param string Tag name.
5757+ * @param dict<string, wild> Tag attributes.
5858+ * @param wild Tag content.
5959+ * @return PhutilSafeHTML Tag object.
6060+ */
6161+ protected function newTag($name, array $attrs, $content = null) {
6262+ foreach ($attrs as $key => $attr) {
6363+ if ($attr !== null) {
6464+ $attrs[$key] = $this->assertFlatText($attr);
6565+ }
6666+ }
6767+6868+ return phutil_tag($name, $attrs, $content);
6969+ }
7070+7171+ /**
7272+ * Assert that a text token is flat (it contains no replacement tokens).
7373+ *
7474+ * Because tokens can be replaced with markup, it is dangerous to use
7575+ * arbitrary input text in tag attributes. Normally, rule precedence should
7676+ * prevent this. Asserting that text is flat before using it as an attribute
7777+ * provides an extra layer of security.
7878+ *
7979+ * Normally, you can call @{method:newTag} rather than calling this method
8080+ * directly. @{method:newTag} will check attributes for you.
8181+ *
8282+ * @param wild Ostensibly flat text.
8383+ * @return string Flat text.
8484+ */
8585+ protected function assertFlatText($text) {
8686+ $text = (string)hsprintf('%s', phutil_safe_html($text));
8787+ $rich = (strpos($text, PhutilRemarkupBlockStorage::MAGIC_BYTE) !== false);
8888+ if ($rich) {
8989+ throw new Exception(
9090+ pht(
9191+ 'Remarkup rule precedence is dangerous: rendering text with tokens '.
9292+ 'as flat text!'));
9393+ }
9494+9595+ return $text;
9696+ }
9797+9898+ /**
9999+ * Check whether text is flat (contains no replacement tokens) or not.
100100+ *
101101+ * @param wild Ostensibly flat text.
102102+ * @return bool True if the text is flat.
103103+ */
104104+ protected function isFlatText($text) {
105105+ $text = (string)hsprintf('%s', phutil_safe_html($text));
106106+ return (strpos($text, PhutilRemarkupBlockStorage::MAGIC_BYTE) === false);
107107+ }
108108+109109+}
···11+ lang=txt
22+ x
33+ y
44+~~~~~~~~~~
55+<div class="remarkup-code-block" data-code-lang="txt" data-sigil="remarkup-code-block"><pre class="remarkup-code"> x
66+y</pre></div>
77+~~~~~~~~~~
88+ x
99+ y
···11+omg~~ wtf~~~~~ bbq~~~ lol~~~
22+~~deleted text~~~
33+~~This is a great idea~~~ die forever please
44+~~~~~~~
55+~~~~~~~~~~
66+<p>omg~~ wtf~~~~~ bbq~~~ lol~~~
77+<del>deleted text</del>
88+<del>This is a great idea~</del> die forever please
99+~~~~~~</p>
1010+~~~~~~~~~~
1111+omg~~ wtf~~~~~ bbq~~~ lol~~ ~~deleted text~~ ~~This is a great idea~~~ die forever please ~~~~~~~
···11+here is a diff
22+33+ lang=diff
44+ @@ derp derp @@
55+ x
66+ y
77+88+ - x
99+ - y
1010+ + z
1111+1212+derp derp
1313+~~~~~~~~~~
1414+<p>here is a diff</p>
1515+1616+<div class="remarkup-code-block" data-code-lang="diff" data-sigil="remarkup-code-block"><pre class="remarkup-code">@@ derp derp @@
1717+x
1818+y
1919+2020+- x
2121+- y
2222++ z</pre></div>
2323+2424+<p>derp derp</p>
2525+~~~~~~~~~~
2626+here is a diff
2727+2828+ @@ derp derp @@
2929+ x
3030+ y
3131+3232+ - x
3333+ - y
3434+ + z
3535+3636+derp derp
···11+#2 is my favorite.
22+33+#project
44+~~~~~~~~~~
55+<p>#2 is my favorite.</p>
66+77+<p>#project</p>
88+~~~~~~~~~~
99+#2 is my favorite.
1010+1111+#project
···11+how about we !!highlight!! some !!TEXT!!!
22+wow this must be **!!very important!!**
33+omg!!!!!
44+~~~~~~~~~~
55+<p>how about we <span class="remarkup-highlight">highlight</span> some <span class="remarkup-highlight">TEXT!</span>
66+wow this must be <strong><span class="remarkup-highlight">very important</span></strong>
77+omg!!!!!</p>
88+~~~~~~~~~~
99+how about we !!highlight!! some !!TEXT!!! wow this must be **!!very important!!** omg!!!!!
···11+- a
22+-- b
33+--- c
44+~~~~~~~~~~
55+<ul class="remarkup-list">
66+<li class="remarkup-list-item">a<ul class="remarkup-list">
77+<li class="remarkup-list-item">b<ul class="remarkup-list">
88+<li class="remarkup-list-item">c</li>
99+</ul></li>
1010+</ul></li>
1111+</ul>
1212+~~~~~~~~~~
1313+- a
1414+ - b
1515+ - c
···11+- a
22+ - a
33+ - a
44+ - a
55+ - a
66+ - a
77+ - a
88+ - a
99+ - a
1010+ - a
1111+ - a
1212+ - a
1313+ - a
1414+ - a
1515+ - a
1616+ - a
1717+ - a
1818+ - a
1919+ - a
2020+ - a
2121+ - a
2222+ - a
2323+ - a
2424+ - a
2525+ - a
2626+ - a
2727+ - a
2828+ - a
2929+ - a
3030+ - a
3131+ - a
3232+ - a
3333+ - a
3434+ - a
3535+ - a
3636+ - a
3737+ - a
3838+ - a
3939+4040+4141+derp
4242+~~~~~~~~~~
4343+<ul class="remarkup-list">
4444+<li class="remarkup-list-item">a<ul class="remarkup-list">
4545+<li class="remarkup-list-item">a<ul class="remarkup-list">
4646+<li class="remarkup-list-item">a<ul class="remarkup-list">
4747+<li class="remarkup-list-item">a<ul class="remarkup-list">
4848+<li class="remarkup-list-item">a<ul class="remarkup-list">
4949+<li class="remarkup-list-item">a<ul class="remarkup-list">
5050+<li class="remarkup-list-item">a<ul class="remarkup-list">
5151+<li class="remarkup-list-item">a<ul class="remarkup-list">
5252+<li class="remarkup-list-item">a<ul class="remarkup-list">
5353+<li class="remarkup-list-item">a<ul class="remarkup-list">
5454+<li class="remarkup-list-item">a<ul class="remarkup-list">
5555+<li class="remarkup-list-item">a<ul class="remarkup-list">
5656+<li class="remarkup-list-item">a<ul class="remarkup-list">
5757+<li class="remarkup-list-item">a</li>
5858+<li class="remarkup-list-item">a</li>
5959+<li class="remarkup-list-item">a</li>
6060+<li class="remarkup-list-item">a</li>
6161+<li class="remarkup-list-item">a</li>
6262+<li class="remarkup-list-item">a</li>
6363+<li class="remarkup-list-item">a</li>
6464+<li class="remarkup-list-item">a</li>
6565+<li class="remarkup-list-item">a</li>
6666+<li class="remarkup-list-item">a</li>
6767+<li class="remarkup-list-item">a</li>
6868+<li class="remarkup-list-item">a</li>
6969+<li class="remarkup-list-item">a</li>
7070+<li class="remarkup-list-item">a</li>
7171+<li class="remarkup-list-item">a</li>
7272+<li class="remarkup-list-item">a</li>
7373+<li class="remarkup-list-item">a</li>
7474+<li class="remarkup-list-item">a</li>
7575+<li class="remarkup-list-item">a</li>
7676+<li class="remarkup-list-item">a</li>
7777+<li class="remarkup-list-item">a</li>
7878+<li class="remarkup-list-item">a</li>
7979+<li class="remarkup-list-item">a</li>
8080+<li class="remarkup-list-item">a</li>
8181+<li class="remarkup-list-item">a</li>
8282+</ul></li>
8383+</ul></li>
8484+</ul></li>
8585+</ul></li>
8686+</ul></li>
8787+</ul></li>
8888+</ul></li>
8989+</ul></li>
9090+</ul></li>
9191+</ul></li>
9292+</ul></li>
9393+</ul></li>
9494+</ul></li>
9595+</ul>
9696+9797+<p>derp</p>
9898+~~~~~~~~~~
9999+- a
100100+ - a
101101+ - a
102102+ - a
103103+ - a
104104+ - a
105105+ - a
106106+ - a
107107+ - a
108108+ - a
109109+ - a
110110+ - a
111111+ - a
112112+ - a
113113+ - a
114114+ - a
115115+ - a
116116+ - a
117117+ - a
118118+ - a
119119+ - a
120120+ - a
121121+ - a
122122+ - a
123123+ - a
124124+ - a
125125+ - a
126126+ - a
127127+ - a
128128+ - a
129129+ - a
130130+ - a
131131+ - a
132132+ - a
133133+ - a
134134+ - a
135135+ - a
136136+ - a
137137+138138+derp
···11+# At the end of a block, this should be a list.
22+~~~~~~~~~~
33+<ol class="remarkup-list">
44+<li class="remarkup-list-item">At the end of a block, this should be a list.</li>
55+</ol>
66+~~~~~~~~~~
77+1. At the end of a block, this should be a list.
···11+## Small Header
22+33+This should be a small header.
44+~~~~~~~~~~
55+<h3 class="remarkup-header">Small Header</h3>
66+77+<p>This should be a small header.</p>
88+~~~~~~~~~~
99+Small Header
1010+------------
1111+1212+This should be a small header.
···11+- a
22+ -- b
33+ -- c
44+~~~~~~~~~~
55+<ul class="remarkup-list">
66+<li class="remarkup-list-item">a<ul class="remarkup-list">
77+<li class="remarkup-list-item">b</li>
88+<li class="remarkup-list-item">c</li>
99+</ul></li>
1010+</ul>
1111+~~~~~~~~~~
1212+- a
1313+ - b
1414+ - c
···11+- a
22+ a
33+- b
44+b
55+~~~~~~~~~~
66+<ul class="remarkup-list">
77+<li class="remarkup-list-item">a a</li>
88+<li class="remarkup-list-item">b</li>
99+</ul>
1010+1111+<p>b</p>
1212+~~~~~~~~~~
1313+- a a
1414+- b
1515+1616+b
···11+- This is a list item
22+ with several paragraphs.
33+44+ This is the second paragraph
55+ of the first list item.
66+- This is the second item
77+ in the list.
88+ - This is a sublist.
99+- This is the third item in the list.
1010+1111+~~~~~~~~~~
1212+<ul class="remarkup-list">
1313+<li class="remarkup-list-item">This is a list item with several paragraphs.
1414+<br /><br />
1515+This is the second paragraph of the first list item.</li>
1616+<li class="remarkup-list-item">This is the second item in the list.<ul class="remarkup-list">
1717+<li class="remarkup-list-item">This is a sublist.</li>
1818+</ul></li>
1919+<li class="remarkup-list-item">This is the third item in the list.</li>
2020+</ul>
2121+~~~~~~~~~~
2222+- This is a list item with several paragraphs.
2323+2424+ This is the second paragraph of the first list item.
2525+- This is the second item in the list.
2626+ - This is a sublist.
2727+- This is the third item in the list.
···11+1) one
22+33+- a
44+~~~~~~~~~~
55+<ol class="remarkup-list">
66+<li class="remarkup-list-item">one</li>
77+</ol>
88+99+<ul class="remarkup-list">
1010+<li class="remarkup-list-item">a</li>
1111+</ul>
1212+~~~~~~~~~~
1313+1. one
1414+1515+- a
···11+This should be a list:
22+33+ - apple
44+ - banana
55+66+~~~~~~~~~~
77+<p>This should be a list:</p>
88+99+<ul class="remarkup-list">
1010+<li class="remarkup-list-item">apple</li>
1111+<li class="remarkup-list-item">banana</li>
1212+</ul>
1313+~~~~~~~~~~
1414+This should be a list:
1515+1616+- apple
1717+- banana
···11+`Zebra`s
22+33+I can`t and I won`t.
44+~~~~~~~~~~
55+<p><tt class="remarkup-monospaced">Zebra</tt>s</p>
66+77+<p>I can`t and I won`t.</p>
88+~~~~~~~~~~
99+`Zebra`s
1010+1111+I can`t and I won`t.
···11+This is a paragraph.
22+33+44+ lang=txt
55+ First line of code block.
66+ Second line of code block.
77+88+99+<table>
1010+ <tr>
1111+ <td>Cell 1</td>
1212+ <td>Cell 2</td>
1313+ </tr>
1414+</table>
1515+~~~~~~~~~~
1616+<p>This is a paragraph.</p>
1717+1818+<div class="remarkup-code-block" data-code-lang="txt" data-sigil="remarkup-code-block"><pre class="remarkup-code">First line of code block.
1919+Second line of code block.</pre></div>
2020+2121+<div class="remarkup-table-wrap"><table class="remarkup-table">
2222+<tr><td>Cell 1</td><td>Cell 2</td></tr>
2323+</table></div>
2424+~~~~~~~~~~
2525+This is a paragraph.
2626+2727+ First line of code block.
2828+ Second line of code block.
2929+3030+| Cell 1 | Cell 2 |
···11+> This should be a code block:
22+>
33+> ```lang=php
44+> <?php
55+> $foo = 'bar';
66+> ```
77+~~~~~~~~~~
88+<blockquote><p>This should be a code block:</p>
99+1010+<div class="remarkup-code-block" data-code-lang="php" data-sigil="remarkup-code-block"><pre class="remarkup-code"><span class="o"><?php</span>
1111+<span class="nv">$foo</span> <span class="k">=</span> <span class="s">'bar'</span><span class="k">;</span></pre></div></blockquote>
1212+~~~~~~~~~~
1313+> This should be a code block:
1414+>
1515+> <?php
1616+> $foo = 'bar';
···11+>>! In U, W wrote:
22+> - Y
33+>
44+> Z
55+~~~~~~~~~~
66+<blockquote class="remarkup-reply-block">
77+<div class="remarkup-reply-head">In U, W wrote:</div>
88+<div class="remarkup-reply-body"><ul class="remarkup-list">
99+<li class="remarkup-list-item">Y</li>
1010+</ul>
1111+1212+<p>Z</p></div>
1313+</blockquote>
1414+~~~~~~~~~~
1515+In U, W wrote:
1616+1717+> - Y
1818+>
1919+> Z
···11+> Dear Sir,
22+> I am utterly disgusted with the quality
33+> of your inflight food service.
44+~~~~~~~~~~
55+<blockquote><p>Dear Sir,
66+I am utterly disgusted with the quality
77+of your inflight food service.</p></blockquote>
88+~~~~~~~~~~
99+> Dear Sir, I am utterly disgusted with the quality of your inflight food service.
···11+>>! In comment #123, alincoln wrote:
22+> Four score and twenty years ago...
33+~~~~~~~~~~
44+<blockquote class="remarkup-reply-block">
55+<div class="remarkup-reply-head">In comment #123, alincoln wrote:</div>
66+<div class="remarkup-reply-body"><p>Four score and twenty years ago...</p></div>
77+</blockquote>
88+~~~~~~~~~~
99+In comment #123, alincoln wrote:
1010+1111+> Four score and twenty years ago...
···11+omg__ wtf_____ bbq___ lol__
22+__underlined text__
33+__This is a great idea___ die forever please
44+__
55+/__notunderlined__/ and also /__notunderlined__.c
66+~~~~~~~~~~
77+<p>omg__ wtf_____ bbq___ lol__
88+<u>underlined text</u>
99+<u>This is a great idea_</u> die forever please
1010+__
1111+/__notunderlined__/ and also /__notunderlined__.c</p>
1212+~~~~~~~~~~
1313+omg__ wtf_____ bbq___ lol__ __underlined text__ __This is a great idea___ die forever please __ /__notunderlined__/ and also /__notunderlined__.c
···11+<?php
22+33+/**
44+ * Represents current transaction state of a connection.
55+ */
66+final class AphrontDatabaseTransactionState extends Phobject {
77+88+ private $depth = 0;
99+ private $readLockLevel = 0;
1010+ private $writeLockLevel = 0;
1111+1212+ public function getDepth() {
1313+ return $this->depth;
1414+ }
1515+1616+ public function increaseDepth() {
1717+ return ++$this->depth;
1818+ }
1919+2020+ public function decreaseDepth() {
2121+ if ($this->depth == 0) {
2222+ throw new Exception(
2323+ pht(
2424+ 'Too many calls to %s or %s!',
2525+ 'saveTransaction()',
2626+ 'killTransaction()'));
2727+ }
2828+2929+ return --$this->depth;
3030+ }
3131+3232+ public function getSavepointName() {
3333+ return 'Aphront_Savepoint_'.$this->depth;
3434+ }
3535+3636+ public function beginReadLocking() {
3737+ $this->readLockLevel++;
3838+ return $this;
3939+ }
4040+4141+ public function endReadLocking() {
4242+ if ($this->readLockLevel == 0) {
4343+ throw new Exception(
4444+ pht(
4545+ 'Too many calls to %s!',
4646+ __FUNCTION__.'()'));
4747+ }
4848+ $this->readLockLevel--;
4949+ return $this;
5050+ }
5151+5252+ public function isReadLocking() {
5353+ return ($this->readLockLevel > 0);
5454+ }
5555+5656+ public function beginWriteLocking() {
5757+ $this->writeLockLevel++;
5858+ return $this;
5959+ }
6060+6161+ public function endWriteLocking() {
6262+ if ($this->writeLockLevel == 0) {
6363+ throw new Exception(
6464+ pht(
6565+ 'Too many calls to %s!',
6666+ __FUNCTION__.'()'));
6767+ }
6868+ $this->writeLockLevel--;
6969+ return $this;
7070+ }
7171+7272+ public function isWriteLocking() {
7373+ return ($this->writeLockLevel > 0);
7474+ }
7575+7676+ public function __destruct() {
7777+ if ($this->depth) {
7878+ throw new Exception(
7979+ pht(
8080+ 'Process exited with an open transaction! The transaction '.
8181+ 'will be implicitly rolled back. Calls to %s must always be '.
8282+ 'paired with a call to %s or %s.',
8383+ 'openTransaction()',
8484+ 'saveTransaction()',
8585+ 'killTransaction()'));
8686+ }
8787+ if ($this->readLockLevel) {
8888+ throw new Exception(
8989+ pht(
9090+ 'Process exited with an open read lock! Call to %s '.
9191+ 'must always be paired with a call to %s.',
9292+ 'beginReadLocking()',
9393+ 'endReadLocking()'));
9494+ }
9595+ if ($this->writeLockLevel) {
9696+ throw new Exception(
9797+ pht(
9898+ 'Process exited with an open write lock! Call to %s '.
9999+ 'must always be paired with a call to %s.',
100100+ 'beginWriteLocking()',
101101+ 'endWriteLocking()'));
102102+ }
103103+ }
104104+105105+}
···11+<?php
22+33+final class AphrontIsolatedDatabaseConnection
44+ extends AphrontDatabaseConnection {
55+66+ private $configuration;
77+ private static $nextInsertID;
88+ private $insertID;
99+1010+ private $transcript = array();
1111+1212+ private $allResults;
1313+ private $affectedRows;
1414+1515+ public function __construct(array $configuration) {
1616+ $this->configuration = $configuration;
1717+1818+ if (self::$nextInsertID === null) {
1919+ // Generate test IDs into a distant ID space to reduce the risk of
2020+ // collisions and make them distinctive.
2121+ self::$nextInsertID = 55555000000 + mt_rand(0, 1000);
2222+ }
2323+ }
2424+2525+ public function openConnection() {
2626+ return;
2727+ }
2828+2929+ public function close() {
3030+ return;
3131+ }
3232+3333+ public function escapeUTF8String($string) {
3434+ return '<S>';
3535+ }
3636+3737+ public function escapeBinaryString($string) {
3838+ return '<B>';
3939+ }
4040+4141+ public function escapeColumnName($name) {
4242+ return '<C>';
4343+ }
4444+4545+ public function escapeMultilineComment($comment) {
4646+ return '<K>';
4747+ }
4848+4949+ public function escapeStringForLikeClause($value) {
5050+ return '<L>';
5151+ }
5252+5353+ private function getConfiguration($key, $default = null) {
5454+ return idx($this->configuration, $key, $default);
5555+ }
5656+5757+ public function getInsertID() {
5858+ return $this->insertID;
5959+ }
6060+6161+ public function getAffectedRows() {
6262+ return $this->affectedRows;
6363+ }
6464+6565+ public function selectAllResults() {
6666+ return $this->allResults;
6767+ }
6868+6969+ public function executeQuery(PhutilQueryString $query) {
7070+7171+ // NOTE: "[\s<>K]*" allows any number of (properly escaped) comments to
7272+ // appear prior to the allowed keyword, since this connection escapes
7373+ // them as "<K>" (above).
7474+7575+ $display_query = $query->getMaskedString();
7676+ $raw_query = $query->getUnmaskedString();
7777+7878+ $keywords = array(
7979+ 'INSERT',
8080+ 'UPDATE',
8181+ 'DELETE',
8282+ 'START',
8383+ 'SAVEPOINT',
8484+ 'COMMIT',
8585+ 'ROLLBACK',
8686+ );
8787+ $preg_keywords = array();
8888+ foreach ($keywords as $key => $word) {
8989+ $preg_keywords[] = preg_quote($word, '/');
9090+ }
9191+ $preg_keywords = implode('|', $preg_keywords);
9292+9393+ if (!preg_match('/^[\s<>K]*('.$preg_keywords.')\s*/i', $raw_query)) {
9494+ throw new AphrontNotSupportedQueryException(
9595+ pht(
9696+ "Database isolation currently only supports some queries. You are ".
9797+ "trying to issue a query which does not begin with an allowed ".
9898+ "keyword (%s): '%s'.",
9999+ implode(', ', $keywords),
100100+ $display_query));
101101+ }
102102+103103+ $this->transcript[] = $display_query;
104104+105105+ // NOTE: This method is intentionally simplified for now, since we're only
106106+ // using it to stub out inserts/updates. In the future it will probably need
107107+ // to grow more powerful.
108108+109109+ $this->allResults = array();
110110+111111+ // NOTE: We jitter the insert IDs to keep tests honest; a test should cover
112112+ // the relationship between objects, not their exact insertion order. This
113113+ // guarantees that IDs are unique but makes it impossible to hard-code tests
114114+ // against this specific implementation detail.
115115+ self::$nextInsertID += mt_rand(1, 10);
116116+ $this->insertID = self::$nextInsertID;
117117+ $this->affectedRows = 1;
118118+ }
119119+120120+ public function executeRawQueries(array $raw_queries) {
121121+ $results = array();
122122+ foreach ($raw_queries as $id => $raw_query) {
123123+ $results[$id] = array();
124124+ }
125125+ return $results;
126126+ }
127127+128128+ public function getQueryTranscript() {
129129+ return $this->transcript;
130130+ }
131131+132132+}
···11+<?php
22+33+interface AphrontDatabaseTableRefInterface {
44+55+ public function getAphrontRefDatabaseName();
66+ public function getAphrontRefTableName();
77+88+}
···11+<?php
22+33+interface PhutilQsprintfInterface {
44+ public function escapeBinaryString($string);
55+ public function escapeUTF8String($string);
66+ public function escapeColumnName($string);
77+ public function escapeMultilineComment($string);
88+ public function escapeStringForLikeClause($string);
99+}
···11+<?php
22+33+final class PhutilQueryString extends Phobject {
44+55+ private $maskedString;
66+ private $unmaskedString;
77+88+ public function __construct(PhutilQsprintfInterface $escaper, array $argv) {
99+ // Immediately render the query into a static scalar value.
1010+1111+ // This makes sure we throw immediately if there are errors in the
1212+ // parameters, which is much better than throwing later on.
1313+1414+ // This also makes sure that later mutations to objects passed as
1515+ // parameters won't affect the outcome. Consider:
1616+ //
1717+ // $object->setTableName('X');
1818+ // $query = qsprintf($conn, '%R', $object);
1919+ // $object->setTableName('Y');
2020+ //
2121+ // We'd like "$query" to reference "X", reflecting the object as it
2222+ // existed when it was passed to "qsprintf(...)". It's surprising if the
2323+ // modification to the object after "qsprintf(...)" can affect "$query".
2424+2525+ $masked_string = xsprintf(
2626+ 'xsprintf_query',
2727+ array(
2828+ 'escaper' => $escaper,
2929+ 'unmasked' => false,
3030+ ),
3131+ $argv);
3232+3333+ $unmasked_string = xsprintf(
3434+ 'xsprintf_query',
3535+ array(
3636+ 'escaper' => $escaper,
3737+ 'unmasked' => true,
3838+ ),
3939+ $argv);
4040+4141+ $this->maskedString = $masked_string;
4242+ $this->unmaskedString = $unmasked_string;
4343+ }
4444+4545+ public function __toString() {
4646+ return $this->getMaskedString();
4747+ }
4848+4949+ public function getUnmaskedString() {
5050+ return $this->unmaskedString;
5151+ }
5252+5353+ public function getMaskedString() {
5454+ return $this->maskedString;
5555+ }
5656+5757+}
+516
src/infrastructure/storage/xsprintf/qsprintf.php
···11+<?php
22+33+/**
44+ * Format an SQL query. This function behaves like `sprintf`, except that all
55+ * the normal conversions (like "%s") will be properly escaped, and additional
66+ * conversions are supported:
77+ *
88+ * %nd, %ns, %nf, %nB
99+ * "Nullable" versions of %d, %s, %f and %B. Will produce 'NULL' if the
1010+ * argument is a strict null.
1111+ *
1212+ * %=d, %=s, %=f
1313+ * "Nullable Test" versions of %d, %s and %f. If you pass a value, you
1414+ * get "= 3"; if you pass null, you get "IS NULL". For instance, this
1515+ * will work properly if `hatID' is a nullable column and $hat is null.
1616+ *
1717+ * qsprintf($escaper, 'WHERE hatID %=d', $hat);
1818+ *
1919+ * %Ld, %Ls, %Lf, %LB
2020+ * "List" versions of %d, %s, %f and %B. These are appropriate for use in
2121+ * an "IN" clause. For example:
2222+ *
2323+ * qsprintf($escaper, 'WHERE hatID IN (%Ld)', $list_of_hats);
2424+ *
2525+ * %B ("Binary String")
2626+ * Escapes a string for insertion into a pure binary column, ignoring
2727+ * tests for characters outside of the basic multilingual plane.
2828+ *
2929+ * %C, %LC, %LK ("Column", "Key Column")
3030+ * Escapes a column name or a list of column names. The "%LK" variant
3131+ * escapes a list of key column specifications which may look like
3232+ * "column(32)".
3333+ *
3434+ * %K ("Comment")
3535+ * Escapes a comment.
3636+ *
3737+ * %Q, %LA, %LO, %LQ, %LJ ("Query Fragment")
3838+ * Injects a query fragment from a prior call to qsprintf(). The list
3939+ * variants join a list of query fragments with AND, OR, comma, or space.
4040+ *
4141+ * %Z ("Raw Query")
4242+ * Injects a raw, unescaped query fragment. Dangerous!
4343+ *
4444+ * %R ("Database and Table Reference")
4545+ * Behaves like "%T.%T" and prints a full reference to a table including
4646+ * the database. Accepts a AphrontDatabaseTableRefInterface.
4747+ *
4848+ * %P ("Password or Secret")
4949+ * Behaves like "%s", but shows "********" when the query is printed in
5050+ * logs or traces. Accepts a PhutilOpaqueEnvelope.
5151+ *
5252+ * %~ ("Substring")
5353+ * Escapes a substring query for a LIKE (or NOT LIKE) clause. For example:
5454+ *
5555+ * // Find all rows with $search as a substring of `name`.
5656+ * qsprintf($escaper, 'WHERE name LIKE %~', $search);
5757+ *
5858+ * See also %> and %<.
5959+ *
6060+ * %> ("Prefix")
6161+ * Escapes a prefix query for a LIKE clause. For example:
6262+ *
6363+ * // Find all rows where `name` starts with $prefix.
6464+ * qsprintf($escaper, 'WHERE name LIKE %>', $prefix);
6565+ *
6666+ * %< ("Suffix")
6767+ * Escapes a suffix query for a LIKE clause. For example:
6868+ *
6969+ * // Find all rows where `name` ends with $suffix.
7070+ * qsprintf($escaper, 'WHERE name LIKE %<', $suffix);
7171+ *
7272+ * %T ("Table")
7373+ * Escapes a table name. In most cases, you should use "%R" instead.
7474+ */
7575+function qsprintf(PhutilQsprintfInterface $escaper, $pattern /* , ... */) {
7676+ $args = func_get_args();
7777+ array_shift($args);
7878+ return new PhutilQueryString($escaper, $args);
7979+}
8080+8181+function vqsprintf(PhutilQsprintfInterface $escaper, $pattern, array $argv) {
8282+ array_unshift($argv, $pattern);
8383+ return new PhutilQueryString($escaper, $argv);
8484+}
8585+8686+/**
8787+ * @{function:xsprintf} callback for encoding SQL queries. See
8888+ * @{function:qsprintf}.
8989+ */
9090+function xsprintf_query($userdata, &$pattern, &$pos, &$value, &$length) {
9191+ $type = $pattern[$pos];
9292+9393+ if (is_array($userdata)) {
9494+ $escaper = $userdata['escaper'];
9595+ $unmasked = $userdata['unmasked'];
9696+ } else {
9797+ $escaper = $userdata;
9898+ $unmasked = false;
9999+ }
100100+101101+ $next = (strlen($pattern) > $pos + 1) ? $pattern[$pos + 1] : null;
102102+ $nullable = false;
103103+ $done = false;
104104+105105+ $prefix = '';
106106+107107+ if (!($escaper instanceof PhutilQsprintfInterface)) {
108108+ throw new InvalidArgumentException(pht('Invalid database escaper.'));
109109+ }
110110+111111+ switch ($type) {
112112+ case '=': // Nullable test
113113+ switch ($next) {
114114+ case 'd':
115115+ case 'f':
116116+ case 's':
117117+ $pattern = substr_replace($pattern, '', $pos, 1);
118118+ $length = strlen($pattern);
119119+ $type = 's';
120120+ if ($value === null) {
121121+ $value = 'IS NULL';
122122+ $done = true;
123123+ } else {
124124+ $prefix = '= ';
125125+ $type = $next;
126126+ }
127127+ break;
128128+ default:
129129+ throw new Exception(
130130+ pht(
131131+ 'Unknown conversion, try %s, %s, or %s.',
132132+ '%=d',
133133+ '%=s',
134134+ '%=f'));
135135+ }
136136+ break;
137137+138138+ case 'n': // Nullable...
139139+ switch ($next) {
140140+ case 'd': // ...integer.
141141+ case 'f': // ...float.
142142+ case 's': // ...string.
143143+ case 'B': // ...binary string.
144144+ $pattern = substr_replace($pattern, '', $pos, 1);
145145+ $length = strlen($pattern);
146146+ $type = $next;
147147+ $nullable = true;
148148+ break;
149149+ default:
150150+ throw new XsprintfUnknownConversionException("%n{$next}");
151151+ }
152152+ break;
153153+154154+ case 'L': // List of..
155155+ qsprintf_check_type($value, "L{$next}", $pattern);
156156+ $pattern = substr_replace($pattern, '', $pos, 1);
157157+ $length = strlen($pattern);
158158+ $type = 's';
159159+ $done = true;
160160+161161+ switch ($next) {
162162+ case 'd': // ...integers.
163163+ $value = implode(', ', array_map('intval', $value));
164164+ break;
165165+ case 'f': // ...floats.
166166+ $value = implode(', ', array_map('floatval', $value));
167167+ break;
168168+ case 's': // ...strings.
169169+ foreach ($value as $k => $v) {
170170+ $value[$k] = "'".$escaper->escapeUTF8String((string)$v)."'";
171171+ }
172172+ $value = implode(', ', $value);
173173+ break;
174174+ case 'B': // ...binary strings.
175175+ foreach ($value as $k => $v) {
176176+ $value[$k] = "'".$escaper->escapeBinaryString((string)$v)."'";
177177+ }
178178+ $value = implode(', ', $value);
179179+ break;
180180+ case 'C': // ...columns.
181181+ foreach ($value as $k => $v) {
182182+ $value[$k] = $escaper->escapeColumnName($v);
183183+ }
184184+ $value = implode(', ', $value);
185185+ break;
186186+ case 'K': // ...key columns.
187187+ // This is like "%LC", but for escaping column lists passed to key
188188+ // specifications. These should be escaped as "`column`(123)". For
189189+ // example:
190190+ //
191191+ // ALTER TABLE `x` ADD KEY `y` (`u`(16), `v`(32));
192192+193193+ foreach ($value as $k => $v) {
194194+ $matches = null;
195195+ if (preg_match('/\((\d+)\)\z/', $v, $matches)) {
196196+ $v = substr($v, 0, -(strlen($matches[1]) + 2));
197197+ $prefix_len = '('.((int)$matches[1]).')';
198198+ } else {
199199+ $prefix_len = '';
200200+ }
201201+202202+ $value[$k] = $escaper->escapeColumnName($v).$prefix_len;
203203+ }
204204+205205+ $value = implode(', ', $value);
206206+ break;
207207+ case 'Q':
208208+ // TODO: Here, and in "%LO", "%LA", and "%LJ", we should eventually
209209+ // stop accepting strings.
210210+ foreach ($value as $k => $v) {
211211+ if (is_string($v)) {
212212+ continue;
213213+ }
214214+ $value[$k] = $v->getUnmaskedString();
215215+ }
216216+ $value = implode(', ', $value);
217217+ break;
218218+ case 'O':
219219+ foreach ($value as $k => $v) {
220220+ if (is_string($v)) {
221221+ continue;
222222+ }
223223+ $value[$k] = $v->getUnmaskedString();
224224+ }
225225+ if (count($value) == 1) {
226226+ $value = '('.head($value).')';
227227+ } else {
228228+ $value = '(('.implode(') OR (', $value).'))';
229229+ }
230230+ break;
231231+ case 'A':
232232+ foreach ($value as $k => $v) {
233233+ if (is_string($v)) {
234234+ continue;
235235+ }
236236+ $value[$k] = $v->getUnmaskedString();
237237+ }
238238+ if (count($value) == 1) {
239239+ $value = '('.head($value).')';
240240+ } else {
241241+ $value = '(('.implode(') AND (', $value).'))';
242242+ }
243243+ break;
244244+ case 'J':
245245+ foreach ($value as $k => $v) {
246246+ if (is_string($v)) {
247247+ continue;
248248+ }
249249+ $value[$k] = $v->getUnmaskedString();
250250+ }
251251+ $value = implode(' ', $value);
252252+ break;
253253+ default:
254254+ throw new XsprintfUnknownConversionException("%L{$next}");
255255+ }
256256+ break;
257257+ }
258258+259259+ if (!$done) {
260260+ qsprintf_check_type($value, $type, $pattern);
261261+ switch ($type) {
262262+ case 's': // String
263263+ if ($nullable && $value === null) {
264264+ $value = 'NULL';
265265+ } else {
266266+ $value = "'".$escaper->escapeUTF8String((string)$value)."'";
267267+ }
268268+ $type = 's';
269269+ break;
270270+271271+ case 'B': // Binary String
272272+ if ($nullable && $value === null) {
273273+ $value = 'NULL';
274274+ } else {
275275+ $value = "'".$escaper->escapeBinaryString((string)$value)."'";
276276+ }
277277+ $type = 's';
278278+ break;
279279+280280+ case 'Q': // Query Fragment
281281+ if ($value instanceof PhutilQueryString) {
282282+ $value = $value->getUnmaskedString();
283283+ }
284284+ $type = 's';
285285+ break;
286286+287287+ case 'Z': // Raw Query Fragment
288288+ $type = 's';
289289+ break;
290290+291291+ case '~': // Like Substring
292292+ case '>': // Like Prefix
293293+ case '<': // Like Suffix
294294+ $value = $escaper->escapeStringForLikeClause($value);
295295+ switch ($type) {
296296+ case '~': $value = "'%".$value."%'"; break;
297297+ case '>': $value = "'".$value."%'"; break;
298298+ case '<': $value = "'%".$value."'"; break;
299299+ }
300300+ $type = 's';
301301+ break;
302302+303303+ case 'f': // Float
304304+ if ($nullable && $value === null) {
305305+ $value = 'NULL';
306306+ } else {
307307+ $value = (float)$value;
308308+ }
309309+ $type = 's';
310310+ break;
311311+312312+ case 'd': // Integer
313313+ if ($nullable && $value === null) {
314314+ $value = 'NULL';
315315+ } else {
316316+ $value = (int)$value;
317317+ }
318318+ $type = 's';
319319+ break;
320320+321321+ case 'T': // Table
322322+ case 'C': // Column
323323+ $value = $escaper->escapeColumnName($value);
324324+ $type = 's';
325325+ break;
326326+327327+ case 'K': // Komment
328328+ $value = $escaper->escapeMultilineComment($value);
329329+ $type = 's';
330330+ break;
331331+332332+ case 'R': // Database + Table Reference
333333+ $database_name = $value->getAphrontRefDatabaseName();
334334+ $database_name = $escaper->escapeColumnName($database_name);
335335+336336+ $table_name = $value->getAphrontRefTableName();
337337+ $table_name = $escaper->escapeColumnName($table_name);
338338+339339+ $value = $database_name.'.'.$table_name;
340340+ $type = 's';
341341+ break;
342342+343343+ case 'P': // Password or Secret
344344+ if ($unmasked) {
345345+ $value = $value->openEnvelope();
346346+ $value = "'".$escaper->escapeUTF8String($value)."'";
347347+ } else {
348348+ $value = '********';
349349+ }
350350+ $type = 's';
351351+ break;
352352+353353+ default:
354354+ throw new XsprintfUnknownConversionException($type);
355355+ }
356356+ }
357357+358358+ if ($prefix) {
359359+ $value = $prefix.$value;
360360+ }
361361+362362+ $pattern[$pos] = $type;
363363+}
364364+365365+function qsprintf_check_type($value, $type, $query) {
366366+ switch ($type) {
367367+ case 'Ld':
368368+ case 'Ls':
369369+ case 'LC':
370370+ case 'LK':
371371+ case 'LB':
372372+ case 'Lf':
373373+ case 'LQ':
374374+ case 'LA':
375375+ case 'LO':
376376+ case 'LJ':
377377+ if (!is_array($value)) {
378378+ throw new AphrontParameterQueryException(
379379+ $query,
380380+ pht('Expected array argument for %%%s conversion.', $type));
381381+ }
382382+ if (empty($value)) {
383383+ throw new AphrontParameterQueryException(
384384+ $query,
385385+ pht('Array for %%%s conversion is empty.', $type));
386386+ }
387387+388388+ foreach ($value as $scalar) {
389389+ qsprintf_check_scalar_type($scalar, $type, $query);
390390+ }
391391+ break;
392392+ default:
393393+ qsprintf_check_scalar_type($value, $type, $query);
394394+ break;
395395+ }
396396+}
397397+398398+function qsprintf_check_scalar_type($value, $type, $query) {
399399+ switch ($type) {
400400+ case 'LQ':
401401+ case 'LA':
402402+ case 'LO':
403403+ case 'LJ':
404404+ // TODO: See T13217. Remove this eventually.
405405+ if (is_string($value)) {
406406+ phlog(
407407+ pht(
408408+ 'UNSAFE: Raw string ("%s") passed to query ("%s") subclause '.
409409+ 'for "%%%s" conversion. Subclause conversions should be passed '.
410410+ 'a list of PhutilQueryString objects.',
411411+ $value,
412412+ $query,
413413+ $type));
414414+ break;
415415+ }
416416+417417+ if (!($value instanceof PhutilQueryString)) {
418418+ throw new AphrontParameterQueryException(
419419+ $query,
420420+ pht(
421421+ 'Expected a list of PhutilQueryString objects for %%%s '.
422422+ 'conversion.',
423423+ $type));
424424+ }
425425+ break;
426426+427427+ case 'Q':
428428+ // TODO: See T13217. Remove this eventually.
429429+ if (is_string($value)) {
430430+ phlog(
431431+ pht(
432432+ 'UNSAFE: Raw string ("%s") passed to query ("%s") for "%%Q" '.
433433+ 'conversion. %%Q should be passed a query string.',
434434+ $value,
435435+ $query));
436436+ break;
437437+ }
438438+439439+ if (!($value instanceof PhutilQueryString)) {
440440+ throw new AphrontParameterQueryException(
441441+ $query,
442442+ pht('Expected a PhutilQueryString for %%%s conversion.', $type));
443443+ }
444444+ break;
445445+446446+ case 'Z':
447447+ if (!is_string($value)) {
448448+ throw new AphrontParameterQueryException(
449449+ $query,
450450+ pht('Value for "%%Z" conversion should be a raw string.'));
451451+ }
452452+ break;
453453+454454+ case 'LC':
455455+ case 'LK':
456456+ case 'T':
457457+ case 'C':
458458+ if (!is_string($value)) {
459459+ throw new AphrontParameterQueryException(
460460+ $query,
461461+ pht('Expected a string for %%%s conversion.', $type));
462462+ }
463463+ break;
464464+465465+ case 'Ld':
466466+ case 'Lf':
467467+ case 'd':
468468+ case 'f':
469469+ if (!is_null($value) && !is_numeric($value)) {
470470+ throw new AphrontParameterQueryException(
471471+ $query,
472472+ pht('Expected a numeric scalar or null for %%%s conversion.', $type));
473473+ }
474474+ break;
475475+476476+ case 'Ls':
477477+ case 's':
478478+ case 'LB':
479479+ case 'B':
480480+ case '~':
481481+ case '>':
482482+ case '<':
483483+ case 'K':
484484+ if (!is_null($value) && !is_scalar($value)) {
485485+ throw new AphrontParameterQueryException(
486486+ $query,
487487+ pht('Expected a scalar or null for %%%s conversion.', $type));
488488+ }
489489+ break;
490490+491491+ case 'R':
492492+ if (!($value instanceof AphrontDatabaseTableRefInterface)) {
493493+ throw new AphrontParameterQueryException(
494494+ $query,
495495+ pht(
496496+ 'Parameter to "%s" conversion in "qsprintf(...)" is not an '.
497497+ 'instance of AphrontDatabaseTableRefInterface.',
498498+ '%R'));
499499+ }
500500+ break;
501501+502502+ case 'P':
503503+ if (!($value instanceof PhutilOpaqueEnvelope)) {
504504+ throw new AphrontParameterQueryException(
505505+ $query,
506506+ pht(
507507+ 'Parameter to "%s" conversion in "qsprintf(...)" is not an '.
508508+ 'instance of PhutilOpaqueEnvelope.',
509509+ '%P'));
510510+ }
511511+ break;
512512+513513+ default:
514514+ throw new XsprintfUnknownConversionException($type);
515515+ }
516516+}