diff --git a/mod/ostatus_subscribe.php b/mod/ostatus_subscribe.php deleted file mode 100644 index 5344ed8451..0000000000 --- a/mod/ostatus_subscribe.php +++ /dev/null @@ -1,131 +0,0 @@ -. - * - */ - -use Friendica\App; -use Friendica\Core\Protocol; -use Friendica\DI; -use Friendica\Model\APContact; -use Friendica\Model\Contact; -use Friendica\Network\HTTPClient\Client\HttpClientAccept; -use Friendica\Protocol\ActivityPub; - -function ostatus_subscribe_content(App $a): string -{ - if (!DI::userSession()->getLocalUserId()) { - DI::sysmsg()->addNotice(DI::l10n()->t('Permission denied.')); - DI::baseUrl()->redirect('ostatus_subscribe'); - // NOTREACHED - } - - $o = '
' . $counter . '/' . $total . ': ' . $url; - - $probed = Contact::getByURL($url); - if (in_array($probed['network'], Protocol::FEDERATED)) { - $result = Contact::createFromProbeForUser($a->getLoggedInUserId(), $probed['url']); - if ($result['success']) { - $o .= ' - ' . DI::l10n()->t('success'); - } else { - $o .= ' - ' . DI::l10n()->t('failed'); - } - } else { - $o .= ' - ' . DI::l10n()->t('ignored'); - } - - $o .= '
'; - - $o .= '' . DI::l10n()->t('Keep this window open until done.') . '
'; - - DI::page()['htmlhead'] = ''; - - return $o; -} diff --git a/mod/poco.php b/mod/poco.php deleted file mode 100644 index b4d0f343a2..0000000000 --- a/mod/poco.php +++ /dev/null @@ -1,234 +0,0 @@ -. - * - * @see https://web.archive.org/web/20160405005550/http://portablecontacts.net/draft-spec.html - */ - -use Friendica\App; -use Friendica\Content\Text\BBCode; -use Friendica\Core\Logger; -use Friendica\Core\Protocol; -use Friendica\Core\System; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Util\DateTimeFormat; - -function poco_init(App $a) { - if (intval(DI::config()->get('system', 'block_public')) || (DI::config()->get('system', 'block_local_dir'))) { - throw new \Friendica\Network\HTTPException\ForbiddenException(); - } - - if (DI::args()->getArgc() > 1) { - // Only the system mode is supported - throw new \Friendica\Network\HTTPException\NotFoundException(); - } - - $format = ($_GET['format'] ?? '') ?: 'json'; - - $totalResults = DBA::count('profile', ['net-publish' => true]); - if ($totalResults == 0) { - throw new \Friendica\Network\HTTPException\ForbiddenException(); - } - - if (!empty($_GET['startIndex'])) { - $startIndex = intval($_GET['startIndex']); - } else { - $startIndex = 0; - } - $itemsPerPage = (!empty($_GET['count']) ? intval($_GET['count']) : $totalResults); - - Logger::info("Start system mode query"); - $contacts = DBA::selectToArray('owner-view', [], ['net-publish' => true], ['limit' => [$startIndex, $itemsPerPage]]); - - Logger::info("Query done"); - - $ret = []; - if (!empty($_GET['sorted'])) { - $ret['sorted'] = false; - } - if (!empty($_GET['filtered'])) { - $ret['filtered'] = false; - } - if (!empty($_GET['updatedSince'])) { - $ret['updatedSince'] = false; - } - $ret['startIndex'] = (int) $startIndex; - $ret['itemsPerPage'] = (int) $itemsPerPage; - $ret['totalResults'] = (int) $totalResults; - $ret['entry'] = []; - - $fields_ret = [ - 'id' => false, - 'displayName' => false, - 'urls' => false, - 'updated' => false, - 'preferredUsername' => false, - 'photos' => false, - 'aboutMe' => false, - 'currentLocation' => false, - 'network' => false, - 'tags' => false, - 'address' => false, - 'contactType' => false, - 'generation' => false - ]; - - if (empty($_GET['fields'])) { - foreach ($fields_ret as $k => $v) { - $fields_ret[$k] = true; - } - } else { - $fields_req = explode(',', $_GET['fields']); - foreach ($fields_req as $f) { - $fields_ret[trim($f)] = true; - } - } - - if (!is_array($contacts)) { - throw new \Friendica\Network\HTTPException\InternalServerErrorException(); - } - - if (DBA::isResult($contacts)) { - foreach ($contacts as $contact) { - if (!isset($contact['updated'])) { - $contact['updated'] = ''; - } - - if (! isset($contact['generation'])) { - $contact['generation'] = 1; - } - - if (($contact['keywords'] == "") && isset($contact['pub_keywords'])) { - $contact['keywords'] = $contact['pub_keywords']; - } - if (isset($contact['account-type'])) { - $contact['contact-type'] = $contact['account-type']; - } - - $cacheKey = 'about:' . $contact['nick'] . ':' . DateTimeFormat::utc($contact['updated'], DateTimeFormat::ATOM); - $about = DI::cache()->get($cacheKey); - if (is_null($about)) { - $about = BBCode::convertForUriId($contact['uri-id'], $contact['about']); - DI::cache()->set($cacheKey, $about); - } - - // Non connected persons can only see the keywords of a Diaspora account - if ($contact['network'] == Protocol::DIASPORA) { - $contact['location'] = ""; - $about = ""; - } - - $entry = []; - if ($fields_ret['id']) { - $entry['id'] = (int)$contact['id']; - } - if ($fields_ret['displayName']) { - $entry['displayName'] = $contact['name']; - } - if ($fields_ret['aboutMe']) { - $entry['aboutMe'] = $about; - } - if ($fields_ret['currentLocation']) { - $entry['currentLocation'] = $contact['location']; - } - if ($fields_ret['generation']) { - $entry['generation'] = (int)$contact['generation']; - } - if ($fields_ret['urls']) { - $entry['urls'] = [['value' => $contact['url'], 'type' => 'profile']]; - if ($contact['addr'] && ($contact['network'] !== Protocol::MAIL)) { - $entry['urls'][] = ['value' => 'acct:' . $contact['addr'], 'type' => 'webfinger']; - } - } - if ($fields_ret['preferredUsername']) { - $entry['preferredUsername'] = $contact['nick']; - } - if ($fields_ret['updated']) { - $entry['updated'] = $contact['success_update']; - - if ($contact['name-date'] > $entry['updated']) { - $entry['updated'] = $contact['name-date']; - } - if ($contact['uri-date'] > $entry['updated']) { - $entry['updated'] = $contact['uri-date']; - } - if ($contact['avatar-date'] > $entry['updated']) { - $entry['updated'] = $contact['avatar-date']; - } - $entry['updated'] = date("c", strtotime($entry['updated'])); - } - if ($fields_ret['photos']) { - $entry['photos'] = [['value' => $contact['photo'], 'type' => 'profile']]; - } - if ($fields_ret['network']) { - $entry['network'] = $contact['network']; - if ($entry['network'] == Protocol::STATUSNET) { - $entry['network'] = Protocol::OSTATUS; - } - if (($entry['network'] == "") && ($contact['self'])) { - $entry['network'] = Protocol::DFRN; - } - } - if ($fields_ret['tags']) { - $tags = str_replace(",", " ", $contact['keywords']); - $tags = explode(" ", $tags); - - $cleaned = []; - foreach ($tags as $tag) { - $tag = trim(strtolower($tag)); - if ($tag != "") { - $cleaned[] = $tag; - } - } - - $entry['tags'] = [$cleaned]; - } - if ($fields_ret['address']) { - $entry['address'] = []; - - if (isset($contact['locality'])) { - $entry['address']['locality'] = $contact['locality']; - } - - if (isset($contact['region'])) { - $entry['address']['region'] = $contact['region']; - } - - if (isset($contact['country'])) { - $entry['address']['country'] = $contact['country']; - } - } - - if ($fields_ret['contactType']) { - $entry['contactType'] = intval($contact['contact-type']); - } - $ret['entry'][] = $entry; - } - } else { - $ret['entry'][] = []; - } - - Logger::info("End of poco"); - - if ($format === 'json') { - System::jsonExit($ret); - } else { - throw new \Friendica\Network\HTTPException\UnsupportedMediaTypeException(); - } -} diff --git a/mod/pubsub.php b/mod/pubsub.php deleted file mode 100644 index a740b7b81d..0000000000 --- a/mod/pubsub.php +++ /dev/null @@ -1,158 +0,0 @@ -. - * - */ - -use Friendica\App; -use Friendica\Core\Logger; -use Friendica\Core\Protocol; -use Friendica\Core\System; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Model\Contact; -use Friendica\Protocol\OStatus; -use Friendica\Util\Strings; -use Friendica\Util\Network; -use Friendica\Model\GServer; -use Friendica\Model\Post; - -function hub_return($valid, $body) -{ - if ($valid) { - echo $body; - } else { - throw new \Friendica\Network\HTTPException\NotFoundException(); - } - System::exit(); -} - -// when receiving an XML feed, always return OK - -function hub_post_return() -{ - throw new \Friendica\Network\HTTPException\OKException(); -} - -function pubsub_init(App $a) -{ - $nick = ((DI::args()->getArgc() > 1) ? trim(DI::args()->getArgv()[1]) : ''); - $contact_id = ((DI::args()->getArgc() > 2) ? intval(DI::args()->getArgv()[2]) : 0 ); - - if (DI::args()->getMethod() === App\Router::GET) { - $hub_mode = trim($_GET['hub_mode'] ?? ''); - $hub_topic = trim($_GET['hub_topic'] ?? ''); - $hub_challenge = trim($_GET['hub_challenge'] ?? ''); - $hub_verify = trim($_GET['hub_verify_token'] ?? ''); - - Logger::notice('Subscription from ' . $_SERVER['REMOTE_ADDR'] . ' Mode: ' . $hub_mode . ' Nick: ' . $nick); - Logger::debug('Data: ', ['get' => $_GET]); - - $subscribe = (($hub_mode === 'subscribe') ? 1 : 0); - - $owner = DBA::selectFirst('user', ['uid'], ['nickname' => $nick, 'account_expired' => false, 'account_removed' => false]); - if (!DBA::isResult($owner)) { - Logger::notice('Local account not found: ' . $nick); - hub_return(false, ''); - } - - $condition = ['uid' => $owner['uid'], 'id' => $contact_id, 'blocked' => false, 'pending' => false]; - - if (!empty($hub_verify)) { - $condition['hub-verify'] = $hub_verify; - } - - $contact = DBA::selectFirst('contact', ['id', 'poll'], $condition); - if (!DBA::isResult($contact)) { - Logger::notice('Contact ' . $contact_id . ' not found.'); - hub_return(false, ''); - } - - if (!empty($hub_topic) && !Strings::compareLink($hub_topic, $contact['poll'])) { - Logger::notice('Hub topic ' . $hub_topic . ' != ' . $contact['poll']); - hub_return(false, ''); - } - - // We must initiate an unsubscribe request with a verify_token. - // Don't allow outsiders to unsubscribe us. - - if (($hub_mode === 'unsubscribe') && empty($hub_verify)) { - Logger::notice('Bogus unsubscribe'); - hub_return(false, ''); - } - - if (!empty($hub_mode)) { - Contact::update(['subhub' => $subscribe], ['id' => $contact['id']]); - Logger::notice($hub_mode . ' success for contact ' . $contact_id . '.'); - } - hub_return(true, $hub_challenge); - } -} - -function pubsub_post(App $a) -{ - $xml = Network::postdata(); - - Logger::info('Feed arrived from ' . $_SERVER['REMOTE_ADDR'] . ' for ' . DI::args()->getCommand() . ' with user-agent: ' . $_SERVER['HTTP_USER_AGENT']); - Logger::debug('Data: ' . $xml); - - $nick = ((DI::args()->getArgc() > 1) ? trim(DI::args()->getArgv()[1]) : ''); - $contact_id = ((DI::args()->getArgc() > 2) ? intval(DI::args()->getArgv()[2]) : 0 ); - - $importer = DBA::selectFirst('user', [], ['nickname' => $nick, 'account_expired' => false, 'account_removed' => false]); - if (!DBA::isResult($importer)) { - hub_post_return(); - } - - $condition = ['id' => $contact_id, 'uid' => $importer['uid'], 'subhub' => true, 'blocked' => false]; - $contact = DBA::selectFirst('contact', [], $condition); - - if (!DBA::isResult($contact)) { - $author = OStatus::salmonAuthor($xml, $importer); - if (!empty($author['contact-id'])) { - $condition = ['id' => $author['contact-id'], 'uid' => $importer['uid'], 'subhub' => true, 'blocked' => false]; - $contact = DBA::selectFirst('contact', [], $condition); - Logger::notice('No record for ' . $nick .' with contact id ' . $contact_id . ' - using '.$author['contact-id'].' instead.'); - } - if (!DBA::isResult($contact)) { - Logger::notice('Contact ' . $author["author-link"] . ' (' . $contact_id . ') for user ' . $nick . " wasn't found - ignored. XML: " . $xml); - hub_post_return(); - } - } - - if (!empty($contact['gsid'])) { - GServer::setProtocol($contact['gsid'], Post\DeliveryData::OSTATUS); - } - - if (!in_array($contact['rel'], [Contact::SHARING, Contact::FRIEND]) && ($contact['network'] != Protocol::FEED)) { - Logger::notice('Contact ' . $contact['id'] . ' is not expected to share with us - ignored.'); - hub_post_return(); - } - - // We only import feeds from OStatus here - if (!in_array($contact['network'], [Protocol::ACTIVITYPUB, Protocol::OSTATUS])) { - Logger::warning('Unexpected network', ['contact' => $contact]); - hub_post_return(); - } - - Logger::info('Import item for ' . $nick . ' from ' . $contact['nick'] . ' (' . $contact['id'] . ')'); - $feedhub = ''; - OStatus::import($xml, $importer, $contact, $feedhub); - - hub_post_return(); -} diff --git a/mod/pubsubhubbub.php b/mod/pubsubhubbub.php deleted file mode 100644 index 5dda9ee0af..0000000000 --- a/mod/pubsubhubbub.php +++ /dev/null @@ -1,147 +0,0 @@ -. - * - */ - -use Friendica\App; -use Friendica\Core\Logger; -use Friendica\Core\System; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Model\PushSubscriber; -use Friendica\Util\Strings; - -function pubsubhubbub_init(App $a) { - // PuSH subscription must be considered "public" so just block it - // if public access isn't enabled. - if (DI::config()->get('system', 'block_public')) { - throw new \Friendica\Network\HTTPException\ForbiddenException(); - } - - // Subscription request from subscriber - // https://pubsubhubbub.googlecode.com/git/pubsubhubbub-core-0.4.html#anchor4 - // Example from GNU Social: - // [hub_mode] => subscribe - // [hub_callback] => http://status.local/main/push/callback/1 - // [hub_verify] => sync - // [hub_verify_token] => af11... - // [hub_secret] => af11... - // [hub_topic] => http://friendica.local/dfrn_poll/sazius - - if (DI::args()->getMethod() === App\Router::POST) { - $hub_mode = $_POST['hub_mode'] ?? ''; - $hub_callback = $_POST['hub_callback'] ?? ''; - $hub_verify_token = $_POST['hub_verify_token'] ?? ''; - $hub_secret = $_POST['hub_secret'] ?? ''; - $hub_topic = $_POST['hub_topic'] ?? ''; - - // check for valid hub_mode - if ($hub_mode === 'subscribe') { - $subscribe = 1; - } elseif ($hub_mode === 'unsubscribe') { - $subscribe = 0; - } else { - Logger::notice("Invalid hub_mode=$hub_mode, ignoring."); - throw new \Friendica\Network\HTTPException\NotFoundException(); - } - - Logger::info("$hub_mode request from " . $_SERVER['REMOTE_ADDR']); - - if (DI::args()->getArgc() > 1) { - // Normally the url should now contain the nick name as last part of the url - $nick = DI::args()->getArgv()[1]; - } else { - // Get the nick name from the topic as a fallback - $nick = $hub_topic; - } - // Extract nick name and strip any .atom extension - $nick = basename($nick, '.atom'); - - if (!$nick) { - Logger::notice('Bad hub_topic=$hub_topic, ignoring.'); - throw new \Friendica\Network\HTTPException\NotFoundException(); - } - - // fetch user from database given the nickname - $condition = ['nickname' => $nick, 'account_expired' => false, 'account_removed' => false]; - $owner = DBA::selectFirst('user', ['uid', 'nickname'], $condition); - if (!DBA::isResult($owner)) { - Logger::notice('Local account not found: ' . $nick . ' - topic: ' . $hub_topic . ' - callback: ' . $hub_callback); - throw new \Friendica\Network\HTTPException\NotFoundException(); - } - - // get corresponding row from contact table - $condition = ['uid' => $owner['uid'], 'blocked' => false, - 'pending' => false, 'self' => true]; - $contact = DBA::selectFirst('contact', ['poll'], $condition); - if (!DBA::isResult($contact)) { - Logger::notice('Self contact for user ' . $owner['uid'] . ' not found.'); - throw new \Friendica\Network\HTTPException\NotFoundException(); - } - - // sanity check that topic URLs are the same - $hub_topic2 = str_replace('/feed/', '/dfrn_poll/', $hub_topic); - $self = DI::baseUrl() . '/api/statuses/user_timeline/' . $owner['nickname'] . '.atom'; - - if (!Strings::compareLink($hub_topic, $contact['poll']) && !Strings::compareLink($hub_topic2, $contact['poll']) && !Strings::compareLink($hub_topic, $self)) { - Logger::notice('Hub topic ' . $hub_topic . ' != ' . $contact['poll']); - throw new \Friendica\Network\HTTPException\NotFoundException(); - } - - // do subscriber verification according to the PuSH protocol - $hub_challenge = Strings::getRandomHex(40); - - $params = http_build_query([ - 'hub.mode' => $subscribe == 1 ? 'subscribe' : 'unsubscribe', - 'hub.topic' => $hub_topic, - 'hub.challenge' => $hub_challenge, - 'hub.verify_token' => $hub_verify_token, - - // lease time is hard coded to one week (in seconds) - // we don't actually enforce the lease time because GNU - // Social/StatusNet doesn't honour it (yet) - 'hub.lease_seconds' => 604800, - ]); - - $hub_callback = rtrim($hub_callback, ' ?'); - $separator = parse_url($hub_callback, PHP_URL_QUERY) === null ? '?' : '&'; - - $fetchResult = DI::httpClient()->fetchFull($hub_callback . $separator . $params); - $body = $fetchResult->getBody(); - $ret = $fetchResult->getReturnCode(); - - // give up if the HTTP return code wasn't a success (2xx) - if ($ret < 200 || $ret > 299) { - Logger::notice("Subscriber verification for $hub_topic at $hub_callback returned $ret, ignoring."); - throw new \Friendica\Network\HTTPException\NotFoundException(); - } - - // check that the correct hub_challenge code was echoed back - if (trim($body) !== $hub_challenge) { - Logger::notice("Subscriber did not echo back hub.challenge, ignoring."); - Logger::notice("\"$hub_challenge\" != \"".trim($body)."\""); - throw new \Friendica\Network\HTTPException\NotFoundException(); - } - - PushSubscriber::renew($owner['uid'], $nick, $subscribe, $hub_callback, $hub_topic, $hub_secret); - - throw new \Friendica\Network\HTTPException\AcceptedException(); - } - System::exit(); -} diff --git a/src/Model/Contact.php b/src/Model/Contact.php index ee74cb1574..8239708e1c 100644 --- a/src/Model/Contact.php +++ b/src/Model/Contact.php @@ -729,7 +729,6 @@ class Contact 'notify' => DI::baseUrl() . '/dfrn_notify/' . $user['nickname'], 'poll' => DI::baseUrl() . '/dfrn_poll/' . $user['nickname'], 'confirm' => DI::baseUrl() . '/dfrn_confirm/' . $user['nickname'], - 'poco' => DI::baseUrl() . '/poco/' . $user['nickname'], 'name-date' => DateTimeFormat::utcNow(), 'uri-date' => DateTimeFormat::utcNow(), 'avatar-date' => DateTimeFormat::utcNow(), @@ -811,7 +810,6 @@ class Contact 'notify' => DI::baseUrl() . '/dfrn_notify/' . $user['nickname'], 'poll' => DI::baseUrl() . '/dfrn_poll/'. $user['nickname'], 'confirm' => DI::baseUrl() . '/dfrn_confirm/' . $user['nickname'], - 'poco' => DI::baseUrl() . '/poco/' . $user['nickname'], ]; diff --git a/src/Module/OStatus/PubSub.php b/src/Module/OStatus/PubSub.php new file mode 100644 index 0000000000..c6305ec77a --- /dev/null +++ b/src/Module/OStatus/PubSub.php @@ -0,0 +1,159 @@ +. + * + */ + +namespace Friendica\Module\OStatus; + +use Friendica\App; +use Friendica\Core\L10n; +use Friendica\Core\Protocol; +use Friendica\Core\System; +use Friendica\Database\Database; +use Friendica\Model\Contact; +use Friendica\Model\GServer; +use Friendica\Model\Post; +use Friendica\Module\Response; +use Friendica\Network\HTTPException; +use Friendica\Protocol\OStatus; +use Friendica\Util\Network; +use Friendica\Util\Profiler; +use Friendica\Util\Strings; +use Psr\Log\LoggerInterface; + +class PubSub extends \Friendica\BaseModule +{ + /** @var Database */ + private $database; + /** @var App\Request */ + private $request; + + public function __construct(App\Request $request, Database $database, L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, Response $response, array $server, array $parameters = []) + { + parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters); + + $this->database = $database; + $this->request = $request; + } + + protected function post(array $request = []) + { + $xml = Network::postdata(); + + $this->logger->info('Feed arrived.', ['from' => $this->request->getRemoteAddress(), 'for' => $this->args->getCommand(), 'user-agent' => $this->server['HTTP_USER_AGENT']]); + $this->logger->debug('Data stream.', ['xml' => $xml]); + + $nickname = $this->parameters['nickname'] ?? ''; + $contact_id = $this->parameters['cid'] ?? 0; + + $importer = $this->database->selectFirst('user', [], ['nickname' => $nickname, 'account_expired' => false, 'account_removed' => false]); + if (!$importer) { + throw new HTTPException\OKException(); + } + + $condition = ['id' => $contact_id, 'uid' => $importer['uid'], 'subhub' => true, 'blocked' => false]; + $contact = $this->database->selectFirst('contact', [], $condition); + if (!$contact) { + $author = OStatus::salmonAuthor($xml, $importer); + if (!empty($author['contact-id'])) { + $condition = ['id' => $author['contact-id'], 'uid' => $importer['uid'], 'subhub' => true, 'blocked' => false]; + $contact = $this->database->selectFirst('contact', [], $condition); + $this->logger->notice('No record found for nickname, using author entry instead.', ['nickname' => $nickname, 'contact-id' => $contact_id, 'author-contact-id' => $author['contact-id']]); + } + + if (!$contact) { + $this->logger->notice("Contact wasn't found - ignored.", ['author-link' => $author['author-link'], 'contact-id' => $contact_id, 'nickname' => $nickname, 'xml' => $xml]); + throw new HTTPException\OKException(); + } + } + + if (!empty($contact['gsid'])) { + GServer::setProtocol($contact['gsid'], Post\DeliveryData::OSTATUS); + } + + if (!in_array($contact['rel'], [Contact::SHARING, Contact::FRIEND]) && ($contact['network'] != Protocol::FEED)) { + $this->logger->notice('Contact is not expected to share with us - ignored.', ['contact-id' => $contact['id']]); + throw new HTTPException\OKException(); + } + + // We only import feeds from OStatus here + if (!in_array($contact['network'], [Protocol::ACTIVITYPUB, Protocol::OSTATUS])) { + $this->logger->warning('Unexpected network', ['contact' => $contact, 'network' => $contact['network']]); + throw new HTTPException\OKException(); + } + + $this->logger->info('Import item from Contact.', ['nickname' => $nickname, 'contact-nickname' => $contact['nick'], 'contact-id' => $contact['id']]); + $feedhub = ''; + OStatus::import($xml, $importer, $contact, $feedhub); + + throw new HTTPException\OKException(); + } + + protected function rawContent(array $request = []) + { + $nickname = $this->parameters['nickname'] ?? ''; + $contact_id = $this->parameters['cid'] ?? 0; + + $hub_mode = trim($request['hub_mode'] ?? ''); + $hub_topic = trim($request['hub_topic'] ?? ''); + $hub_challenge = trim($request['hub_challenge'] ?? ''); + $hub_verify = trim($request['hub_verify_token'] ?? ''); + + $this->logger->notice('Subscription start.', ['from' => $this->request->getRemoteAddress(), 'mode' => $hub_mode, 'nickname' => $nickname]); + $this->logger->debug('Data: ', ['get' => $request]); + + $owner = $this->database->selectFirst('user', ['uid'], ['nickname' => $nickname, 'account_expired' => false, 'account_removed' => false]); + if (!$owner) { + $this->logger->notice('Local account not found.', ['nickname' => $nickname]); + throw new HTTPException\NotFoundException(); + } + + $condition = ['uid' => $owner['uid'], 'id' => $contact_id, 'blocked' => false, 'pending' => false]; + + if (!empty($hub_verify)) { + $condition['hub-verify'] = $hub_verify; + } + + $contact = $this->database->selectFirst('contact', ['id', 'poll'], $condition); + if (!$contact) { + $this->logger->notice('Contact not found.', ['contact' => $contact_id]); + throw new HTTPException\NotFoundException(); + } + + if (!empty($hub_topic) && !Strings::compareLink($hub_topic, $contact['poll'])) { + $this->logger->notice("Hub topic isn't valid for Contact.", ['hub_topic' => $hub_topic, 'contact_poll' => $contact['poll']]); + throw new HTTPException\NotFoundException(); + } + + // We must initiate an unsubscribe request with a verify_token. + // Don't allow outsiders to unsubscribe us. + + if (($hub_mode === 'unsubscribe') && empty($hub_verify)) { + $this->logger->notice('Bogus unsubscribe'); + throw new HTTPException\NotFoundException(); + } + + if (!empty($hub_mode)) { + Contact::update(['subhub' => $hub_mode === 'subscribe'], ['id' => $contact['id']]); + $this->logger->notice('Success for contact.', ['mode' => $hub_mode, 'contact' => $contact_id]); + } + + System::httpExit($hub_challenge); + } +} diff --git a/src/Module/OStatus/PubSubHubBub.php b/src/Module/OStatus/PubSubHubBub.php new file mode 100644 index 0000000000..8fb7d88622 --- /dev/null +++ b/src/Module/OStatus/PubSubHubBub.php @@ -0,0 +1,174 @@ +. + * + */ + +namespace Friendica\Module\OStatus; + +use Friendica\App; +use Friendica\Core\Config\Capability\IManageConfigValues; +use Friendica\Core\L10n; +use Friendica\Database\Database; +use Friendica\Model\PushSubscriber; +use Friendica\Module\Response; +use Friendica\Network\HTTPClient\Capability\ICanSendHttpRequests; +use Friendica\Network\HTTPException; +use Friendica\Util\Profiler; +use Friendica\Util\Strings; +use Psr\Log\LoggerInterface; + +/** + * An open, simple, web-scale and decentralized pubsub protocol. + * + * Part of the OStatus stack. + * + * See https://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html + * + * @version 0.4 + */ +class PubSubHubBub extends \Friendica\BaseModule +{ + /** @var IManageConfigValues */ + private $config; + /** @var Database */ + private $database; + /** @var ICanSendHttpRequests */ + private $httpClient; + /** @var App\Request */ + private $request; + + public function __construct(App\Request $request, ICanSendHttpRequests $httpClient, Database $database, IManageConfigValues $config, L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, Response $response, array $server, array $parameters = []) + { + parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters); + + $this->config = $config; + $this->database = $database; + $this->httpClient = $httpClient; + $this->request = $request; + } + + protected function post(array $request = []) + { + // PuSH subscription must be considered "public" so just block it + // if public access isn't enabled. + if ($this->config->get('system', 'block_public')) { + throw new HTTPException\ForbiddenException(); + } + + // Subscription request from subscriber + // https://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html#rfc.section.5.1 + // Example from GNU Social: + // [hub_mode] => subscribe + // [hub_callback] => http://status.local/main/push/callback/1 + // [hub_verify] => sync + // [hub_verify_token] => af11... + // [hub_secret] => af11... + // [hub_topic] => http://friendica.local/dfrn_poll/sazius + + $hub_mode = $request['hub_mode'] ?? ''; + $hub_callback = $request['hub_callback'] ?? ''; + $hub_verify_token = $request['hub_verify_token'] ?? ''; + $hub_secret = $request['hub_secret'] ?? ''; + $hub_topic = $request['hub_topic'] ?? ''; + + // check for valid hub_mode + if ($hub_mode === 'subscribe') { + $subscribe = 1; + } elseif ($hub_mode === 'unsubscribe') { + $subscribe = 0; + } else { + $this->logger->notice('Invalid hub_mod - ignored.', ['mode' => $hub_mode]); + throw new HTTPException\NotFoundException(); + } + + $this->logger->info('hub_mode request details.', ['from' => $this->request->getRemoteAddress(), 'mode' => $hub_mode]); + + $nickname = $this->parameters['nickname'] ?? $hub_topic; + + // Extract nickname and strip any .atom extension + $nickname = basename($nickname, '.atom'); + if (!$nickname) { + $this->logger->notice('Empty nick, ignoring.'); + throw new HTTPException\NotFoundException(); + } + + // fetch user from database given the nickname + $condition = ['nickname' => $nickname, 'account_expired' => false, 'account_removed' => false]; + $owner = $this->database->selectFirst('user', ['uid', 'nickname'], $condition); + if (!$owner) { + $this->logger->notice('Local account not found', ['nickname' => $nickname, 'topic' => $hub_topic, 'callback' => $hub_callback]); + throw new HTTPException\NotFoundException(); + } + + // get corresponding row from contact table + $condition = ['uid' => $owner['uid'], 'blocked' => false, 'pending' => false, 'self' => true]; + $contact = $this->database->selectFirst('contact', ['poll'], $condition); + if (!$contact) { + $this->logger->notice('Self contact for user not found.', ['uid' => $owner['uid']]); + throw new HTTPException\NotFoundException(); + } + + // sanity check that topic URLs are the same + $hub_topic2 = str_replace('/feed/', '/dfrn_poll/', $hub_topic); + $self = $this->baseUrl . '/api/statuses/user_timeline/' . $owner['nickname'] . '.atom'; + + if (!Strings::compareLink($hub_topic, $contact['poll']) && !Strings::compareLink($hub_topic2, $contact['poll']) && !Strings::compareLink($hub_topic, $self)) { + $this->logger->notice('Hub topic invalid', ['hub_topic' => $hub_topic, 'poll' => $contact['poll']]); + throw new HTTPException\NotFoundException(); + } + + // do subscriber verification according to the PuSH protocol + $hub_challenge = Strings::getRandomHex(40); + + $params = http_build_query([ + 'hub.mode' => $subscribe == 1 ? 'subscribe' : 'unsubscribe', + 'hub.topic' => $hub_topic, + 'hub.challenge' => $hub_challenge, + 'hub.verify_token' => $hub_verify_token, + + // lease time is hard coded to one week (in seconds) + // we don't actually enforce the lease time because GNU + // Social/StatusNet doesn't honour it (yet) + 'hub.lease_seconds' => 604800, + ]); + + $hub_callback = rtrim($hub_callback, ' ?'); + $separator = parse_url($hub_callback, PHP_URL_QUERY) === null ? '?' : '&'; + + $fetchResult = $this->httpClient->fetchFull($hub_callback . $separator . $params); + $body = $fetchResult->getBody(); + $returnCode = $fetchResult->getReturnCode(); + + // give up if the HTTP return code wasn't a success (2xx) + if ($returnCode < 200 || $returnCode > 299) { + $this->logger->notice('Subscriber verification ignored', ['hub_topic' => $hub_topic, 'callback' => $hub_callback, 'returnCode' => $returnCode]); + throw new HTTPException\NotFoundException(); + } + + // check that the correct hub_challenge code was echoed back + if (trim($body) !== $hub_challenge) { + $this->logger->notice('Subscriber did not echo back hub.challenge, ignoring.', ['hub_challenge' => $hub_challenge, 'body' => trim($body)]); + throw new HTTPException\NotFoundException(); + } + + PushSubscriber::renew($owner['uid'], $nickname, $subscribe, $hub_callback, $hub_topic, $hub_secret); + + throw new HTTPException\AcceptedException(); + } +} diff --git a/src/Module/OStatus/Subscribe.php b/src/Module/OStatus/Subscribe.php new file mode 100644 index 0000000000..52bd19aef2 --- /dev/null +++ b/src/Module/OStatus/Subscribe.php @@ -0,0 +1,164 @@ +. + * + */ + +namespace Friendica\Module\OStatus; + +use Friendica\App; +use Friendica\Core\L10n; +use Friendica\Core\PConfig\Capability\IManagePersonalConfigValues; +use Friendica\Core\Protocol; +use Friendica\Core\Session\Capability\IHandleUserSessions; +use Friendica\Model\APContact; +use Friendica\Model\Contact; +use Friendica\Module\Response; +use Friendica\Navigation\SystemMessages; +use Friendica\Network\HTTPClient\Capability\ICanSendHttpRequests; +use Friendica\Network\HTTPClient\Client\HttpClientAccept; +use Friendica\Protocol\ActivityPub; +use Friendica\Util\Profiler; +use Psr\Log\LoggerInterface; + +class Subscribe extends \Friendica\BaseModule +{ + /** @var IHandleUserSessions */ + private $session; + /** @var SystemMessages */ + private $systemMessages; + /** @var ICanSendHttpRequests */ + private $httpClient; + /** @var IManagePersonalConfigValues */ + private $pConfig; + /** @var App\Page */ + private $page; + + public function __construct(App\Page $page, IManagePersonalConfigValues $pConfig, ICanSendHttpRequests $httpClient, SystemMessages $systemMessages, IHandleUserSessions $session, L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, Response $response, array $server, array $parameters = []) + { + parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters); + + $this->session = $session; + $this->systemMessages = $systemMessages; + $this->httpClient = $httpClient; + $this->pConfig = $pConfig; + $this->page = $page; + } + + protected function content(array $request = []): string + { + if (!$this->session->getLocalUserId()) { + $this->systemMessages->addNotice($this->t('Permission denied.')); + $this->baseUrl->redirect('login'); + } + + $o = '' . $counter . '/' . $total . ': ' . $url; + + $probed = Contact::getByURL($url); + if (in_array($probed['network'], Protocol::FEDERATED)) { + $result = Contact::createFromProbeForUser($this->session->getLocalUserId(), $probed['url']); + if ($result['success']) { + $o .= ' - ' . $this->t('success'); + } else { + $o .= ' - ' . $this->t('failed'); + } + } else { + $o .= ' - ' . $this->t('ignored'); + } + + $o .= '
'; + + $o .= '' . $this->t('Keep this window open until done.') . '
'; + + $this->page['htmlhead'] = ''; + + return $o; + } +} diff --git a/src/Module/Profile/Profile.php b/src/Module/Profile/Profile.php index a602660935..07db082591 100644 --- a/src/Module/Profile/Profile.php +++ b/src/Module/Profile/Profile.php @@ -334,7 +334,6 @@ class Profile extends BaseProfile foreach ($dfrn_pages as $dfrn) { $htmlhead .= '' . "\n"; } - $htmlhead .= '' . "\n"; return $htmlhead; } diff --git a/src/Module/Settings/Connectors.php b/src/Module/Settings/Connectors.php index 969d74d054..acd638829a 100644 --- a/src/Module/Settings/Connectors.php +++ b/src/Module/Settings/Connectors.php @@ -140,7 +140,7 @@ class Connectors extends BaseSettings $legacy_contact = $this->pconfig->get($this->session->getLocalUserId(), 'ostatus', 'legacy_contact'); if (!empty($legacy_contact)) { - $this->baseUrl->redirect('ostatus_subscribe?url=' . urlencode($legacy_contact)); + $this->baseUrl->redirect('ostatus/subscribe?url=' . urlencode($legacy_contact)); } $connector_settings_forms = []; diff --git a/src/Module/User/PortableContacts.php b/src/Module/User/PortableContacts.php new file mode 100644 index 0000000000..6629994810 --- /dev/null +++ b/src/Module/User/PortableContacts.php @@ -0,0 +1,277 @@ +. + * + * @see https://web.archive.org/web/20160405005550/http://portablecontacts.net/draft-spec.html + */ + +namespace Friendica\Module\User; + +use Friendica\App; +use Friendica\BaseModule; +use Friendica\Content\Text\BBCode; +use Friendica\Core\Cache\Capability\ICanCache; +use Friendica\Core\Config\Capability\IManageConfigValues; +use Friendica\Core\L10n; +use Friendica\Core\Protocol; +use Friendica\Core\System; +use Friendica\Database\Database; +use Friendica\Module\Response; +use Friendica\Network\HTTPException; +use Friendica\Util\DateTimeFormat; +use Friendica\Util\Profiler; +use Psr\Log\LoggerInterface; + +/** + * Minimal implementation of the Portable Contacts protocol + * @see https://portablecontacts.github.io + */ +class PortableContacts extends BaseModule +{ + /** @var IManageConfigValues */ + private $config; + /** @var Database */ + private $database; + /** @var ICanCache */ + private $cache; + + public function __construct(ICanCache $cache, Database $database, IManageConfigValues $config, L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, Response $response, array $server, array $parameters = []) + { + parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters); + + $this->config = $config; + $this->database = $database; + $this->cache = $cache; + } + + protected function rawContent(array $request = []) + { + if ($this->config->get('system', 'block_public') || $this->config->get('system', 'block_local_dir')) { + throw new HTTPException\ForbiddenException(); + } + + $format = $request['format'] ?? 'json'; + if ($format !== 'json') { + throw new HTTPException\UnsupportedMediaTypeException(); + } + + $totalResults = $this->database->count('profile', ['net-publish' => true]); + if (!$totalResults) { + throw new HTTPException\ForbiddenException(); + } + + if (!empty($request['startIndex']) && is_numeric($request['startIndex'])) { + $startIndex = intval($request['startIndex']); + } else { + $startIndex = 0; + } + + $itemsPerPage = !empty($request['count']) && is_numeric($request['count']) ? intval($request['count']) : $totalResults; + + $this->logger->info('Start system mode query'); + $contacts = $this->database->selectToArray('owner-view', [], ['net-publish' => true], ['limit' => [$startIndex, $itemsPerPage]]); + $this->logger->info('Query done'); + + $return = []; + if (!empty($request['sorted'])) { + $return['sorted'] = false; + } + + if (!empty($request['filtered'])) { + $return['filtered'] = false; + } + + if (!empty($request['updatedSince'])) { + $return['updatedSince'] = false; + } + + $return['startIndex'] = $startIndex; + $return['itemsPerPage'] = $itemsPerPage; + $return['totalResults'] = $totalResults; + + $return['entry'] = []; + + $selectedFields = [ + 'id' => false, + 'displayName' => false, + 'urls' => false, + 'updated' => false, + 'preferredUsername' => false, + 'photos' => false, + 'aboutMe' => false, + 'currentLocation' => false, + 'network' => false, + 'tags' => false, + 'address' => false, + 'contactType' => false, + 'generation' => false + ]; + + if (empty($request['fields']) || $request['fields'] == '@all') { + foreach ($selectedFields as $k => $v) { + $selectedFields[$k] = true; + } + } else { + $fields_req = explode(',', $request['fields']); + foreach ($fields_req as $f) { + $selectedFields[trim($f)] = true; + } + } + + if (!$contacts) { + $return['entry'][] = []; + } + + foreach ($contacts as $contact) { + if (!isset($contact['updated'])) { + $contact['updated'] = ''; + } + + if (!isset($contact['generation'])) { + $contact['generation'] = 1; + } + + if (empty($contact['keywords']) && isset($contact['pub_keywords'])) { + $contact['keywords'] = $contact['pub_keywords']; + } + + if (isset($contact['account-type'])) { + $contact['contact-type'] = $contact['account-type']; + } + + $cacheKey = 'about:' . $contact['nick'] . ':' . DateTimeFormat::utc($contact['updated'], DateTimeFormat::ATOM); + $about = $this->cache->get($cacheKey); + if (is_null($about)) { + $about = BBCode::convertForUriId($contact['uri-id'], $contact['about']); + $this->cache->set($cacheKey, $about); + } + + // Non connected persons can only see the keywords of a Diaspora account + if ($contact['network'] == Protocol::DIASPORA) { + $contact['location'] = ''; + $about = ''; + } + + $entry = []; + if ($selectedFields['id']) { + $entry['id'] = (int)$contact['id']; + } + + if ($selectedFields['displayName']) { + $entry['displayName'] = $contact['name']; + } + + if ($selectedFields['aboutMe']) { + $entry['aboutMe'] = $about; + } + + if ($selectedFields['currentLocation']) { + $entry['currentLocation'] = $contact['location']; + } + + if ($selectedFields['generation']) { + $entry['generation'] = (int)$contact['generation']; + } + + if ($selectedFields['urls']) { + $entry['urls'] = [['value' => $contact['url'], 'type' => 'profile']]; + if ($contact['addr'] && ($contact['network'] !== Protocol::MAIL)) { + $entry['urls'][] = ['value' => 'acct:' . $contact['addr'], 'type' => 'webfinger']; + } + } + + if ($selectedFields['preferredUsername']) { + $entry['preferredUsername'] = $contact['nick']; + } + + if ($selectedFields['updated']) { + $entry['updated'] = $contact['success_update']; + + if ($contact['name-date'] > $entry['updated']) { + $entry['updated'] = $contact['name-date']; + } + + if ($contact['uri-date'] > $entry['updated']) { + $entry['updated'] = $contact['uri-date']; + } + + if ($contact['avatar-date'] > $entry['updated']) { + $entry['updated'] = $contact['avatar-date']; + } + + $entry['updated'] = date('c', strtotime($entry['updated'])); + } + + if ($selectedFields['photos']) { + $entry['photos'] = [['value' => $contact['photo'], 'type' => 'profile']]; + } + + if ($selectedFields['network']) { + $entry['network'] = $contact['network']; + if ($entry['network'] == Protocol::STATUSNET) { + $entry['network'] = Protocol::OSTATUS; + } + + if (($entry['network'] == '') && ($contact['self'])) { + $entry['network'] = Protocol::DFRN; + } + } + + if ($selectedFields['tags']) { + $tags = str_replace(',', ' ', $contact['keywords']); + $tags = explode(' ', $tags); + + $cleaned = []; + foreach ($tags as $tag) { + $tag = trim(strtolower($tag)); + if ($tag != '') { + $cleaned[] = $tag; + } + } + + $entry['tags'] = [$cleaned]; + } + + if ($selectedFields['address']) { + $entry['address'] = []; + + if (isset($contact['locality'])) { + $entry['address']['locality'] = $contact['locality']; + } + + if (isset($contact['region'])) { + $entry['address']['region'] = $contact['region']; + } + + if (isset($contact['country'])) { + $entry['address']['country'] = $contact['country']; + } + } + + if ($selectedFields['contactType']) { + $entry['contactType'] = intval($contact['contact-type']); + } + + $return['entry'][] = $entry; + } + + $this->logger->info('End of poco'); + + System::jsonExit($return); + } +} diff --git a/src/Module/Xrd.php b/src/Module/Xrd.php index 4e4603fbdb..29641482f6 100644 --- a/src/Module/Xrd.php +++ b/src/Module/Xrd.php @@ -184,10 +184,6 @@ class Xrd extends BaseModule 'type' => 'text/html', 'href' => $baseURL . '/hcard/' . $owner['nickname'], ], - [ - 'rel' => ActivityNamespace::POCO, - 'href' => $owner['poco'], - ], [ 'rel' => 'http://webfinger.net/rel/avatar', 'type' => $avatar['type'], @@ -272,56 +268,50 @@ class Xrd extends BaseModule ] ], '5:link' => [ - '@attributes' => [ - 'rel' => 'http://portablecontacts.net/spec/1.0', - 'href' => $owner['poco'] - ] - ], - '6:link' => [ '@attributes' => [ 'rel' => 'http://webfinger.net/rel/avatar', 'type' => $avatar['type'], 'href' => User::getAvatarUrl($owner) ] ], - '7:link' => [ + '6:link' => [ '@attributes' => [ 'rel' => 'http://joindiaspora.com/seed_location', 'type' => 'text/html', 'href' => $baseURL ] ], - '8:link' => [ + '7:link' => [ '@attributes' => [ 'rel' => 'salmon', 'href' => $baseURL . '/salmon/' . $owner['nickname'] ] ], - '9:link' => [ + '8:link' => [ '@attributes' => [ 'rel' => 'http://salmon-protocol.org/ns/salmon-replies', 'href' => $baseURL . '/salmon/' . $owner['nickname'] ] ], - '10:link' => [ + '9:link' => [ '@attributes' => [ 'rel' => 'http://salmon-protocol.org/ns/salmon-mention', 'href' => $baseURL . '/salmon/' . $owner['nickname'] . '/mention' ] ], - '11:link' => [ + '10:link' => [ '@attributes' => [ 'rel' => 'http://ostatus.org/schema/1.0/subscribe', 'template' => $baseURL . '/contact/follow?url={uri}' ] ], - '12:link' => [ + '11:link' => [ '@attributes' => [ 'rel' => 'magic-public-key', 'href' => 'data:application/magic-public-key,' . Salmon::salmonKey($owner['spubkey']) ] ], - '13:link' => [ + '12:link' => [ '@attributes' => [ 'rel' => 'http://purl.org/openwebauth/v1', 'type' => 'application/x-zot+json', diff --git a/static/routes.config.php b/static/routes.config.php index 43fafb1672..d5bed27569 100644 --- a/static/routes.config.php +++ b/static/routes.config.php @@ -526,8 +526,6 @@ return [ '/openid' => [Module\Security\OpenID::class, [R::GET]], '/opensearch' => [Module\OpenSearch::class, [R::GET]], - '/ostatus/repair' => [Module\OStatus\Repair::class, [R::GET]], - '/parseurl' => [Module\ParseUrl::class, [R::GET]], '/permission/tooltip/{type}/{id:\d+}' => [Module\PermissionTooltip::class, [R::GET]], @@ -569,7 +567,13 @@ return [ '/{sub1}/{sub2}/{url}' => [Module\Proxy::class, [R::GET]], ], - '/salmon/{nickname}' => [Module\OStatus\Salmon::class, [ R::POST]], + // OStatus stack modules + '/ostatus/repair' => [Module\OStatus\Repair::class, [R::GET ]], + '/ostatus/subscribe' => [Module\OStatus\Subscribe::class, [R::GET ]], + '/poco' => [Module\User\PortableContacts::class, [R::GET ]], + '/pubsub/{nickname}/{cid:\d+}' => [Module\OStatus\PubSub::class, [R::GET, R::POST]], + '/pubsubhubbub/{nickname}' => [Module\OStatus\PubSubHubBub::class, [ R::POST]], + '/salmon/{nickname}' => [Module\OStatus\Salmon::class, [ R::POST]], '/search' => [ '[/]' => [Module\Search\Index::class, [R::GET ]], diff --git a/view/lang/C/messages.po b/view/lang/C/messages.po index 2e85a8b109..80b062030f 100644 --- a/view/lang/C/messages.po +++ b/view/lang/C/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: 2022.12-dev\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-11-09 21:27+0000\n" +"POT-Creation-Date: 2022-11-11 00:42-0500\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME