diff --git a/components/Link/Link.php b/components/Link/Link.php index 36b3229d6d..7457296aea 100644 --- a/components/Link/Link.php +++ b/components/Link/Link.php @@ -22,6 +22,7 @@ namespace Component\Link; use App\Core\DB\DB; +use App\Core\Event; use App\Core\Modules\Component; use App\Entity; use App\Entity\NoteToLink; @@ -38,26 +39,22 @@ class Link extends Component END; /** - * TODO PLACEHOLDER + * Extract URLs from $content and create the appropriate Link and NoteToLink entities */ public function onProcessNoteContent(int $note_id, string $content) { if (Common::config('attachments', 'process_links')) { - $matched_urls = []; - $processed_urls = false; + $matched_urls = []; preg_match_all(self::URL_REGEX, $content, $matched_urls, PREG_SET_ORDER); foreach ($matched_urls as $match) { try { $link_id = Entity\Link::getOrCreate($match[0])->getId(); DB::persist(NoteToLink::create(['link_id' => $link_id, 'note_id' => $note_id])); - $processed_urls = true; } catch (InvalidArgumentException) { continue; } } - if ($processed_urls) { - DB::flush(); - } } + return Event::next; } } diff --git a/components/Posting/Posting.php b/components/Posting/Posting.php index f97c79ccfa..8976f0b384 100644 --- a/components/Posting/Posting.php +++ b/components/Posting/Posting.php @@ -25,19 +25,17 @@ use App\Core\Cache; use App\Core\DB\DB; use App\Core\Event; use App\Core\Form; -use App\Core\GSFile; use function App\Core\I18n\_m; use App\Core\Modules\Component; use App\Entity\Attachment; -use App\Entity\AttachmentToNote; -use App\Entity\GSActorToAttachment; +use App\Entity\GSActor; use App\Entity\Note; use App\Util\Common; use App\Util\Exception\ClientException; -use App\Util\Exception\DuplicateFoundException; use App\Util\Exception\InvalidFormException; use App\Util\Exception\RedirectException; use App\Util\Exception\ServerException; +use App\Util\Formatting; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; @@ -61,9 +59,8 @@ class Posting extends Component $actor_id = $user->getId(); $to_tags = []; - $tags = Cache::get("actor-circle-{$actor_id}", function () use ($actor_id) { - return DB::dql('select c.tag from App\Entity\GSActorCircle c where c.tagger = :tagger', ['tagger' => $actor_id]); - }); + $tags = Cache::get("actor-circle-{$actor_id}", + fn () => DB::dql('select c.tag from App\Entity\GSActorCircle c where c.tagger = :tagger', ['tagger' => $actor_id])); foreach ($tags as $t) { $t = $t['tag']; $to_tags[$t] = $t; @@ -76,8 +73,8 @@ class Posting extends Component $initial_content = ''; Event::handle('PostingInitialContent', [&$initial_content]); - $content_type = ['Plain Text' => 'text/plain']; - Event::handle('PostingAvailableContentTypes', [&$content_type]); + $available_content_types = ['Plain Text' => 'text/plain']; + Event::handle('PostingAvailableContentTypes', [&$available_content_types]); $request = $vars['request']; $form_params = [ @@ -86,8 +83,11 @@ class Posting extends Component ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [_m('Public') => 'public', _m('Instance') => 'instance', _m('Private') => 'private']]], ['to', ChoiceType::class, ['label' => _m('To:'), 'multiple' => false, 'expanded' => false, 'choices' => $to_tags]], ]; - if (count($content_type) > 1) { - $form_params[] = ['content_type', ChoiceType::class, ['label' => _m('Text format:'), 'multiple' => false, 'expanded' => false, 'data' => 'text/plain', 'choices' => $content_type]]; + if (count($available_content_types) > 1) { + $form_params[] = ['content_type', ChoiceType::class, + ['label' => _m('Text format:'), 'multiple' => false, 'expanded' => false, + 'data' => $available_content_types[array_key_first($available_content_types)], + 'choices' => $available_content_types, ], ]; } $form_params[] = ['post_note', SubmitType::class, ['label' => _m('Post')]]; $form = Form::create($form_params); @@ -96,7 +96,8 @@ class Posting extends Component if ($form->isSubmitted()) { $data = $form->getData(); if ($form->isValid()) { - self::storeNote($actor_id, $data['content_type'] ?? array_key_first($content_type), $data['content'], $data['attachments'], is_local: true); + $content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)]; + self::storeLocalNote($user->getActor(), $data['content'], $content_type, $data['attachments']); throw new RedirectException(); } else { throw new InvalidFormException(); @@ -108,52 +109,30 @@ class Posting extends Component return Event::next; } - /** - * Store the given note with $content and $attachments, created by - * $actor_id, possibly as a reply to note $reply_to and with flag - * $is_local. Sanitizes $content and $attachments - * - * @throws DuplicateFoundException - * @throws ClientException|ServerException - */ - public static function storeNote(int $actor_id, string $content_type, string $content, array $attachments, bool $is_local, ?int $reply_to = null, ?int $repeat_of = null) + public static function storeLocalNote(GSActor $actor, string $content, string $content_type, array $attachments, ?Note $reply_to = null, ?Note $repeat_of = null) { + $rendered = null; + Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $reply_to]); $note = Note::create([ - 'gsactor_id' => $actor_id, - 'content_type' => $content_type, + 'gsactor_id' => $actor->getId(), 'content' => $content, - 'is_local' => $is_local, - 'reply_to' => $reply_to, - 'repeat_of' => $repeat_of, + 'content_type' => $content_type, + 'rendered' => $rendered, + 'attachments' => $attachments, // Not a regular field + 'is_local' => true, ]); - - $processed_attachments = []; - foreach ($attachments as $f) { // where $f is a Symfony\Component\HttpFoundation\File\UploadedFile - $filesize = $f->getSize(); - $max_file_size = Common::config('attachments', 'file_quota'); - if ($max_file_size < $filesize) { - throw new ClientException(_m('No file may be larger than {quota} bytes and the file you sent was {size} bytes. ' . - 'Try to upload a smaller version.', ['quota' => $max_file_size, 'size' => $filesize])); - } - Event::handle('EnforceUserFileQuota', [$filesize, $actor_id]); - $processed_attachments[] = [GSFile::sanitizeAndStoreFileAsAttachment($f), $f->getClientOriginalName()]; - } - - DB::persist($note); - - // Need file and note ids for the next step + Event::handle('ProcessNoteContent', [$note->getId(), $content, $content_type]); DB::flush(); - if ($processed_attachments != []) { - foreach ($processed_attachments as [$a, $fname]) { - if (empty(DB::findBy('gsactor_to_attachment', ['attachment_id' => $a->getId(), 'gsactor_id' => $actor_id]))) { - DB::persist(GSActorToAttachment::create(['attachment_id' => $a->getId(), 'gsactor_id' => $actor_id])); - } - DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname])); - } - DB::flush(); - } + } - Event::handle('ProcessNoteContent', [$note->getId(), $content]); + public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, GSActor $author, ?Note $reply_to = null) + { + if ($content_type === 'text/plain') { + $content = Formatting::renderPlainText($content); + $rendered = Formatting::linkifyMentions($content, $author, $reply_to); + return Event::stop; + } + return Event::next; } /** diff --git a/components/Tag/Controller/Tag.php b/components/Tag/Controller/Tag.php new file mode 100644 index 0000000000..328d43648e --- /dev/null +++ b/components/Tag/Controller/Tag.php @@ -0,0 +1,13 @@ +. + +// }}} + +namespace Component\Tag; + +use App\Core\DB\DB; +use App\Core\Event; +use App\Core\Modules\Component; +use App\Entity\NoteTag; + +/** + * Component responsible for extracting tags from posted notes, as well as normalizing them + * + * @author Hugo Sales + * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class Tag extends Component +{ + const TAG_REGEX = '/(?:^|\s)#([\pL\pN_\-\.]{1,64})/u'; // Brion Vibber 2011-02-23 v2:classes/Notice.php:367 function saveTags + + /** + * Process note by extracting any tags present + */ + public function onProcessNoteContent(int $note_id, string $content) + { + $matched_tags = []; + $processed_tags = false; + preg_match_all(self::TAG_REGEX, $content, $matched_tags, PREG_SET_ORDER); + foreach ($matched_tags as $match) { + DB::persist($tag = NoteTag::create(['tag' => $match[0], 'note_id' => $note_id])); + $processed_tags = true; + } + if ($processed_tags) { + DB::flush(); + } + } + + public function onAddRoute($r): bool + { + $r->connect('tag', '/tag/{tag' . self::TAG_REGEX . '}' , [Controller\Tag::class, 'tag']); + return Event::next; + } +} diff --git a/config/bootstrap.php b/config/bootstrap.php index 2e9f8b4236..9b49a9f865 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -41,6 +41,17 @@ define('GNUSOCIAL_CODENAME', 'Big bang'); define('MODULE_CACHE_FILE', INSTALLDIR . '/var/cache/module_manager.php'); +/** + * StatusNet had this string as valid path characters: '\pN\pL\,\!\(\)\.\:\-\_\+\/\=\&\;\%\~\*\$\'\@' + * Some of those characters can be troublesome when auto-linking plain text. Such as "http://some.com/)" + * URL encoding should be used whenever a weird character is used, the following strings are not definitive. + */ +define('URL_REGEX_VALID_PATH_CHARS', '\pN\pL\,\!\.\:\-\_\+\/\@\=\;\%\~\*\(\)'); +define('URL_REGEX_VALID_QSTRING_CHARS', URL_REGEX_VALID_PATH_CHARS . '\&'); +define('URL_REGEX_VALID_FRAGMENT_CHARS', URL_REGEX_VALID_QSTRING_CHARS . '\?\#'); +define('URL_REGEX_EXCLUDED_END_CHARS', '\?\.\,\!\#\:\''); // don't include these if they are directly after a URL +define('URL_REGEX_DOMAIN_NAME', '(?:(?!-)[A-Za-z0-9\-]{1,63}(? $max_file_size, 'size' => $filesize])); + } + $max_user_quota = Common::config('attachments', 'user_quota'); if ($max_user_quota !== false) { // If not disabled $cache_key_user_total = "FileQuota-total-user-{$user_id}"; diff --git a/plugins/Reply/Controller/Reply.php b/plugins/Reply/Controller/Reply.php index a23f54ba2a..9354a5458b 100644 --- a/plugins/Reply/Controller/Reply.php +++ b/plugins/Reply/Controller/Reply.php @@ -57,7 +57,6 @@ class Reply extends Controller } $form = Form::create([ - ['content', TextareaType::class, [ 'label' => _m('Reply'), 'label_attr' => ['class' => 'section-form-label'], @@ -73,12 +72,11 @@ class Reply extends Controller if ($form->isSubmitted()) { $data = $form->getData(); if ($form->isValid()) { - Posting::storeNote( - actor_id: $actor_id, - content_type: 'text/plain', + Posting::storeLocalNote( + actor: $user->getActor(), content: $data['content'], + content_type: 'text/plain', // TODO attachments: $data['attachments'], - is_local: true, reply_to: $reply_to, repeat_of: null ); diff --git a/plugins/Reply/Reply.php b/plugins/Reply/Reply.php index fbd097dc7d..ddde386556 100644 --- a/plugins/Reply/Reply.php +++ b/plugins/Reply/Reply.php @@ -32,7 +32,6 @@ use Plugin\Reply\Controller\Reply as ReplyController; use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\HttpFoundation\Request; -use function App\Core\I18n\_m; class Reply extends NoteHandlerPlugin { @@ -59,7 +58,6 @@ class Reply extends NoteHandlerPlugin ['content', HiddenType::class, ['label' => ' ', 'required' => false]], ['attachments', HiddenType::class, ['label' => ' ', 'required' => false]], ['note_id', HiddenType::class, ['data' => $note->getId()]], - ['reply', SubmitType::class, [ 'label' => ' ', @@ -76,14 +74,13 @@ class Reply extends NoteHandlerPlugin if ($data['content'] !== null) { // JS submitted // TODO Implement in JS - $actor_id = $user->getId(); - Posting::storeNote( - $actor_id, - $data['content'], - $data['attachments'], - $is_local = true, - $data['reply_to'], - $repeat_of = null + Posting::storeLocalNote( + actor: $user->getActor(), + content: $data['content'], + content_type: 'text/plain', + attachments: $data['attachments'], + reply_to: $data['reply_to'], + repeat_of: null ); } else { // JS disabled, redirect diff --git a/src/Entity/GSActor.php b/src/Entity/GSActor.php index 397278e6f9..58698b8e4f 100644 --- a/src/Entity/GSActor.php +++ b/src/Entity/GSActor.php @@ -25,7 +25,10 @@ use App\Core\Cache; use App\Core\DB\DB; use App\Core\Entity; use App\Core\Event; +use App\Core\Router\Router; use App\Core\UserRoles; +use App\Util\Exception\NicknameException; +use App\Util\Nickname; use DateTimeInterface; use Functional as F; @@ -282,6 +285,45 @@ class GSActor extends Entity }); } + public function isPerson(): bool + { + return ($this->roles & UserRoles::BOT) === 0; + } + + /** + * Resolve an ambiguous nickname reference, checking in following order: + * - Actors that $sender subscribes to + * - Actors that subscribe to $sender + * - Any Actor + * + * @param string $nickname validated nickname of + * + * @throws NicknameException + */ + public function findRelativeActor(string $nickname): ?self + { + // Will throw exception on invalid input. + $nickname = Nickname::normalize($nickname, check_already_used: false); + return Cache::get('relative-nickname-' . $nickname . '-' . $this->getId(), + fn () => DB::dql('select a from gsactor a where ' . + 'a.id in (select followed from follow f join gsactor a on f.followed = a.id where and f.follower = :actor_id and a.nickname = :nickname) or' . + 'a.id in (select follower from follow f join gsactor a on f.follower = a.id where and f.followed = :actor_id and a.nickname = :nickname) or' . + 'a.nickname = :nickname' . + 'limit 1', + ['nickname' => $nickname, 'actor_id' => $this->getId()] + )); + } + + public function getUri(): string + { + return Router::url('actor_id', ['actor_id' => $this->getId()]); + } + + public function getUrl(): string + { + return Router::url('actor_nickname', ['actor_nickname' => $this->getNickname()]); + } + public static function schemaDef(): array { $def = [ diff --git a/src/Entity/GSActorCircle.php b/src/Entity/GSActorCircle.php index 0b4578d4ef..3d4b8a4bb8 100644 --- a/src/Entity/GSActorCircle.php +++ b/src/Entity/GSActorCircle.php @@ -122,9 +122,8 @@ class GSActorCircle extends Entity 'name' => 'gsactor_circle', 'description' => 'a gsactor can have lists of gsactors, to separate their timeline', 'fields' => [ - 'tagger' => ['type' => 'int', 'foreign key' => true, 'target' => 'GSActor.id', 'multiplicity' => 'many to one', 'name' => 'gsactor_list_tagger_fkey', 'not null' => true, 'description' => 'user making the tag'], - 'tag' => ['type' => 'varchar', 'length' => 64 //, 'foreign key' => true, 'target' => 'GSActorTag.tag', 'multiplicity' => 'many to one' // so, Doctrine doesn't like that the target is not unique, even though the pair is - , 'not null' => true, 'description' => 'gsactor tag', ], // Join with GSActorTag + 'tagger' => ['type' => 'int', 'foreign key' => true, 'target' => 'GSActor.id', 'multiplicity' => 'many to one', 'name' => 'gsactor_list_tagger_fkey', 'not null' => true, 'description' => 'user making the tag'], + 'tag' => ['type' => 'varchar', 'length' => 64, 'foreign key' => true, 'target' => 'GSActorTag.tag', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'gsactor tag'], // Join with GSActorTag // // so, Doctrine doesn't like that the target is not unique, even though the pair is 'description' => ['type' => 'text', 'description' => 'description of the people tag'], 'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'], 'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'], diff --git a/src/Entity/Group.php b/src/Entity/Group.php index 118a6cbee0..283c59b2ce 100644 --- a/src/Entity/Group.php +++ b/src/Entity/Group.php @@ -19,8 +19,14 @@ namespace App\Entity; +use App\Core\Cache; +use App\Core\DB\DB; use App\Core\Entity; +use App\Core\Router\Router; +use App\Util\Exception\NotFoundException; +use App\Util\Nickname; use DateTimeInterface; +use InvalidArgumentException; /** * Entity for groups a user is in @@ -260,6 +266,29 @@ class Group extends Entity // @codeCoverageIgnoreEnd // }}} Autocode + public function getActor(): GSActor + { + return GSActor::getFromId($this->getId()); + } + + public static function getFromNickname(string $nickname, ?GSActor $actor = null): ?self + { + $nickname = Nickname::normalize($nickname, check_already_used: false); + $group = null; + try { + $group = Cache::get('group-nick-' . $nickname, fn () => DB::findOneBy('group', ['nickname' => $nickname])); + // TODO check group scope with $actor + } catch (NotFoundException) { + throw new InvalidArgumentException; + } + return $group; + } + + public function getUrl(): string + { + return Router::url('group_nickname', ['actor_nickname' => $this->getNickname()]); + } + public static function schemaDef(): array { return [ diff --git a/src/Entity/Link.php b/src/Entity/Link.php index 84fdc106b1..c572209579 100644 --- a/src/Entity/Link.php +++ b/src/Entity/Link.php @@ -30,7 +30,8 @@ use App\Util\Exception\DuplicateFoundException; use App\Util\Exception\NotFoundException; use DateTimeInterface; use InvalidArgumentException; -use Symfony\Component\HttpClient\Exception\ClientException; +use Symfony\Component\HttpClient\Exception\ClientException as HTTPClientException; +use Symfony\Component\HttpClient\Exception\TransportException; /** * Entity for representing a Link @@ -144,12 +145,12 @@ class Link extends Entity // Forbidden throw new InvalidArgumentException(message: "A Link can't point to a local location ({$url}), it must be a remote one", code: 400); } - $head = HTTPClient::head($url); - // This must come before getInfo given that Symfony HTTPClient is lazy (thus forcing curl exec) try { + $head = HTTPClient::head($url); + // This must come before getInfo given that Symfony HTTPClient is lazy (thus forcing curl exec) $headers = $head->getHeaders(); // @codeCoverageIgnoreStart - } catch (ClientException $e) { + } catch (HTTPClientException | TransportException $e) { throw new InvalidArgumentException(previous: $e); // @codeCoverageIgnoreEnd } diff --git a/src/Entity/Note.php b/src/Entity/Note.php index e5a5a7affd..306a1db8e8 100644 --- a/src/Entity/Note.php +++ b/src/Entity/Note.php @@ -25,6 +25,7 @@ use App\Core\Cache; use App\Core\DB\DB; use App\Core\Entity; use App\Core\Event; +use App\Core\GSFile; use App\Core\VisibilityScope; use DateTimeInterface; @@ -208,7 +209,12 @@ class Note extends Entity // @codeCoverageIgnoreEnd // }}} Autocode - public function getActorNickname() + public function getActor(): GSActor + { + return GSActor::getFromId($this->gsactor_id); + } + + public function getActorNickname(): string { return GSActor::getNicknameFromId($this->gsactor_id); } @@ -219,13 +225,14 @@ class Note extends Entity Event::handle('GetAvatarUrl', [$this->getGSActorId(), &$url]); return $url; } + public static function getAllNotes(int $noteScope): array { return DB::sql('select * from note n ' . - 'where n.reply_to is null and (n.scope & :notescope) <> 0 ' . - 'order by n.created DESC', - ['n' => 'App\Entity\Note'], - ['notescope' => $noteScope] + 'where n.reply_to is null and (n.scope & :notescope) <> 0 ' . + 'order by n.created DESC', + ['n' => 'App\Entity\Note'], + ['notescope' => $noteScope] ); } @@ -290,6 +297,49 @@ class Note extends Entity ['note_id' => $this->id, 'actor_id' => $a->getId()])); } + /** + * Create an instance of NoteToLink or fill in the + * properties of $obj with the associative array $args. Does + * persist the result + */ + public static function create(array $args, mixed $obj = null): self + { + /** @var \Symfony\Component\HttpFoundation\File\UploadedFile[] $attachments */ + $attachments = $args['attachments']; + unset($args['attachments']); + + $note = parent::create($args, new self()); + + $processed_attachments = []; + foreach ($attachments as $f) { + Event::handle('EnforceUserFileQuota', [$f->getSize(), $args['gsactor_id']]); + $processed_attachments[] = [GSFile::sanitizeAndStoreFileAsAttachment($f), $f->getClientOriginalName()]; + } + + // Need file and note ids for the next step + DB::persist($note); + + if ($processed_attachments != []) { + foreach ($processed_attachments as [$a, $fname]) { + if (DB::count('gsactor_to_attachment', $args = ['attachment_id' => $a->getId(), 'gsactor_id' => $args['gsactor_id']]) === 0) { + DB::persist(GSActorToAttachment::create($args)); + } + DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname])); + } + } + + return $note; + } + + /** + * @return GSActor[] + */ + public function getAttentionProfiles(): array + { + // TODO implement + return []; + } + public static function schemaDef(): array { return [ diff --git a/src/Entity/NoteToLink.php b/src/Entity/NoteToLink.php index cdebaf2e93..6555da4950 100644 --- a/src/Entity/NoteToLink.php +++ b/src/Entity/NoteToLink.php @@ -74,6 +74,8 @@ class NoteToLink extends Entity { return $this->modified; } + // @codeCoverageIgnoreEnd + // }}} Autocode /** * Create an instance of NoteToLink or fill in the @@ -91,9 +93,6 @@ class NoteToLink extends Entity return parent::create($args, $obj); } - // @codeCoverageIgnoreEnd - // }}} Autocode - public static function schemaDef(): array { return [ diff --git a/src/Util/Formatting.php b/src/Util/Formatting.php index d815c1e2ba..49d25eaf32 100644 --- a/src/Util/Formatting.php +++ b/src/Util/Formatting.php @@ -30,7 +30,13 @@ namespace App\Util; +use App\Core\Event; use App\Core\Log; +use App\Core\Router\Router; +use App\Entity\Group; +use App\Entity\GSActor; +use App\Entity\Note; +use App\Util\Exception\NicknameException; use App\Util\Exception\ServerException; use Functional as F; use InvalidArgumentException; @@ -246,4 +252,501 @@ abstract class Formatting } return false; } + + /** + * Render a plain text note content into HTML, extracting links and tags + */ + public static function renderPlainText(string $text): string + { + $text = self::removeUnicodeFormattingCodes($text); + $text = nl2br(htmlspecialchars($text, flags: ENT_QUOTES | ENT_SUBSTITUTE, double_encode: false), use_xhtml: false); + + // Remove ASCII control codes + $text = preg_replace('/[\x{0}-\x{8}\x{b}-\x{c}\x{e}-\x{19}]/', '', $text); + $text = self::replaceURLs($text, [self::class, 'linkify']); + $text = preg_replace_callback('/(^|\"\;|\'|\(|\[|\{|\s+)#([\pL\pN_\-\.]{1,64})/u', + fn ($m) => "{$m[1]}#" . self::tagLink($m[2]), $text); + + return $text; + } + + /** + * Strip Unicode text formatting/direction codes. This is can be + * pretty dangerous for visualisation of text or be used for + * mischief + */ + public static function removeUnicodeFormattingCodes(string $text): string + { + return preg_replace('/[\\x{200b}-\\x{200f}\\x{202a}-\\x{202e}]/u', '', $text); + } + + const URL_SCHEME_COLON_DOUBLE_SLASH = 1; + const URL_SCHEME_SINGLE_COLON = 2; + const URL_SCHEME_NO_DOMAIN = 4; + const URL_SCHEME_COLON_COORDINATES = 8; + + public static function URLSchemes($filter = null) + { + // TODO: move these to config + $schemes = [ + 'http' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'https' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'ftp' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'ftps' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'mms' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'rtsp' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'gopher' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'news' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'nntp' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'telnet' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'wais' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'file' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'prospero' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'webcal' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'irc' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'ircs' => self::URL_SCHEME_COLON_DOUBLE_SLASH, + 'aim' => self::URL_SCHEME_SINGLE_COLON, + 'bitcoin' => self::URL_SCHEME_SINGLE_COLON, + 'fax' => self::URL_SCHEME_SINGLE_COLON, + 'jabber' => self::URL_SCHEME_SINGLE_COLON, + 'mailto' => self::URL_SCHEME_SINGLE_COLON, + 'tel' => self::URL_SCHEME_SINGLE_COLON, + 'xmpp' => self::URL_SCHEME_SINGLE_COLON, + 'magnet' => self::URL_SCHEME_NO_DOMAIN, + 'geo' => self::URL_SCHEME_COLON_COORDINATES, + ]; + + return array_keys(array_filter($schemes, fn ($scheme) => is_null($filter) || ($scheme & $filter))); + } + + /** + * Find links in the given text and pass them to the given callback function. + * + * @param string $text + * @param callable(string $text, mixed $arg): string $callback: return replacement text + * @param mixed $arg: optional argument will be passed on to the callback + */ + public static function replaceURLs(string $text, callable $callback, mixed $arg = null) + { + $geouri_labeltext_regex = '\pN\pL\-'; + $geouri_mark_regex = '\-\_\.\!\~\*\\\'\(\)'; // the \\\' is really pretty + $geouri_unreserved_regex = '\pN\pL' . $geouri_mark_regex; + $geouri_punreserved_regex = '\[\]\:\&\+\$'; + $geouri_pctencoded_regex = '(?:\%[0-9a-fA-F][0-9a-fA-F])'; + $geouri_paramchar_regex = $geouri_unreserved_regex . $geouri_punreserved_regex; //FIXME: add $geouri_pctencoded_regex here so it works + + // Start off with a regex + $regex = '#' . + '(?:^|[\s\<\>\(\)\[\]\{\}\\\'\\\";]+)(?![\@\!\#])' . + '(' . + '(?:' . + '(?:' . //Known protocols + '(?:' . + '(?:(?:' . implode('|', self::URLSchemes(self::URL_SCHEME_COLON_DOUBLE_SLASH)) . ')://)' . + '|' . + '(?:(?:' . implode('|', self::URLSchemes(self::URL_SCHEME_SINGLE_COLON)) . '):)' . + ')' . + '(?:[\pN\pL\-\_\+\%\~]+(?::[\pN\pL\-\_\+\%\~]+)?\@)?' . //user:pass@ + '(?:' . + '(?:' . + '\[[\pN\pL\-\_\:\.]+(? self::callbackHelper($matches, $callback, $arg), $text); + } + + /** + * Intermediate callback for common_replace_links(), helps resolve some + * ambiguous link forms before passing on to the final callback. + * + * @param array $matches + * @param callable $callback + * @param mixed $arg optional argument to pass on as second param to callback + * + * @return string + * + */ + private static function callbackHelper(array $matches, callable $callback, mixed $arg = null): string + { + $url = $matches[1]; + $left = strpos($matches[0], $url); + $right = $left + strlen($url); + + $groupSymbolSets = [ + [ + 'left' => '(', + 'right' => ')', + ], + [ + 'left' => '[', + 'right' => ']', + ], + [ + 'left' => '{', + 'right' => '}', + ], + [ + 'left' => '<', + 'right' => '>', + ], + ]; + + $cannotEndWith = ['.', '?', ',', '#']; + do { + $original_url = $url; + foreach ($groupSymbolSets as $groupSymbolSet) { + if (substr($url, -1) == $groupSymbolSet['right']) { + $group_left_count = substr_count($url, $groupSymbolSet['left']); + $group_right_count = substr_count($url, $groupSymbolSet['right']); + if ($group_left_count < $group_right_count) { + --$right; + $url = substr($url, 0, -1); + } + } + } + if (in_array(substr($url, -1), $cannotEndWith)) { + --$right; + $url = substr($url, 0, -1); + } + } while ($original_url != $url); + + $result = call_user_func_array($callback, [$url, $arg]); + return substr($matches[0], 0, $left) . $result . substr($matches[0], $right); + } + + /** + * Convert a plain text $url to HTML + */ + public static function linkify(string $url): string + { + // It comes in special'd, so we unspecial it before passing to the stringifying + // functions + $url = htmlspecialchars_decode($url); + + if (strpos($url, '@') !== false && strpos($url, ':') === false && ($email = filter_var($url, FILTER_VALIDATE_EMAIL)) !== false) { + //url is an email address without the mailto: protocol + $url = "mailto:{$email}"; + } + + $attrs = ['href' => $url, 'title' => $url]; + + // TODO Check to see whether this is a known "attachment" URL. + + // Whether to nofollow + $nf = Common::config('nofollow', 'external'); + if ($nf == 'never') { + $attrs['rel'] = 'external'; + } else { + $attrs['rel'] = 'noopener nofollow external noreferrer'; + } + + return HTML::html(['a' => ['attrs' => $attrs, $url]]); + } + + public static function tagLink(string $tag): string + { + $canonical = self::canonicalTag($tag); + $url = Router::url('tag', ['tag' => $canonical]); + return HTML::html(['span' => ['a' => ['attrs' => ['href' => $url, 'rel' => 'tag']]]]); + } + + public static function canonicalTag(string $tag): string + { + return substr(self::slugify($tag), 0, 64); + } + + /** + * Convert $str to it's closest ASCII representation + */ + public static function slugify(string $str): string + { + // php-intl is highly recommended... + if (!function_exists('transliterator_transliterate')) { + $str = preg_replace('/[^\pL\pN]/u', '', $str); + $str = mb_convert_case($str, MB_CASE_LOWER, 'UTF-8'); + $str = substr($str, 0, 64); + return $str; + } + $str = transliterator_transliterate('Any-Latin;' . // any charset to latin compatible + 'NFD;' . // decompose + '[:Nonspacing Mark:] Remove;' . // remove nonspacing marks (accents etc.) + 'NFC;' . // composite again + '[:Punctuation:] Remove;' . // remove punctuation (.,¿? etc.) + 'Lower();' . // turn into lowercase + 'Latin-ASCII;', // get ASCII equivalents (ð to d for example) + $str); + return preg_replace('/[^\pL\pN]/u', '', $str); + } + + /** + * Find @-mentions in the given text, using the given notice object as context. + * References will be resolved with common_relative_profile() against the user + * who posted the notice. + * + * Note the return data format is internal, to be used for building links and + * such. Should not be used directly; rather, call common_linkify_mentions(). + * + * @param string $text + * @param GSActor $actor the GSActor that is sending the current text + * @param Note $parent the Note this text is in reply to, if any + * + * @return array + * + */ + public static function findMentions(string $text, GSActor $actor, Note $parent = null) + { + $mentions = []; + if (Event::handle('StartFindMentions', [$actor, $text, &$mentions])) { + // Get the context of the original notice, if any + $origMentions = []; + // Does it have a parent notice for context? + if ($parent instanceof Note) { + foreach ($parent->getAttentionProfiles() as $repliedTo) { + if (!$repliedTo->isPerson()) { + continue; + } + $origMentions[$repliedTo->getId()] = $repliedTo; + } + } + + $matches = self::findMentionsRaw($text, '@'); + + foreach ($matches as $match) { + try { + $nickname = Nickname::normalize($match[0], check_already_used: false); + } catch (NicknameException $e) { + // Bogus match? Drop it. + continue; + } + + // primarily mention the profiles mentioned in the parent + $mention_found_in_origMentions = false; + foreach ($origMentions as $origMentionsId => $origMention) { + if ($origMention->getNickname() == $nickname) { + $mention_found_in_origMentions = $origMention; + // don't mention same twice! the parent might have mentioned + // two users with same nickname on different instances + unset($origMentions[$origMentionsId]); + break; + } + } + + // Try to get a profile for this nickname. + // Start with parents mentions, then go to parents sender context + if ($mention_found_in_origMentions) { + $mentioned = $mention_found_in_origMentions; + } elseif ($parent instanceof Note && $parent->getActorNickname() === $nickname) { + $mentioned = $parent->getActor(); + } else { + // sets to null if no match + $mentioned = $actor->findRelativeActor($nickname); + } + + if ($mentioned instanceof GSActor) { + $url = $mentioned->getUri(); // prefer the URI as URL, if it is one. + if (!Common::isValidHttpUrl($url)) { + $url = $mentioned->getUrl(); + } + + $mention = [ + 'mentioned' => [$mentioned], + 'type' => 'mention', + 'text' => $match[0], + 'position' => $match[1], + 'length' => mb_strlen($match[0]), + 'title' => $mentioned->getFullname(), + 'url' => $url, + ]; + + $mentions[] = $mention; + } + } + + // TODO Tag subscriptions + // @#tag => mention of all subscriptions tagged 'tag' + // $tag_matches = []; + // preg_match_all( + // '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/', + // $text, + // $tag_matches, + // PREG_OFFSET_CAPTURE + // ); + // foreach ($tag_matches[1] as $tag_match) { + // $tag = self::canonicalTag($tag_match[0]); + // $plist = Profile_list::getByTaggerAndTag($actor->getID(), $tag); + // if (!$plist instanceof Profile_list || $plist->private) { + // continue; + // } + // $tagged = $actor->getTaggedSubscribers($tag); + // $url = common_local_url( + // 'showprofiletag', + // ['nickname' => $actor->getNickname(), 'tag' => $tag] + // ); + // $mentions[] = ['mentioned' => $tagged, + // 'type' => 'list', + // 'text' => $tag_match[0], + // 'position' => $tag_match[1], + // 'length' => mb_strlen($tag_match[0]), + // 'url' => $url, ]; + // } + + $group_matches = self::findMentionsRaw($text, '!'); + foreach ($group_matches as $group_match) { + $nickname = Nickname::normalize($group_match[0], check_already_used: false); + $group = Group::getFromNickname($nickname, $actor); + + if (!$group instanceof Group) { + continue; + } + + $profile = $group->getActor(); + + $mentions[] = [ + 'mentioned' => [$profile], + 'type' => 'group', + 'text' => $group_match[0], + 'position' => $group_match[1], + 'length' => mb_strlen($group_match[0]), + 'url' => $group->getUri(), + 'title' => $group->getFullname(), + ]; + } + + Event::handle('EndFindMentions', [$actor, $text, &$mentions]); + } + + return $mentions; + } + + /** + * Does the actual regex pulls to find @-mentions in text. + * Should generally not be called directly; for use in common_find_mentions. + * + * @param string $text + * @param string $preMention Character(s) that signals a mention ('@', '!'...) + * + * @return array of PCRE match arrays + */ + private static function findMentionsRaw(string $text, string $preMention = '@'): array + { + $tmatches = []; + preg_match_all( + '/^T (' . Nickname::DISPLAY_FMT . ') /', + $text, + $tmatches, + PREG_OFFSET_CAPTURE + ); + + $atmatches = []; + // the regexp's "(?!\@)" makes sure it doesn't matches the single "@remote" in "@remote@server.com" + preg_match_all( + '/' . Nickname::BEFORE_MENTIONS . preg_quote($preMention, '/') . '(' . Nickname::DISPLAY_FMT . ')\b(?!\@)/', + $text, + $atmatches, + PREG_OFFSET_CAPTURE + ); + + $matches = array_merge($tmatches[1], $atmatches[1]); + return $matches; + } + + /** + * Finds @-mentions within the partially-rendered text section and + * turns them into live links. + * + * Should generally not be called except from common_render_content(). + * + * @param string $text partially-rendered HTML + * @param GSActor $author the GSActor that is composing the current notice + * @param Note $parent the Note this is sent in reply to, if any + * + * @return string partially-rendered HTML + */ + public static function linkifyMentions($text, GSActor $author, ?Note $parent = null) + { + $mentions = self::findMentions($text, $author, $parent); + + // We need to go through in reverse order by position, + // so our positions stay valid despite our fudging with the + // string! + + $points = []; + + foreach ($mentions as $mention) { + $points[$mention['position']] = $mention; + } + + krsort($points); + + foreach ($points as $position => $mention) { + $linkText = self::linkifyMentionArray($mention); + + $text = substr_replace($text, $linkText, $position, $mention['length']); + } + + return $text; + } + + public static function linkifyMentionArray(array $mention) + { + $output = null; + + if (Event::handle('StartLinkifyMention', [$mention, &$output])) { + $attrs = [ + 'href' => $mention['url'], + 'class' => 'h-card u-url p-nickname ' . $mention['type'], + ]; + + if (!empty($mention['title'])) { + $attrs['title'] = $mention['title']; + } + + $output = HTML::html(['a' => ['attrs' => $attrs, $mention['text']]]); + + Event::handle('EndLinkifyMention', [$mention, &$output]); + } + + return $output; + } } diff --git a/templates/note/view.html.twig b/templates/note/view.html.twig index 1c0d647ec0..70c4884253 100644 --- a/templates/note/view.html.twig +++ b/templates/note/view.html.twig @@ -35,32 +35,23 @@ {% endif %} {% if replies is defined and replies is not empty %} @@ -71,6 +62,6 @@ {% endif %} {% if reply_to is not empty %} -
+
{% endif %} - \ No newline at end of file +